fix(env): validate Docker-compatible variable keys

Add shared environment variable key validation and normalization for Livewire forms and models, allowing Docker-compatible keys while rejecting invalid entries such as keys containing equals signs. Also quote Railpack build environment and secret arguments safely.
This commit is contained in:
Andras Bacsai 2026-05-11 15:43:09 +02:00
parent d5946dcfca
commit b5ff124446
11 changed files with 294 additions and 57 deletions

View file

@ -2570,7 +2570,7 @@ private function railpack_build_environment_prefix(Collection $variables): strin
return 'env '.$variables
->map(function ($value, $key) {
return "{$key}=".escapeShellValue($value);
return escapeShellValue("{$key}={$value}");
})
->implode(' ').' ';
}
@ -2583,7 +2583,7 @@ private function railpack_build_secret_flags(Collection $variables): string
return ' '.$variables
->map(function ($value, $key) {
return "--secret id={$key},env={$key}";
return '--secret '.escapeShellValue("id={$key},env={$key}");
})
->implode(' ');
}

View file

@ -2,9 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -37,15 +42,23 @@ class Add extends Component
protected $listeners = ['clearAddEnv' => 'clear'];
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'comment' => 'nullable|string|max:256',
];
protected function rules(): array
{
return [
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'comment' => 'nullable|string|max:256',
];
}
protected function messages(): array
{
return ValidationPatterns::environmentVariableKeyMessages('key');
}
protected $validationAttributes = [
'key' => 'key',
@ -85,7 +98,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@ -116,12 +129,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@ -131,7 +144,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
$server = \App\Models\Server::where('team_id', $team->id)
$server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@ -141,7 +154,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -149,7 +162,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
$application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
$application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@ -160,7 +173,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -168,7 +181,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
$service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
$service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@ -179,7 +192,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -192,6 +205,7 @@ public function availableSharedVariables(): array
public function submit()
{
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -38,7 +40,7 @@ public function mount()
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [\App\Models\Application::class];
$resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
@ -194,7 +196,7 @@ public function submit($data = null)
private function updateOrder()
{
$variables = parseEnvFormatToArray($this->variables);
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$order = 1;
foreach ($variables as $key => $value) {
$env = $this->resource->environment_variables()->where('key', $key)->first();
@ -206,7 +208,7 @@ private function updateOrder()
}
if ($this->showPreview) {
$previewVariables = parseEnvFormatToArray($this->variablesPreview);
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
$order = 1;
foreach ($previewVariables as $key => $value) {
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
@ -221,7 +223,7 @@ private function updateOrder()
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@ -241,7 +243,7 @@ private function handleBulkSubmit()
}
if ($this->showPreview) {
$previewVariables = parseEnvFormatToArray($this->variablesPreview);
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@ -267,6 +269,7 @@ private function handleBulkSubmit()
private function handleSingleSubmit($data)
{
$data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
if ($found) {
$this->dispatch('error', 'Environment variable already exists.');
@ -334,6 +337,23 @@ private function deleteRemovedVariables($isPreview, $variables)
return $variablesToDelete->count();
}
private function normalizeEnvironmentVariables(array $variables): array
{
$normalizedVariables = [];
foreach ($variables as $key => $data) {
$normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
if (array_key_exists($normalizedKey, $normalizedVariables)) {
throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
}
$normalizedVariables[$normalizedKey] = $data;
}
return $normalizedVariables;
}
private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;

View file

@ -2,12 +2,17 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -64,23 +69,31 @@ class Show extends Component
'compose_loaded' => '$refresh',
];
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
protected function rules(): array
{
return [
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
}
protected function messages(): array
{
return ValidationPatterns::environmentVariableKeyMessages('key');
}
public function mount()
{
$this->syncData();
if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
$this->isSharedVariable = true;
}
$this->parameters = get_route_parameters();
@ -108,9 +121,11 @@ public function refresh()
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
if ($this->isSharedVariable) {
$this->validate([
'key' => 'required|string',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
@ -233,7 +248,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@ -264,12 +279,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@ -279,7 +294,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
$server = \App\Models\Server::where('team_id', $team->id)
$server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@ -289,7 +304,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -297,7 +312,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
$application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
$application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@ -308,7 +323,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -316,7 +331,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
$service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
$service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@ -327,7 +342,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@ -370,7 +371,7 @@ private function set_environment_variables(?string $environment_variable = null)
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
);
}

View file

@ -2,6 +2,8 @@
namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
@ -33,6 +35,13 @@ class SharedEnvironmentVariable extends Model
'value' => 'encrypted',
];
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
);
}
public function team()
{
return $this->belongsTo(Team::class);

View file

@ -82,6 +82,12 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for Docker-compatible environment variable keys.
* Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
*/
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
@ -96,6 +102,67 @@ class ValidationPatterns
*/
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Normalize environment variable keys before validation and storage.
*/
public static function normalizeEnvironmentVariableKey(string $value): string
{
return str($value)->trim()->value;
}
/**
* Get validation rules for environment variable keys.
*/
public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN;
return $rules;
}
/**
* Get validation messages for environment variable key fields.
*/
public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
{
return [
"{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Check if a string is a valid environment variable key.
*/
public static function isValidEnvironmentVariableKey(string $value): bool
{
return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
}
/**
* Normalize and validate an environment variable key.
*/
public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string
{
$key = self::normalizeEnvironmentVariableKey($value);
if (! self::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']);
}
return $key;
}
/**
* Get validation rules for database identifier fields (username, database name).
*

View file

@ -0,0 +1,39 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Livewire\Livewire;
it('rejects environment variable keys Docker cannot represent in the add form', function () {
Livewire::test(Add::class)
->set('key', 'BAD=KEY')
->set('value', 'value')
->call('submit')
->assertHasErrors(['key' => 'regex']);
});
it('allows Docker-compatible environment variable keys in the add form', function (string $key) {
Livewire::test(Add::class)
->set('key', $key)
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) use ($key) {
return data_get($data, 'key') === $key || data_get($data, '0.key') === $key;
});
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
]);
it('trims surrounding whitespace in environment variable keys in the add form', function () {
Livewire::test(Add::class)
->set('key', ' node.name ')
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) {
return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name';
});
});

View file

@ -225,14 +225,14 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $
],
);
expect($command)->toContain("env RAILPACK_NODE_VERSION='22'");
expect($command)->toContain("RAILPACK_INSTALL_CMD='npm ci && npm run postinstall'");
expect($command)->toContain("RAILPACK_DEPLOY_APT_PACKAGES='curl wget'");
expect($command)->toContain("SECRET_JSON='{\"token\":\"abc\"}'");
expect($command)->toContain('--secret id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION');
expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD');
expect($command)->toContain('--secret id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES');
expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON');
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'");
expect($command)->toContain(' --build-arg secrets-hash=');
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
});

View file

@ -1,6 +1,7 @@
<?php
use App\Models\EnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
it('flags NIXPACKS_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
@ -35,3 +36,39 @@
expect($env->getAppends())->toContain('is_buildpack_control');
expect($env->getAppends())->not->toContain('is_nixpacks');
});
it('normalizes environment variable keys before storing them on the model', function () {
$env = new EnvironmentVariable;
$env->key = ' node.name ';
expect($env->key)->toBe('node.name');
});
it('allows Docker-compatible environment variable keys on the model', function (string $key) {
$env = new EnvironmentVariable;
$env->key = $key;
expect($env->key)->toBe($key);
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
]);
it('rejects environment variable keys Docker cannot represent on the model', function () {
$env = new EnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});
it('rejects shared environment variable keys Docker cannot represent on the model', function () {
$env = new SharedEnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});

View file

@ -130,3 +130,38 @@
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('accepts Docker-compatible environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue();
})->with([
'letters' => 'APP_ENV',
'leading underscore' => '_TOKEN',
'railpack control variable' => 'RAILPACK_NODE_VERSION',
'digits after first character' => 'NODE_VERSION_20',
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
]);
it('rejects environment variable keys Docker cannot represent', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
})->with([
'equals' => 'BAD=KEY',
'empty' => '',
]);
it('generates environment variable key rules with correct defaults', function () {
$rules = ValidationPatterns::environmentVariableKeyRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN);
});
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
});