mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
Reject link-local targets even when allowlisted, link private-target errors to endpoint settings, and tighten persistent-volume table widths.
429 lines
17 KiB
PHP
429 lines
17 KiB
PHP
<?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, RefreshDatabase::class);
|
|
|
|
it('accepts valid public URLs', function () {
|
|
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
|
|
|
$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('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 link-local targets even when allowlisted', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
|
'webhook_allowed_internal_hosts' => ['169.254.0.0/16'],
|
|
]));
|
|
|
|
$validator = Validator::make(
|
|
['url' => 'http://169.254.169.254/latest/meta-data'],
|
|
['url' => new SafeWebhookUrl],
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue();
|
|
});
|
|
|
|
it('rejects hostnames that resolve to blocked addresses', function (string $url, array $resolvedIps) {
|
|
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
|
|
|
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
|
|
|
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
|
|
})->with([
|
|
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
|
|
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
|
|
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
|
|
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
|
|
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
|
|
'hostname to mapped link-local IP' => ['http://mapped-link-local.example.test/', ['::ffff:169.254.169.254']],
|
|
]);
|
|
|
|
it('rejects IPv4-mapped IPv6 literals for blocked IPv4 ranges', function (string $url) {
|
|
$rule = new SafeWebhookUrl;
|
|
|
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
|
|
|
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
|
})->with([
|
|
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
|
|
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
|
|
'mapped zero' => 'http://[::ffff:0.0.0.0]/',
|
|
]);
|
|
|
|
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',
|
|
'localhost with trailing dot' => 'http://localhost.',
|
|
'.internal domain' => 'http://myservice.internal',
|
|
'.internal domain with trailing dot' => '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');
|
|
});
|
|
|
|
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,[::1]');
|
|
}
|
|
});
|
|
|
|
it('pins every resolved address in a single CURLOPT_RESOLVE entry', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
|
'webhook_allowed_internal_hosts' => ['localhost'],
|
|
'webhook_allow_localhost' => true,
|
|
]));
|
|
|
|
if (! defined('CURLOPT_RESOLVE')) {
|
|
expect(true)->toBeTrue();
|
|
|
|
return;
|
|
}
|
|
|
|
$entries = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook')['curl'][CURLOPT_RESOLVE];
|
|
|
|
// localhost resolves to both 127.0.0.1 and ::1. libcurl overrides an
|
|
// existing host:port cache entry with each new one, so multiple entries
|
|
// would leave only [::1] pinned and break every request on hosts without
|
|
// IPv6 egress.
|
|
expect($entries)->toHaveCount(1)
|
|
->and($entries[0])->toBe('localhost:8080:127.0.0.1,[::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('collapses dual-stack S3 hosts to a single bracket-free IPv4 resolve entry', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
|
'webhook_allowed_internal_hosts' => ['localhost'],
|
|
'webhook_allow_localhost' => true,
|
|
]));
|
|
|
|
// localhost resolves to both 127.0.0.1 and ::1. The MinIO client keeps one IP per
|
|
// host and rejects bracketed IPv6, so exactly one bracket-free IPv4 entry is produced.
|
|
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
|
|
|
|
expect($options)->toBe(['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');
|
|
});
|
|
|
|
it('falls back to system DNS when custom DNS returns no answers', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
|
'custom_dns_servers' => '1.1.1.1',
|
|
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
|
]));
|
|
|
|
$rule = new class extends SafeWebhookUrl
|
|
{
|
|
/** @var array<int, array{0: string, 1: array<int, string>}> */
|
|
public array $customCalls = [];
|
|
|
|
/** @var array<int, string> */
|
|
public array $systemCalls = [];
|
|
|
|
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
|
{
|
|
$this->customCalls[] = [$host, $dnsServers];
|
|
|
|
// Simulate public DNS (1.1.1.1) having no record for a docker hostname.
|
|
return [];
|
|
}
|
|
|
|
protected function resolveHostWithSystemDns(string $host): array
|
|
{
|
|
$this->systemCalls[] = $host;
|
|
|
|
return ['172.16.0.5'];
|
|
}
|
|
};
|
|
|
|
$validator = Validator::make(
|
|
['url' => 'http://coolify-minio:9000'],
|
|
['url' => $rule],
|
|
);
|
|
|
|
expect($validator->passes())->toBeTrue('Expected system-DNS fallback for allowlisted docker hostname')
|
|
->and($rule->customCalls)->toHaveCount(1)
|
|
->and($rule->customCalls[0][0])->toBe('coolify-minio')
|
|
->and($rule->customCalls[0][1])->toBe(['1.1.1.1'])
|
|
->and($rule->systemCalls)->toBe(['coolify-minio']);
|
|
});
|
|
|
|
it('does not fall back to system DNS when custom DNS returns answers', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
|
'custom_dns_servers' => '1.1.1.1',
|
|
]));
|
|
|
|
$rule = new class extends SafeWebhookUrl
|
|
{
|
|
public int $systemCalls = 0;
|
|
|
|
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
|
{
|
|
return ['93.184.216.34'];
|
|
}
|
|
|
|
protected function resolveHostWithSystemDns(string $host): array
|
|
{
|
|
$this->systemCalls++;
|
|
|
|
return ['10.0.0.1'];
|
|
}
|
|
};
|
|
|
|
$validator = Validator::make(
|
|
['url' => 'https://example.com/webhook'],
|
|
['url' => $rule],
|
|
);
|
|
|
|
expect($validator->passes())->toBeTrue()
|
|
->and($rule->systemCalls)->toBe(0);
|
|
});
|
|
|
|
it('still rejects private targets after system DNS fallback when host is not allowlisted', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
|
'custom_dns_servers' => '1.1.1.1',
|
|
'webhook_allowed_internal_hosts' => [],
|
|
]));
|
|
|
|
$rule = new class extends SafeWebhookUrl
|
|
{
|
|
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function resolveHostWithSystemDns(string $host): array
|
|
{
|
|
return ['172.16.0.5'];
|
|
}
|
|
};
|
|
|
|
$validator = Validator::make(
|
|
['url' => 'http://coolify-minio:9000'],
|
|
['url' => $rule],
|
|
);
|
|
|
|
expect($validator->fails())->toBeTrue('Expected private IP rejection without allowlist after fallback');
|
|
});
|