Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai committed 2026-07-07 12:56:19 +02:00
commit ff976a134f
136 files changed
+6142 -1733

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
<?php
use App\Models\Server;
it('normalizes requested log line counts', function () {
if (! function_exists('normalizeLogLines')) {
expect(function_exists('normalizeLogLines'))->toBeTrue();
return;
}
expect(normalizeLogLines(null))->toBe(100)
->and(normalizeLogLines(''))->toBe(100)
->and(normalizeLogLines('abc'))->toBe(100)
->and(normalizeLogLines('0'))->toBe(100)
->and(normalizeLogLines('-5'))->toBe(100)
->and(normalizeLogLines('50'))->toBe(50)
->and(normalizeLogLines('50000'))->toBe(10000);
});
it('parses show_timestamps query values as booleans', function () {
if (! function_exists('parseLogTimestampFlag')) {
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
return;
}
expect(parseLogTimestampFlag('true'))->toBeTrue()
->and(parseLogTimestampFlag('1'))->toBeTrue()
->and(parseLogTimestampFlag(true))->toBeTrue()
->and(parseLogTimestampFlag('false'))->toBeFalse()
->and(parseLogTimestampFlag('0'))->toBeFalse()
->and(parseLogTimestampFlag(false))->toBeFalse()
->and(parseLogTimestampFlag('not-a-bool'))->toBeFalse()
->and(parseLogTimestampFlag(null))->toBeFalse();
});
it('builds docker log commands with options before an escaped container id', function () {
if (! function_exists('buildContainerLogsCommand')) {
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
return;
}
$server = new Server;
$server->settings = ['is_swarm_manager' => false];
expect(buildContainerLogsCommand($server, 'container-1', 25, true))
->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
});
it('builds swarm service log commands with options before an escaped service id', function () {
if (! function_exists('buildContainerLogsCommand')) {
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
return;
}
$server = new Server;
$server->settings = ['is_swarm_manager' => true];
expect(buildContainerLogsCommand($server, "service'name", 25, true))
->toBe("docker service logs -n 25 --timestamps 'service'\\''name' 2>&1");
});
it('filters service sub containers in PHP instead of using user input in shell filters', function () {
if (! function_exists('filterServiceSubContainersByName')) {
expect(function_exists('filterServiceSubContainersByName'))->toBeTrue();
return;
}
$containers = collect([
['ID' => 'first', 'Labels' => 'coolify.serviceId=10,coolify.name=app-service-uuid,coolify.type=service'],
['ID' => 'second', 'Labels' => 'coolify.serviceId=10,coolify.name=db-service-uuid,coolify.type=service'],
['ID' => 'third', 'Labels' => ['coolify.name' => 'app-service-uuid']],
]);
expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all())
->toBe(['first', 'third']);
});
it('does not interpolate the requested service name into the docker ps shell command', function () {
$source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php');
expect($source)->not->toContain('coolify.name={$name}');
});
@@ -0,0 +1,121 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
function setDeploymentJobProperty(object $job, string $property, mixed $value): void
{
$reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
$reflectionProperty->setValue($job, $value);
}
function getDeploymentJobProperty(object $job, string $property): mixed
{
$reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
return $reflectionProperty->getValue($job);
}
function invokeCheckGitIfBuildNeeded(object $job): void
{
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed');
$method->invoke($job);
}
function makeDeploymentJobForCommitCheck(string $pinnedSha, string $branchHeadSha): object
{
$job = new class extends ApplicationDeploymentJob
{
public function __construct() {}
public function execute_remote_command(...$commands) {}
};
$application = new class extends Application
{
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
{
return [
'commands' => collect([]),
'branch' => 'main',
'fullRepoUrl' => 'https://github.com/coollabsio/coolify.git',
];
}
};
$application->forceFill([
'uuid' => 'application-uuid',
'git_branch' => 'main',
]);
$application->setRelation('settings', (object) [
'include_source_commit_in_build' => false,
'use_build_secrets' => false,
]);
$application->setRelation('private_key', null);
$deploymentQueue = new class extends ApplicationDeploymentQueue
{
public bool $saved = false;
public function save(array $options = []): bool
{
$this->saved = true;
return true;
}
};
$deploymentQueue->commit = $pinnedSha;
setDeploymentJobProperty($job, 'application', $application);
setDeploymentJobProperty($job, 'application_deployment_queue', $deploymentQueue);
setDeploymentJobProperty($job, 'deployment_uuid', 'deployment-uuid');
setDeploymentJobProperty($job, 'pull_request_id', 0);
setDeploymentJobProperty($job, 'commit', $pinnedSha);
setDeploymentJobProperty($job, 'rollback', false);
setDeploymentJobProperty($job, 'git_type', 'github');
setDeploymentJobProperty($job, 'saved_outputs', collect([
'git_commit_sha' => str("{$branchHeadSha}\trefs/heads/main"),
]));
return $job;
}
function shouldResolveBranchHeadForCommit(?string $commit): bool
{
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$commitProperty = new ReflectionProperty($job, 'commit');
$commitProperty->setValue($job, $commit ?? '');
$method = new ReflectionMethod($job, 'shouldResolveBranchHeadCommit');
return $method->invoke($job);
}
describe('ApplicationDeploymentJob commit resolution', function () {
test('resolves branch head for HEAD deployments', function () {
expect(shouldResolveBranchHeadForCommit('HEAD'))->toBeTrue();
});
test('resolves branch head for blank deployments', function () {
expect(shouldResolveBranchHeadForCommit(''))->toBeTrue();
});
test('keeps pinned deployment commits instead of replacing them with branch head', function () {
expect(shouldResolveBranchHeadForCommit('abc123def456abc123def456abc123def456abc1'))->toBeFalse();
});
test('check git does not overwrite pinned deployment commit with branch head', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$branchHeadSha = '111222333444555666777888999000aaabbbccc1';
$job = makeDeploymentJobForCommitCheck($pinnedSha, $branchHeadSha);
invokeCheckGitIfBuildNeeded($job);
$deploymentQueue = getDeploymentJobProperty($job, 'application_deployment_queue');
expect(getDeploymentJobProperty($job, 'commit'))->toBe($pinnedSha)
->and($deploymentQueue->commit)->toBe($pinnedSha)
->and($deploymentQueue->saved)->toBeFalse();
});
});
@@ -218,6 +218,75 @@ it('fails fast when docker buildx is unavailable for railpack builds', function
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
});
it('checks buildx inside the helper container before railpack builds', function () {
[$job, $reflection] = makeRailpackDeploymentJob([], [
'railpack_helper_buildx_available' => 'available',
]);
invokeRailpackMethod($job, $reflection, 'ensure_helper_docker_buildx_available_for_railpack');
expect($job->recordedCommands[0][0][0])
->toContain('DOCKER_CONFIG=/root/.docker docker buildx version');
});
it('fails clearly when buildx is missing inside the helper container', function () {
[$job, $reflection] = makeRailpackDeploymentJob([], [
'railpack_helper_buildx_available' => 'not-available',
]);
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_helper_docker_buildx_available_for_railpack'))
->toThrow(DeploymentException::class, 'helper container');
});
it('pins docker config while running railpack buildx commands', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([]),
],
);
expect($command)
->toContain('DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack')
->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack');
});
it('filters reserved docker client variables from railpack build secrets', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'DOCKER_CONFIG' => '/tmp/no-buildx',
'DOCKER_HOST' => 'tcp://invalid:2375',
'RAILPACK_NODE_VERSION' => '22',
'APP_ENV' => 'production',
]),
],
);
expect($command)
->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'")
->toContain("--secret 'id=APP_ENV,env=APP_ENV'")
->not->toContain('id=DOCKER_CONFIG')
->not->toContain('id=DOCKER_HOST')
->not->toContain("env 'DOCKER_CONFIG=")
->not->toContain("env 'DOCKER_HOST=");
});
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
@@ -95,6 +95,6 @@ it('ensures docker_compose_raw update is wrapped in try-catch for error handling
->toContain('// This keeps the original user input clean while preventing content reapplication')
->toContain('try {')
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('} catch (\Exception $e) {')
->toContain("ray('Failed to update docker_compose_raw");
->toContain('} catch (Exception) {')
->toContain('// If parsing fails, keep the original docker_compose_raw unchanged');
});
+86
View File
@@ -0,0 +1,86 @@
<?php
use App\Models\GithubApp;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class);
it('classifies github hosts', function () {
expect(isGithubDotComHost('https://github.com'))->toBeTrue()
->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue()
->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue()
->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue()
->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue()
->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse();
});
it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) {
expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl);
})->with([
'github.com' => ['https://github.com', 'https://api.github.com'],
'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'],
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
]);
it('rejects malformed urls when deriving an origin', function (string $url) {
expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
})->with([
'blank' => [''],
'scheme-less' => ['github.company.internal'],
'malformed' => ['not-a-url'],
]);
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
$githubApp = new GithubApp;
$githubApp->forceFill(array_merge([
'id' => 123,
'name' => 'Coolify Test App',
'team_id' => 456,
], $attributes));
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$state = $query['state'] ?? null;
expect($installationUrl)->toStartWith($expectedPrefix)
->and($state)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $state)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
})->with([
'github.com' => [
['html_url' => 'https://github.com'],
'https://github.com/apps/coolify-test-app/installations/new?',
],
'ghe.com organization' => [
['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'],
'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?',
],
'ghe.com blank organization fallback' => [
['html_url' => 'https://octocorp.ghe.com', 'organization' => null],
'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?',
],
'github enterprise server' => [
['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'],
'https://github.company.internal/github-apps/coolify-test-app/installations/new?',
],
]);
it('encodes organization path segments in settings links', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'name' => 'coolify-app',
'organization' => 'octo+corp',
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => 123,
]);
expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions');
});
+29
View File
@@ -0,0 +1,29 @@
<?php
it('accepts hostnames containing underscores', function () {
// Regression: PHP's FILTER_VALIDATE_URL rejects underscores in the host,
// which blocked valid service domains (e.g. Docker service naming) from
// being saved and getting Let's Encrypt certificates. See issue #10597.
expect(isValidDomainUrl('https://myapp_service.example.com'))->toBeTrue();
expect(isValidDomainUrl('http://my_app.example.com'))->toBeTrue();
expect(isValidDomainUrl('https://a_b_c.example.com/path'))->toBeTrue();
});
it('accepts ordinary domains and URLs', function () {
expect(isValidDomainUrl('https://example.com'))->toBeTrue();
expect(isValidDomainUrl('http://sub.example.com:8080/path?q=1'))->toBeTrue();
expect(isValidDomainUrl('https://example.com/a_b'))->toBeTrue();
});
it('rejects strings that are not valid URLs', function () {
expect(isValidDomainUrl('not a url'))->toBeFalse();
expect(isValidDomainUrl('example.com'))->toBeFalse();
expect(isValidDomainUrl('ht_tp://example.com'))->toBeFalse();
expect(isValidDomainUrl(''))->toBeFalse();
});
it('rejects URLs that do not use HTTP or HTTPS schemes', function () {
expect(isValidDomainUrl('ftp://example.com'))->toBeFalse();
expect(isValidDomainUrl('javascript://example.com'))->toBeFalse();
expect(isValidDomainUrl('data://example.com'))->toBeFalse();
});
+53
View File
@@ -0,0 +1,53 @@
<?php
function rayRemovalFiles(string $directory): array
{
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
$files = [];
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
sort($files);
return $files;
}
function rayRemovalBasePath(string $path = ''): string
{
return dirname(__DIR__, 2).($path === '' ? '' : DIRECTORY_SEPARATOR.$path);
}
it('does not include Ray as a dependency', function () {
$composer = json_decode(file_get_contents(rayRemovalBasePath('composer.json')), true, flags: JSON_THROW_ON_ERROR);
$lock = json_decode(file_get_contents(rayRemovalBasePath('composer.lock')), true, flags: JSON_THROW_ON_ERROR);
$lockedPackageNames = collect([
...($lock['packages'] ?? []),
...($lock['packages-dev'] ?? []),
])->pluck('name');
expect($composer['require'] ?? [])->not->toHaveKey('spatie/laravel-ray')
->and($composer['require-dev'] ?? [])->not->toHaveKey('spatie/laravel-ray')
->and($lockedPackageNames)->not->toContain('spatie/laravel-ray', 'spatie/ray');
});
it('does not ship Ray configuration', function () {
expect(file_exists(rayRemovalBasePath('config/ray.php')))->toBeFalse();
});
it('does not call ray from application code', function () {
$files = [
...rayRemovalFiles(rayRemovalBasePath('app')),
...rayRemovalFiles(rayRemovalBasePath('bootstrap/helpers')),
];
$rayCalls = collect($files)
->filter(fn (string $file): bool => preg_match('/\bray\s*\(/', file_get_contents($file)) === 1)
->map(fn (string $file): string => str_replace(rayRemovalBasePath().'/', '', $file))
->values();
expect($rayCalls)->toBeEmpty();
});
+22 -9
View File
@@ -1,21 +1,23 @@
<?php
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
uses(TestCase::class, RefreshDatabase::class);
/**
* Regression tests for SSRF via S3 Storage endpoint.
* Regression tests for S3 Storage endpoint validation.
*
* 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) {
it('rejects disallowed targets on the S3 endpoint', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
@@ -43,19 +45,15 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
it('accepts real-world S3 endpoints', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]],
);
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',
'Custom public domain S3-compatible endpoint' => 'https://example.com',
]);
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
@@ -91,3 +89,18 @@ it('blocks testConnection() for loopback endpoints', function (string $endpoint)
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
'internal TLD' => 'http://backend.internal',
]);
it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
);
expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}");
})->with([
'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']],
'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
]);
+174 -13
View File
@@ -1,13 +1,15 @@
<?php
use App\Models\InstanceSettings;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
uses(TestCase::class, RefreshDatabase::class);
it('accepts valid public URLs', function () {
$rule = new SafeWebhookUrl;
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
$validUrls = [
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
@@ -22,17 +24,6 @@ it('accepts valid public URLs', function () {
}
});
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;
@@ -117,3 +108,173 @@ it('rejects IPv6 loopback', function () {
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
it('rejects private and reserved network targets by default', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
})->with([
'private 10/8' => 'http://10.0.0.5/webhook',
'private 172.16/12' => 'http://172.16.0.1:8080/hook',
'private 192.168/16' => 'http://192.168.1.50:8080/webhook',
'shared address space' => 'http://100.64.0.1/webhook',
'zero network peer alias' => 'http://0.0.0.1/webhook',
'multicast' => 'http://224.0.0.1/webhook',
'benchmark range' => 'http://198.18.0.1/webhook',
'documentation range' => 'http://192.0.2.10/webhook',
]);
it('rejects hostname forms that resolve to loopback', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}");
})->with([
'decimal IPv4' => 'http://2130706433:8888/exfil',
'hex IPv4' => 'http://0x7f000001:8888/exfil',
'octal IPv4' => 'http://017700000001:8888/exfil',
'short dotted IPv4' => 'http://127.1:8888/exfil',
'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil',
]);
it('rejects internal DNS suffixes by default', function (string $url) {
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
})->with([
'.local host' => 'http://receiver.local/webhook',
'.cluster.local host' => 'http://service.cluster.local/webhook',
]);
it('rejects unresolvable hostnames by default', function () {
$rule = new SafeWebhookUrl(fn (string $host): array => []);
$validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
});
it('keeps webhook DNS resolution enabled when general DNS validation is disabled', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['is_dns_validation_enabled' => false]));
$rule = new SafeWebhookUrl(fn (string $host): array => ['127.0.0.1']);
$validator = Validator::make(['url' => 'http://rebinding.example.test/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected webhook SSRF DNS checks to remain enabled');
});
it('reads configured custom DNS servers for webhook hostname resolution', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, invalid, 2606:4700:4700::1111']));
$method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers');
$method->setAccessible(true);
expect($method->invoke(new SafeWebhookUrl))
->toBe(['1.1.1.1', '2606:4700:4700::1111']);
});
it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}");
})->with([
'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']],
'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']],
]);
it('requires explicit localhost opt in in addition to allowlist', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']]));
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true]));
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in');
});
it('builds HTTP client options that pin resolved DNS for the request', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
$options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook');
expect($options['allow_redirects'])->toBeFalse();
if (defined('CURLOPT_RESOLVE')) {
expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
}
});
it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
->toThrow(RuntimeException::class, 'unsafe IP address');
});
it('builds MinIO client resolve options for S3 backup uploads', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
expect($options)->toContain('localhost:9000=127.0.0.1');
});
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
$validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection');
expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook'))
->toThrow(RuntimeException::class, 'trailing dot');
});
it('rejects reserved IPv6 ranges by default', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}");
})->with([
'documentation IPv6' => 'http://[2001:db8::1]/webhook',
'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook',
'6to4' => 'http://[2002::1]/webhook',
]);
it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) {
$rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]);
$validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}");
})->with([
'2001:db8::1',
'64:ff9b::1',
'2002::1',
]);
it('redacts webhook URLs for logs', function () {
expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
->toBe('https://hooks.slack.com');
});
@@ -0,0 +1,37 @@
<?php
use App\Http\Controllers\Api\ServicesController;
use App\Models\Service;
it('does not treat service URLs with different path casing as duplicates', function () {
$controller = new ServicesController;
$method = new ReflectionMethod($controller, 'applyServiceUrls');
$service = new class extends Service
{
public function applications()
{
return new class
{
public function where(string $column, mixed $value): self
{
return $this;
}
public function first(): null
{
return null;
}
};
}
};
$result = $method->invoke($controller, $service, [
['name' => 'web', 'url' => 'https://example.com/Route'],
['name' => 'api', 'url' => 'HTTPS://EXAMPLE.COM/route'],
], '1');
expect($result['errors'] ?? [])->toBe([
"Service container with 'web' not found.",
"Service container with 'api' not found.",
]);
});
+231 -44
View File
@@ -1,21 +1,43 @@
<?php
use App\Actions\Server\UpdateCoolify;
use App\Livewire\Settings\Updates;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
beforeEach(function () {
// Mock Server
$this->mockServer = Mockery::mock(Server::class)->makePartial();
$this->mockServer->id = 0;
uses(TestCase::class, RefreshDatabase::class);
// Mock InstanceSettings
$this->settings = Mockery::mock(InstanceSettings::class);
$this->settings->is_auto_update_enabled = true;
$this->settings->shouldReceive('save')->andReturn(true);
});
function updateCoolifyTestCreateRootServerAndSettings(array $settings = []): void
{
Team::factory()->create(['id' => 0]);
Server::forceCreate([
'id' => 0,
'name' => 'localhost',
'ip' => '127.0.0.1',
'user' => 'root',
'team_id' => 0,
'private_key_id' => 1,
]);
InstanceSettings::forceCreate(array_merge([
'id' => 0,
'is_auto_update_enabled' => true,
'auto_update_frequency' => '0 0 * * *',
'update_check_frequency' => '0 * * * *',
], $settings));
Once::flush();
}
afterEach(function () {
Mockery::close();
@@ -26,15 +48,7 @@ it('has UpdateCoolify action class', function () {
});
it('validates cache against running version before fallback', function () {
// Mock Server::find to return our mock server
Server::shouldReceive('find')
->with(0)
->andReturn($this->mockServer);
// Mock instanceSettings
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
updateCoolifyTestCreateRootServerAndSettings();
// CDN fails
Http::fake(['*' => Http::response(null, 500)]);
@@ -51,7 +65,7 @@ it('validates cache against running version before fallback', function () {
try {
$action->handle(manual_update: false);
expect(false)->toBeTrue('Expected exception was not thrown');
} catch (\Exception $e) {
} catch (Exception $e) {
expect($e->getMessage())->toContain('cache version');
expect($e->getMessage())->toContain('4.0.5');
expect($e->getMessage())->toContain('4.0.10');
@@ -59,15 +73,9 @@ it('validates cache against running version before fallback', function () {
});
it('uses validated cache when CDN fails and cache is newer', function () {
// Mock Server::find
Server::shouldReceive('find')
->with(0)
->andReturn($this->mockServer);
// Mock instanceSettings
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
updateCoolifyTestCreateRootServerAndSettings();
Queue::fake();
config(['constants.ssh.mux_enabled' => false]);
// CDN fails
Http::fake(['*' => Http::response(null, 500)]);
@@ -78,12 +86,9 @@ it('uses validated cache when CDN fails and cache is newer', function () {
config(['constants.coolify.version' => '4.0.5']);
// Mock the update method to prevent actual update
$action = Mockery::mock(UpdateCoolify::class)->makePartial();
$action->shouldReceive('update')->once();
$action->server = $this->mockServer;
$action = new UpdateCoolify;
\Illuminate\Support\Facades\Log::shouldReceive('warning')
Log::shouldReceive('warning')
->once()
->with('Failed to fetch fresh version from CDN, using validated cache', Mockery::type('array'));
@@ -93,16 +98,198 @@ it('uses validated cache when CDN fails and cache is newer', function () {
expect($action->latestVersion)->toBe('4.0.10');
});
it('prevents downgrade even with manual update', function () {
// Mock Server::find
Server::shouldReceive('find')
->with(0)
->andReturn($this->mockServer);
it('passes the saved registry URL to the upgrade script command', function () {
Queue::fake();
config([
'app.env' => 'testing',
'constants.coolify.version' => '4.0.9',
'constants.coolify.helper_version' => '1.0.14',
'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/upgrade.sh',
'constants.ssh.mux_enabled' => false,
]);
// Mock instanceSettings
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
updateCoolifyTestCreateRootServerAndSettings([
'is_auto_update_enabled' => true,
'docker_registry_url' => 'ghcr.io',
]);
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.10']],
], 200),
]);
(new UpdateCoolify)->handle();
expect(Activity::query()->latest('id')->first()?->getExtraProperty('command'))->toBe(
"curl -fsSL https://cdn.example.com/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
"bash /data/coolify/source/upgrade.sh '4.0.10' '1.0.14' 'ghcr.io'"
);
});
it('falls back to docker io for the upgrade script command when no registry is saved', function () {
Queue::fake();
config([
'app.env' => 'testing',
'constants.coolify.version' => '4.0.9',
'constants.coolify.helper_version' => '1.0.14',
'constants.coolify.registry_url' => 'ghcr.io',
'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/upgrade.sh',
'constants.ssh.mux_enabled' => false,
]);
updateCoolifyTestCreateRootServerAndSettings([
'is_auto_update_enabled' => true,
]);
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.10']],
], 200),
]);
(new UpdateCoolify)->handle();
expect(Activity::query()->latest('id')->first()?->getExtraProperty('command'))->toBe(
"curl -fsSL https://cdn.example.com/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
"bash /data/coolify/source/upgrade.sh '4.0.10' '1.0.14' 'docker.io'"
);
});
it('defaults the registry setting to docker io when no registry is saved', function () {
config([
'app.env' => 'testing',
'constants.coolify.registry_url' => 'ghcr.io',
'constants.coolify.self_hosted' => true,
]);
updateCoolifyTestCreateRootServerAndSettings();
$rootTeam = Team::findOrFail(0);
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Updates::class)
->assertSet('docker_registry_url', 'docker.io');
});
it('uses the database registry for helper images when the configured helper image is default', function () {
config([
'constants.coolify.registry_url' => 'ghcr.io',
'constants.coolify.helper_image' => 'ghcr.io/coollabsio/coolify-helper',
]);
updateCoolifyTestCreateRootServerAndSettings([
'docker_registry_url' => 'docker.io',
]);
expect(coolifyRegistryUrl())->toBe('docker.io')
->and(coolifyHelperImage())->toBe('docker.io/coollabsio/coolify-helper');
});
it('preserves an explicit custom helper image override', function () {
config([
'constants.coolify.registry_url' => 'docker.io',
'constants.coolify.helper_image' => 'registry.example.com/custom/helper',
]);
updateCoolifyTestCreateRootServerAndSettings([
'docker_registry_url' => 'ghcr.io',
]);
expect(coolifyHelperImage())->toBe('registry.example.com/custom/helper');
});
it('uses the database registry for sentinel images', function () {
$action = file_get_contents(app_path('Actions/Server/StartSentinel.php'));
expect($action)->toContain("\$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.\$version;");
});
it('rejects invalid registry values and does not sync them', function () {
Process::fake();
config([
'app.env' => 'testing',
'constants.coolify.registry_url' => 'docker.io',
]);
updateCoolifyTestCreateRootServerAndSettings([
'is_auto_update_enabled' => true,
'auto_update_frequency' => '0 0 * * *',
'update_check_frequency' => '0 * * * *',
'docker_registry_url' => 'docker.io',
]);
$rootTeam = Team::findOrFail(0);
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Updates::class)
->set('docker_registry_url', 'ghcr.io; touch /tmp/pwned')
->call('submit')
->assertHasErrors(['docker_registry_url' => ['in']]);
expect(InstanceSettings::findOrFail(0)->docker_registry_url)->toBe('docker.io');
Process::assertDidntRun(fn () => true);
});
it('does not save registry changes when syncing the env file fails', function () {
config([
'app.env' => 'testing',
'constants.coolify.registry_url' => 'docker.io',
'constants.coolify.self_hosted' => true,
]);
updateCoolifyTestCreateRootServerAndSettings([
'is_auto_update_enabled' => true,
'auto_update_frequency' => '0 0 * * *',
'update_check_frequency' => '0 * * * *',
'docker_registry_url' => 'docker.io',
]);
$rootTeam = Team::findOrFail(0);
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
$component = new class extends Updates
{
protected function syncRegistryUrlToEnv(string $registryUrl): void
{
throw new RuntimeException('sync failed');
}
};
$component->settings = InstanceSettings::findOrFail(0);
$component->auto_update_frequency = '0 0 * * *';
$component->update_check_frequency = '0 * * * *';
$component->is_auto_update_enabled = true;
$component->docker_registry_url = 'ghcr.io';
$component->instantSave();
expect(InstanceSettings::findOrFail(0)->docker_registry_url)->toBe('docker.io');
});
it('appends registry url to env file when the key is missing', function () {
$component = new Updates;
$method = new ReflectionMethod(Updates::class, 'registryEnvSyncCommand');
expect($method->invoke($component, 'ghcr.io'))
->toContain("grep -q '^REGISTRY_URL=' /data/coolify/source/.env")
->toContain("sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env")
->toContain("printf '%s\\n' 'REGISTRY_URL=ghcr.io' >> /data/coolify/source/.env");
});
it('prevents downgrade even with manual update', function () {
updateCoolifyTestCreateRootServerAndSettings();
// CDN returns older version
Http::fake([
@@ -116,7 +303,7 @@ it('prevents downgrade even with manual update', function () {
$action = new UpdateCoolify;
\Illuminate\Support\Facades\Log::shouldReceive('error')
Log::shouldReceive('error')
->once()
->with('Downgrade prevented', Mockery::type('array'));
@@ -124,7 +311,7 @@ it('prevents downgrade even with manual update', function () {
try {
$action->handle(manual_update: true);
expect(false)->toBeTrue('Expected exception was not thrown');
} catch (\Exception $e) {
} catch (Exception $e) {
expect($e->getMessage())->toContain('Cannot downgrade');
expect($e->getMessage())->toContain('4.0.10');
expect($e->getMessage())->toContain('4.0.0');
+32
View File
@@ -44,6 +44,38 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the selected registry url when extracting upgrade images', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)->toContain('IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('persists the selected registry url during upgrades', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)->toContain('set_env_var "REGISTRY_URL" "$REGISTRY_URL"');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)
->toContain('if [ -n "${3+x}" ]; then')
->toContain('REGISTRY_URL="$3"')
->toContain('elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then')
->toContain("REGISTRY_URL=$(grep \"^REGISTRY_URL=\" \"\$ENV_FILE\" | cut -d '=' -f2- | head -n1)")
->toContain('REGISTRY_URL="docker.io"');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
+11
View File
@@ -180,3 +180,14 @@ it('normalizes environment variable keys before model validation', function () {
expect($environmentVariable->key)->toBe('APP_ENV');
});
it('normalizes application domain scheme and host without lowercasing path query or fragment', function () {
$domains = ' HTTPS://EXAMPLE.COM/MixedCase/Path?Token=ABC#Fragment, http://Sub.EXAMPLE.com/Api/V1 ';
expect(ValidationPatterns::normalizeApplicationDomains($domains))
->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
});
it('validates application domains with underscores in the hostname', function () {
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
});