From e17b15f5f16165c84499022c7b00b78690ae6d57 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:52:01 +0200 Subject: [PATCH] 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 --- app/Http/Controllers/Api/GithubController.php | 8 +- app/Livewire/Source/Github/Change.php | 4 +- app/Livewire/Source/Github/Create.php | 4 +- app/Livewire/Source/Gitlab/Change.php | 4 +- app/Livewire/Source/Gitlab/Create.php | 4 +- app/Providers/AppServiceProvider.php | 5 +- app/Rules/SafeWebhookUrl.php | 50 ++++++++-- tests/Feature/GitSourcePrivateNetworkTest.php | 95 +++++++++++++++++++ 8 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/GitSourcePrivateNetworkTest.php diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 5a0e74d0d9..85238e799b 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -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'; diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 6bb896fcc6..6c65190e80 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -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', diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 6a5bf6e607..37eea732c3 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -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', diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 29374105b2..50c9e9115c 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -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', diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php index 200bd6d403..2846d20b1b 100644 --- a/app/Livewire/Source/Gitlab/Create.php +++ b/app/Livewire/Source/Gitlab/Create.php @@ -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', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fbdaaecab0..27f095ac45 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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) { diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 5add4c1896..7d614d03de 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -14,11 +14,29 @@ class SafeWebhookUrl implements ValidationRule { /** * @param (Closure(string): array)|null $resolver - */ - /** * @param array $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)|null $resolver * @return array */ - 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} */ - 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'); } diff --git a/tests/Feature/GitSourcePrivateNetworkTest.php b/tests/Feature/GitSourcePrivateNetworkTest.php new file mode 100644 index 0000000000..cfaed850b2 --- /dev/null +++ b/tests/Feature/GitSourcePrivateNetworkTest.php @@ -0,0 +1,95 @@ + 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(); +});