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 committed 2026-05-11 15:43:09 +02:00
1 parent d5946dcfca
commit b5ff124446
11 files changed
+294 -57

No files matched your search

+2 -2
View File
@@ -2570,7 +2570,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return 'env '.$variables
->map(function ($value, $key) {
return "{$key}=".escapeShellValue($value);
return escapeShellValue("{$key}={$value}");
})
->implode(' ').' ';
}
@@ -2583,7 +2583,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return ' '.$variables
->map(function ($value, $key) {
return "--secret id={$key},env={$key}";
return '--secret '.escapeShellValue("id={$key},env={$key}");
})
->implode(' ');
}
@@ -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 @@ class Add extends Component
$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 @@ class Add extends Component
$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 @@ class Add extends Component
$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 @@ class Add extends Component
$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 @@ class Add extends Component
// 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 @@ class Add extends Component
$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 @@ class Add extends Component
// 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 @@ class Add extends Component
$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 @@ class Add extends Component
public function submit()
{
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,
@@ -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 @@ class All extends Component
$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 @@ class All extends Component
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 @@ class All extends Component
}
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 @@ class All extends Component
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -241,7 +243,7 @@ class All extends Component
}
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 @@ class All extends Component
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 @@ class All extends Component
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;
@@ -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 @@ class Show extends Component
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 @@ class Show extends Component
$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 @@ class Show extends Component
$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 @@ class Show extends Component
$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 @@ class Show extends Component
$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 @@ class Show extends Component
// 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 @@ class Show extends Component
$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 @@ class Show extends Component
// 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 @@ class Show extends Component
$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
}
}
+2 -1
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 @@ class EnvironmentVariable extends BaseModel
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
);
}
+9
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);
+67
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).
*