From b5ff124446b9bd5bdecdf9f69777dab0454f952e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 15:43:09 +0200 Subject: [PATCH] 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. --- app/Jobs/ApplicationDeploymentJob.php | 4 +- .../Shared/EnvironmentVariable/Add.php | 50 +++++++++----- .../Shared/EnvironmentVariable/All.php | 30 +++++++-- .../Shared/EnvironmentVariable/Show.php | 61 ++++++++++------- app/Models/EnvironmentVariable.php | 3 +- app/Models/SharedEnvironmentVariable.php | 9 +++ app/Support/ValidationPatterns.php | 67 +++++++++++++++++++ .../EnvironmentVariableKeyValidationTest.php | 39 +++++++++++ ...pplicationDeploymentRailpackConfigTest.php | 16 ++--- ...nvironmentVariableBuildpackControlTest.php | 37 ++++++++++ tests/Unit/ValidationPatternsTest.php | 35 ++++++++++ 11 files changed, 294 insertions(+), 57 deletions(-) create mode 100644 tests/Feature/EnvironmentVariableKeyValidationTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index beb1f5c05..663499cee 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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(' '); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index c51b27b6a..1dcb7c781 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -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, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f250a860b..53b55009e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -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; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 4e8521f27..26369852e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -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 } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index ac0d238b3..dcbdad253 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -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), ); } diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index fa6fd45e0..eadc33ec2 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -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); diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 58dbbe1ac..07926e1cf 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -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). * diff --git a/tests/Feature/EnvironmentVariableKeyValidationTest.php b/tests/Feature/EnvironmentVariableKeyValidationTest.php new file mode 100644 index 000000000..4f41ed3d6 --- /dev/null +++ b/tests/Feature/EnvironmentVariableKeyValidationTest.php @@ -0,0 +1,39 @@ +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'; + }); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 361ca666b..15bee488c 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -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').'"'); }); diff --git a/tests/Unit/EnvironmentVariableBuildpackControlTest.php b/tests/Unit/EnvironmentVariableBuildpackControlTest.php index 1a277bcdd..c24956c0d 100644 --- a/tests/Unit/EnvironmentVariableBuildpackControlTest.php +++ b/tests/Unit/EnvironmentVariableBuildpackControlTest.php @@ -1,6 +1,7 @@ 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'); +}); diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php index 9ecffe46d..092c37b25 100644 --- a/tests/Unit/ValidationPatternsTest.php +++ b/tests/Unit/ValidationPatternsTest.php @@ -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'); +});