Files
coolify/tests/Feature/GitSourcePrivateNetworkTest.php
T
Andras BacsaiandClaude Opus 5.5 e17b15f5f1 fix(sources): allow private networks for self-hosted Git sources
Since GitHub App and GitLab API calls use the outbound URL guard,
GitHub Enterprise or GitLab on a private network failed with "Webhook
URL resolved to an unsafe IP address" unless an admin allow-listed it.

On self-hosted instances, Git source URLs and requests now allow
private (RFC 1918), CGNAT (100.64/10, Tailscale), and IPv6 unique local
addresses, plus internal hostnames such as .internal, .local, and
container names. Loopback, localhost, link-local (cloud metadata),
0.0.0.0, and other reserved targets stay blocked. Redirects stay off
and DNS stays pinned. Coolify Cloud and all other outbound URLs keep
the strict rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:52:01 +02:00

96 lines
4.5 KiB
PHP

<?php
use App\Models\InstanceSettings;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
config(['constants.coolify.self_hosted' => true]);
});
function gitSourceOptions(string $url, array $resolvedIps = ['203.0.113.10']): array
{
return SafeExternalUrl::httpClientOptions($url, resolver: fn (string $host): array => $resolvedIps, allowPrivateNetworks: true);
}
function gitSourceUrlIsValid(string $url): bool
{
return Validator::make(['api_url' => $url], ['api_url' => [SafeExternalUrl::forGitSource()]])->passes();
}
test('self-hosted Git sources can use private networks and internal hostnames', function (string $url, array $resolvedIps) {
$options = gitSourceOptions($url, $resolvedIps);
expect($options['allow_redirects'])->toBeFalse();
})->with([
'RFC 1918 IP' => ['https://10.0.0.5/api/v3', []],
'private IP with port' => ['http://192.168.1.20:8929/api/v4', []],
'Tailscale CGNAT IP' => ['https://100.101.102.103/api/v4', []],
'IPv6 unique local address' => ['https://[fd12:3456::1]/api/v4', []],
'hostname on a private IP' => ['https://github.company.lan/api/v3', ['10.1.2.3']],
'.internal hostname' => ['https://gitlab.corp.internal/api/v4', ['172.16.5.4']],
'.local hostname' => ['https://gitea.local/api/v1', ['192.168.1.30']],
'single-label container name' => ['http://gitea:3000/api/v1', ['172.18.0.7']],
'Tailscale MagicDNS name' => ['https://git.tail1234.ts.net/api/v4', ['100.64.0.9']],
]);
test('the private-network mode pins the resolved private address', function () {
$options = gitSourceOptions('https://gitlab.corp.internal/api/v4', ['172.16.5.4']);
expect($options['curl'][CURLOPT_RESOLVE][0])->toBe('gitlab.corp.internal:443:172.16.5.4');
});
test('Git sources still cannot reach metadata, loopback, or reserved targets', function (string $url, array $resolvedIps) {
expect(fn () => gitSourceOptions($url, $resolvedIps))->toThrow(RuntimeException::class);
})->with([
'cloud metadata IP' => ['http://169.254.169.254/latest', []],
'metadata hostname' => ['http://metadata.google.internal/computeMetadata', ['169.254.169.254']],
'IPv6 link-local' => ['http://[fe80::1]/', []],
'loopback IP' => ['http://127.0.0.1:6379/', []],
'IPv6 loopback' => ['http://[::1]/', []],
'localhost' => ['http://localhost:8080/', []],
'hostname on loopback' => ['https://git.example.com/', ['127.0.0.1']],
'unspecified address' => ['http://0.0.0.0/', []],
'IPv4-mapped metadata IP' => ['http://[::ffff:169.254.169.254]/', []],
'multicast' => ['http://224.0.0.1/', []],
'hostname with one private and one metadata IP' => ['https://git.example.com/', ['10.0.0.5', '169.254.169.254']],
]);
test('other outbound URLs still block private networks by default', function () {
expect(fn () => SafeExternalUrl::httpClientOptions('https://10.0.0.5/hook'))
->toThrow(RuntimeException::class, 'unsafe IP address')
->and(fn () => SafeExternalUrl::httpClientOptions('https://gitlab.corp.internal/', resolver: fn (): array => ['172.16.5.4']))
->toThrow(RuntimeException::class);
});
test('the Git source HTTP client allows a private GitHub Enterprise on self-hosted', function () {
$options = Http::GitHub('https://10.20.30.40/api/v3', 'secret')->getOptions();
expect($options['allow_redirects'])->toBeFalse()
->and($options['headers']['Authorization'])->toBe('Bearer secret');
});
test('the Git source HTTP client keeps private networks blocked on Coolify Cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(fn () => Http::GitHub('https://10.20.30.40/api/v3', 'secret'))
->toThrow(RuntimeException::class, 'unsafe IP address');
});
test('Git source URL validation follows the same private-network rules', function () {
expect(gitSourceUrlIsValid('https://10.20.30.40/api/v3'))->toBeTrue()
->and(gitSourceUrlIsValid('https://100.101.102.103/api/v4'))->toBeTrue()
->and(gitSourceUrlIsValid('http://169.254.169.254/latest'))->toBeFalse()
->and(gitSourceUrlIsValid('http://127.0.0.1/'))->toBeFalse()
->and(gitSourceUrlIsValid('http://localhost/'))->toBeFalse();
config(['constants.coolify.self_hosted' => false]);
expect(gitSourceUrlIsValid('https://10.20.30.40/api/v3'))->toBeFalse();
});