mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 09:45:58 -04:00
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:
co-authored by
Claude Opus 5.5
parent
b2b8177982
commit
e17b15f5f1
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user