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>
This commit is contained in:
Andras Bacsai
2026-09-25 23:52:01 +02:00
co-authored by Claude Opus 5.5
parent b2b8177982
commit e17b15f5f1
8 changed files with 151 additions and 23 deletions
@@ -219,8 +219,8 @@ class GithubController extends Controller
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'api_url' => ['nullable', 'string', 'url', SafeExternalUrl::forGitSource()],
'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
@@ -615,10 +615,10 @@ class GithubController extends Controller
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
}
if (isset($payload['api_url'])) {
$rules['api_url'] = ['url', new SafeExternalUrl];
$rules['api_url'] = ['url', SafeExternalUrl::forGitSource()];
}
if (isset($payload['html_url'])) {
$rules['html_url'] = ['url', new SafeExternalUrl];
$rules['html_url'] = ['url', SafeExternalUrl::forGitSource()];
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
+2 -2
View File
@@ -89,8 +89,8 @@ class Change extends Component
return [
'name' => 'required|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'apiUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'htmlUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'customUser' => 'required|string',
'customPort' => 'required|int',
'appId' => 'nullable|int',
+2 -2
View File
@@ -59,8 +59,8 @@ class Create extends Component
$this->validate([
'name' => 'required|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'api_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'custom_user' => 'required|string',
'custom_port' => 'required|int',
'is_system_wide' => 'required|bool',
+2 -2
View File
@@ -70,8 +70,8 @@ class Change extends Component
{
return [
'name' => 'required|string',
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'apiUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'htmlUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'customUser' => 'required|string',
'customPort' => 'required|int',
'clientId' => 'nullable|string',
+2 -2
View File
@@ -59,8 +59,8 @@ class Create extends Component
$this->validate([
'name' => 'required|string',
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'api_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'custom_user' => 'required|string',
'custom_port' => 'required|int',
'is_system_wide' => 'required|bool',
+4 -1
View File
@@ -100,7 +100,10 @@ class AppServiceProvider extends ServiceProvider
private function configureGitHubHttp(): void
{
Http::macro('GitSource', function (string $url) {
return Http::withOptions(SafeExternalUrl::httpClientOptions($url));
return Http::withOptions(SafeExternalUrl::httpClientOptions(
$url,
allowPrivateNetworks: SafeExternalUrl::gitSourcesMayUsePrivateNetworks(),
));
});
Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) {
+40 -10
View File
@@ -14,11 +14,29 @@ class SafeWebhookUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
/**
* @param array<int, string> $trustedInternalHosts
* @param bool $allowPrivateNetworks Allow private, CGNAT, and unique local addresses and internal hostnames.
* Loopback, link-local (cloud metadata), and reserved targets stay blocked.
*/
public function __construct(private ?Closure $resolver = null, private array $trustedInternalHosts = []) {}
public function __construct(
private ?Closure $resolver = null,
private array $trustedInternalHosts = [],
private bool $allowPrivateNetworks = false,
) {}
/**
* Git sources such as GitHub Enterprise or GitLab often run on a private network.
* Self-hosted instances allow them. Coolify Cloud keeps them blocked.
*/
public static function forGitSource(): static
{
return new static(allowPrivateNetworks: self::gitSourcesMayUsePrivateNetworks());
}
public static function gitSourcesMayUsePrivateNetworks(): bool
{
return ! isCloud();
}
/**
* Run the validation rule.
@@ -112,7 +130,7 @@ class SafeWebhookUrl implements ValidationRule
* @param (Closure(string): array<int, string>)|null $resolver
* @return array<string, mixed>
*/
public static function httpClientOptions(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null): array
public static function httpClientOptions(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false): array
{
$options = ['allow_redirects' => false];
@@ -120,7 +138,7 @@ class SafeWebhookUrl implements ValidationRule
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
}
$target = self::resolveUrlForRequest($url, $trustedInternalHosts, $resolver);
$target = self::resolveUrlForRequest($url, $trustedInternalHosts, $resolver, $allowPrivateNetworks);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return $options;
@@ -187,9 +205,9 @@ class SafeWebhookUrl implements ValidationRule
/**
* @return array{host: string, port: int, ips: array<int, string>}
*/
private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null): array
private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false): array
{
$rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts);
$rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts, allowPrivateNetworks: $allowPrivateNetworks);
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \RuntimeException('Webhook URL is invalid.');
}
@@ -372,7 +390,11 @@ class SafeWebhookUrl implements ValidationRule
}
if ($this->isPrivateIp($ip)) {
return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
return $this->allowPrivateNetworks || $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
}
if ($this->allowPrivateNetworks && $this->ipv4InCidr($ip, '100.64.0.0/10')) {
return true;
}
return $this->isAllowlistedIp($ip);
@@ -491,8 +513,16 @@ class SafeWebhookUrl implements ValidationRule
private function isBlockedHostname(string $host): bool
{
return in_array($host, ['localhost'], true)
|| str_ends_with($host, '.local')
if ($host === 'localhost') {
return true;
}
// The resolved addresses of internal hostnames are still checked.
if ($this->allowPrivateNetworks) {
return false;
}
return str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')
|| str_ends_with($host, '.cluster.local');
}
@@ -0,0 +1,95 @@
<?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();
});