Files
coolify/tests/Unit/SafeWebhookUrlTest.php
T
Andras BacsaiandClaude Opus 5.5 1af7de8a85 fix(backups): pin a reachable IP for S3 uploads on IPv6-only servers
mc keeps one --resolve IP per host, and Coolify always pinned the IPv4
address of a dual-stack endpoint. On servers without IPv4, uploads failed
with "network is unreachable". A helper container on the upload network
now connects to each validated IP, IPv4 first, and the first IP that
connects is pinned. S3 restores are now pinned in the same way.

Closes #12397

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:38:49 +01:00

567 lines
22 KiB
PHP

<?php
use App\Models\InstanceSettings;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use PurplePixie\PhpDns\DNSAnswer;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSResult;
use PurplePixie\PhpDns\DNSTypes;
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('lists every S3 host IP as a bracket-free resolve entry, IPv4 first', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
// mc rejects bracketed IPv6, and S3Storage::minioClientResolveOptions() picks the reachable entry.
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
expect($options)->toBe(['localhost:9000=127.0.0.1', 'localhost:9000=::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('rejects NAT64 local-use IPv6 literals and DNS answers', function (string $ipv6) {
$url = "http://[{$ipv6}]/webhook";
$rule = new SafeWebhookUrl(fn (string $host): array => [$ipv6]);
expect(Validator::make(['url' => $url], ['url' => $rule])->fails())->toBeTrue("Expected literal rejection: {$ipv6}");
expect(Validator::make(['url' => 'http://nat64.example.test/webhook'], ['url' => $rule])->fails())->toBeTrue("Expected DNS rejection: {$ipv6}");
expect(fn () => SafeWebhookUrl::httpClientOptions($url))->toThrow(RuntimeException::class, 'unsafe IP address');
expect(fn () => SafeWebhookUrl::httpClientOptions('http://nat64.example.test/webhook', resolver: fn (string $host): array => [$ipv6]))
->toThrow(RuntimeException::class, 'unsafe IP address');
})->with([
'loopback' => '64:ff9b:1::7f00:1',
'private 172.16' => '64:ff9b:1::ac10:1',
'private 192.168' => '64:ff9b:1::c0a8:1',
'link-local' => '64:ff9b:1::a9fe:a9fe',
]);
it('keeps public IPv6 allowed and NAT64 well-known prefix blocked', function () {
$rule = new SafeWebhookUrl;
expect(Validator::make(['url' => 'https://[2606:4700:4700::1111]/webhook'], ['url' => $rule])->passes())->toBeTrue();
expect(Validator::make(['url' => 'http://[64:ff9b::7f00:1]/webhook'], ['url' => $rule])->fails())->toBeTrue();
});
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');
});
it('passes bracketed IPv6 custom dns servers to the dns query', function () {
$queriedServers = new ArrayObject;
app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers) {
$queriedServers->append($parameters['server']);
return new class('192.0.2.1') extends DNSQuery
{
public function query(string $question, string $typeName = DNSTypes::NAME_A): false
{
return false;
}
public function hasError(): bool
{
return true;
}
};
});
$method = new ReflectionMethod(SafeWebhookUrl::class, 'resolveHostWithCustomDnsServers');
$method->invoke(new SafeWebhookUrl, 'hooks.example.com', ['2606:4700:4700::1111', '[2001:db8::53]', '192.0.2.1']);
expect(array_values(array_unique($queriedServers->getArrayCopy())))
->toBe(['[2606:4700:4700::1111]', '[2001:db8::53]', '192.0.2.1']);
});
it('accepts bracketed IPv6 custom dns servers from instance settings', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, [2606:4700:4700::1111]']));
$method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers');
expect($method->invoke(new SafeWebhookUrl))->toBe(['1.1.1.1', '2606:4700:4700::1111']);
});
/**
* Fakes a resolver like 1.1.1.1 that sets the TC bit on the UDP answer for A records.
*/
function bindTruncatingDnsResolver(ArrayObject $queries, string $truncatedError = 'Response too big for UDP, retry with TCP'): void
{
app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queries, $truncatedError) {
return new class($parameters['udp'], $queries, $truncatedError) extends DNSQuery
{
private string $fakeError = '';
public function __construct(
private readonly bool $useUdp,
private readonly ArrayObject $queries,
private readonly string $truncatedError,
) {
parent::__construct('192.0.2.1');
}
public function query(string $question, string $typeName = DNSTypes::NAME_A)
{
$this->queries->append([$typeName, $this->useUdp ? 'udp' : 'tcp']);
$this->fakeError = '';
if ($typeName === DNSTypes::NAME_A && $this->useUdp) {
$this->fakeError = $this->truncatedError;
return false;
}
$answer = new DNSAnswer;
$ip = $typeName === DNSTypes::NAME_A ? '172.64.66.1' : '2606:4700:113::1';
$answer->addResult(new DNSResult($typeName, 0, 'IN', 300, $ip, $question, '', []));
return $answer;
}
public function hasError(): bool
{
return $this->fakeError !== '';
}
public function getLasterror(): string
{
return $this->fakeError;
}
};
});
}
it('retries custom dns lookups over TCP when the UDP answer is truncated', function () {
$queries = new ArrayObject;
bindTruncatingDnsResolver($queries);
$method = new ReflectionMethod(SafeWebhookUrl::class, 'resolveHostWithCustomDnsServers');
expect($method->invoke(new SafeWebhookUrl, 'bucket.r2.cloudflarestorage.com', ['1.1.1.1']))
->toBe(['172.64.66.1', '2606:4700:113::1'])
->and($queries->getArrayCopy())->toBe([
[DNSTypes::NAME_A, 'udp'],
[DNSTypes::NAME_A, 'tcp'],
[DNSTypes::NAME_AAAA, 'udp'],
]);
});
it('does not retry custom dns lookups over TCP for other UDP failures', function () {
$queries = new ArrayObject;
bindTruncatingDnsResolver($queries, 'Failed to read data buffer');
$method = new ReflectionMethod(SafeWebhookUrl::class, 'resolveHostWithCustomDnsServers');
expect($method->invoke(new SafeWebhookUrl, 'bucket.r2.cloudflarestorage.com', ['1.1.1.1']))
->toBe(['2606:4700:113::1'])
->and($queries->getArrayCopy())->toBe([
[DNSTypes::NAME_A, 'udp'],
[DNSTypes::NAME_AAAA, 'udp'],
]);
});