diff --git a/src/Actions/Integrations/ResolvePublicUrl.php b/src/Actions/Integrations/ResolvePublicUrl.php new file mode 100644 index 00000000..65b0949a --- /dev/null +++ b/src/Actions/Integrations/ResolvePublicUrl.php @@ -0,0 +1,156 @@ + */ + private const BLOCKED_IPV4_RANGES = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.88.99.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + ]; + + /** @var list */ + private const BLOCKED_IPV6_RANGES = [ + '::/8', + '2001::/23', + '2001:db8::/32', + '2002::/16', + '3fff::/20', + '5f00::/16', + 'fc00::/7', + 'fe80::/10', + 'fec0::/10', + 'ff00::/8', + ]; + + /** + * Resolve and validate a URL before making an outbound request. + */ + public function handle(string $url): ResolvedPublicUrl + { + $parts = parse_url($url); + + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + throw new InvalidArgumentException('The URL is invalid.'); + } + + $scheme = strtolower($parts['scheme']); + + if (! in_array($scheme, ['http', 'https'], true) || isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) { + throw new InvalidArgumentException('The URL is invalid.'); + } + + $host = trim($parts['host'], '[]'); + $addresses = $this->resolveAddresses($host); + + if ($addresses === [] || collect($addresses)->contains(fn (string $address): bool => ! $this->isPublicAddress($address))) { + throw new InvalidArgumentException('The URL must resolve to a public address.'); + } + + $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80); + $authority = str_contains($host, ':') ? "[{$host}]" : $host; + + if (isset($parts['port'])) { + $authority .= ":{$port}"; + } + + $path = rtrim($parts['path'] ?? '', '/'); + + return new ResolvedPublicUrl( + url: "{$scheme}://{$authority}{$path}/json", + host: $host, + port: $port, + address: $addresses[0], + ); + } + + /** + * Resolve every address so mixed public/private DNS answers are rejected. + * + * @return list + */ + private function resolveAddresses(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; + } + + $records = dns_get_record($host, DNS_A | DNS_AAAA); + + if ($records === false) { + return []; + } + + $addresses = []; + + foreach ($records as $record) { + $address = $record['ip'] ?? $record['ipv6'] ?? null; + + if (is_string($address)) { + $addresses[] = $address; + } + } + + return array_values(array_unique($addresses)); + } + + private function isPublicAddress(string $address): bool + { + $ranges = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false + ? self::BLOCKED_IPV4_RANGES + : self::BLOCKED_IPV6_RANGES; + + if (filter_var($address, FILTER_VALIDATE_IP) === false) { + return false; + } + + return ! collect($ranges)->contains(fn (string $range): bool => $this->isInRange($address, $range)); + } + + /** + * Compare packed addresses to avoid platform-dependent integer handling. + */ + private function isInRange(string $address, string $range): bool + { + [$network, $prefixLength] = explode('/', $range); + $addressBytes = inet_pton($address); + $networkBytes = inet_pton($network); + + if ($addressBytes === false || $networkBytes === false || strlen($addressBytes) !== strlen($networkBytes)) { + return false; + } + + $prefixLength = (int) $prefixLength; + $wholeBytes = intdiv($prefixLength, 8); + $remainingBits = $prefixLength % 8; + + if (substr($addressBytes, 0, $wholeBytes) !== substr($networkBytes, 0, $wholeBytes)) { + return false; + } + + if ($remainingBits === 0) { + return true; + } + + $mask = (0xFF << (8 - $remainingBits)) & 0xFF; + + return (ord($addressBytes[$wholeBytes]) & $mask) === (ord($networkBytes[$wholeBytes]) & $mask); + } +} diff --git a/src/Data/ResolvedPublicUrl.php b/src/Data/ResolvedPublicUrl.php new file mode 100644 index 00000000..03994585 --- /dev/null +++ b/src/Data/ResolvedPublicUrl.php @@ -0,0 +1,24 @@ +host, ':') ? "[{$this->host}]" : $this->host; + $address = str_contains($this->address, ':') ? "[{$this->address}]" : $this->address; + + return sprintf('%s:%d:%s', $host, $this->port, $address); + } +} diff --git a/src/Filament/Pages/Integrations/OhDear.php b/src/Filament/Pages/Integrations/OhDear.php index acfd5c66..64944f74 100644 --- a/src/Filament/Pages/Integrations/OhDear.php +++ b/src/Filament/Pages/Integrations/OhDear.php @@ -3,6 +3,7 @@ namespace Cachet\Filament\Pages\Integrations; use Cachet\Actions\Integrations\ImportOhDearFeed; +use Cachet\Actions\Integrations\ResolvePublicUrl; use Cachet\Cachet; use Cachet\Filament\Resources\ComponentGroups\ComponentGroupResource; use Cachet\Models\Component; @@ -17,6 +18,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use InvalidArgumentException; /** * @property Schema $form @@ -100,16 +102,33 @@ public function form(Schema $schema): Schema /** * Import the OhDear feed. */ - public function importFeed(ImportOhDearFeed $importOhDearFeedAction): void + public function importFeed(ImportOhDearFeed $importOhDearFeedAction, ResolvePublicUrl $resolvePublicUrlAction): void { $this->validate(); try { - $ohDear = Http::baseUrl(rtrim($this->url)) + if (! defined('CURLOPT_RESOLVE')) { + throw new ConnectionException('Secure URL resolution requires the PHP cURL extension.'); + } + + $curlResolveOption = constant('CURLOPT_RESOLVE'); + + $resolvedUrl = $resolvePublicUrlAction->handle($this->url); + + $ohDear = Http::withOptions([ + 'allow_redirects' => false, + 'curl' => [$curlResolveOption => [$resolvedUrl->curlResolve()]], + ]) + ->connectTimeout(5) + ->timeout(10) ->withUserAgent(Cachet::USER_AGENT) - ->get('/json') + ->get($resolvedUrl->url) ->throw() ->json(); + } catch (InvalidArgumentException) { + $this->addError('url', __('cachet::integrations.oh_dear.provided_url_invalid')); + + return; } catch (ConnectionException $e) { $this->addError('url', $e->getMessage()); diff --git a/tests/Feature/Filament/Integrations/OhDearTest.php b/tests/Feature/Filament/Integrations/OhDearTest.php index 4e9f1b5a..ba1ddd74 100644 --- a/tests/Feature/Filament/Integrations/OhDearTest.php +++ b/tests/Feature/Filament/Integrations/OhDearTest.php @@ -4,6 +4,8 @@ use Cachet\Filament\Pages\Integrations\OhDear; use Filament\Facades\Filament; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use Workbench\App\User; use function Pest\Laravel\actingAs; @@ -20,3 +22,36 @@ ->assertSee(__('cachet::integrations.oh_dear.status_page_section_title')) ->assertSee(__('cachet::integrations.oh_dear.import_options_section_title')); }); + +it('rejects private import URLs before sending a request', function () { + Http::preventStrayRequests(); + + livewire(OhDear::class) + ->fillForm(['url' => 'http://169.254.169.254']) + ->call('importFeed') + ->assertHasFormErrors(['url']); + + Http::assertNothingSent(); +}); + +it('imports from a validated public URL', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'https://93.184.216.34/json' => Http::response([ + 'sites' => [], + 'summarizedStatus' => ['status' => 'up'], + ]), + ]); + + livewire(OhDear::class) + ->fillForm([ + 'url' => 'https://93.184.216.34', + 'import_sites' => false, + 'import_incidents' => false, + ]) + ->call('importFeed') + ->assertHasNoFormErrors(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://93.184.216.34/json'); +}); diff --git a/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php b/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php new file mode 100644 index 00000000..8a6c7a8e --- /dev/null +++ b/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php @@ -0,0 +1,34 @@ +handle('https://93.184.216.34/status/'); + + expect($resolvedUrl->url)->toBe('https://93.184.216.34/status/json') + ->and($resolvedUrl->curlResolve())->toBe('93.184.216.34:443:93.184.216.34'); +}); + +it('brackets public IPv6 addresses for a pinned request', function () { + $resolvedUrl = app(ResolvePublicUrl::class)->handle('https://[2606:4700:4700::1111]/'); + + expect($resolvedUrl->url)->toBe('https://[2606:4700:4700::1111]/json') + ->and($resolvedUrl->curlResolve())->toBe('[2606:4700:4700::1111]:443:[2606:4700:4700::1111]'); +}); + +it('rejects non-public destinations', function (string $url) { + app(ResolvePublicUrl::class)->handle($url); +})->throws(InvalidArgumentException::class)->with([ + 'loopback IPv4' => 'http://127.0.0.1', + 'private IPv4' => 'http://10.0.0.1', + 'link-local IPv4' => 'http://169.254.169.254', + 'multicast IPv4' => 'http://224.0.0.1', + 'loopback IPv6' => 'http://[::1]', + 'private IPv6' => 'http://[fc00::1]', + 'link-local IPv6' => 'http://[fe80::1]', + 'site-local IPv6' => 'http://[fec0::1]', +]); + +it('rejects unsupported URL schemes', function () { + app(ResolvePublicUrl::class)->handle('file:///etc/passwd'); +})->throws(InvalidArgumentException::class);