From 5e366275fb177c4a92f41fbd88df8e8fdc71ce34 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 26 Aug 2026 16:46:01 +0100 Subject: [PATCH 1/3] Harden Oh Dear feed imports Reject private or special-use destinations, pin validated DNS results, disable redirects, and bound outbound connection and response times. --- src/Actions/Integrations/ResolvePublicUrl.php | 155 ++++++++++++++++++ src/Data/ResolvedPublicUrl.php | 21 +++ src/Filament/Pages/Integrations/OhDear.php | 19 ++- .../Filament/Integrations/OhDearTest.php | 33 ++++ .../Integrations/ResolvePublicUrlTest.php | 26 +++ 5 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 src/Actions/Integrations/ResolvePublicUrl.php create mode 100644 src/Data/ResolvedPublicUrl.php create mode 100644 tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php diff --git a/src/Actions/Integrations/ResolvePublicUrl.php b/src/Actions/Integrations/ResolvePublicUrl.php new file mode 100644 index 00000000..86efb3a0 --- /dev/null +++ b/src/Actions/Integrations/ResolvePublicUrl.php @@ -0,0 +1,155 @@ + */ + 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', + '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..65120fec --- /dev/null +++ b/src/Data/ResolvedPublicUrl.php @@ -0,0 +1,21 @@ +host, $this->port, $this->address); + } +} diff --git a/src/Filament/Pages/Integrations/OhDear.php b/src/Filament/Pages/Integrations/OhDear.php index acfd5c66..2c5d3db8 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,27 @@ 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)) + $resolvedUrl = $resolvePublicUrlAction->handle($this->url); + + $ohDear = Http::withOptions([ + 'allow_redirects' => false, + 'curl' => [\CURLOPT_RESOLVE => [$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..37f65a35 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,34 @@ ->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::fake(); + + 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::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..cea4dd25 --- /dev/null +++ b/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php @@ -0,0 +1,26 @@ +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('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]', +]); + +it('rejects unsupported URL schemes', function () { + app(ResolvePublicUrl::class)->handle('file:///etc/passwd'); +})->throws(InvalidArgumentException::class); From 47f3ce2735f7275aa1d6b299c7af91ef0a440331 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 26 Aug 2026 16:57:39 +0100 Subject: [PATCH 2/3] Close Oh Dear SSRF edge cases Reject legacy site-local IPv6 addresses, format pinned IPv6 destinations correctly, and fail safely when cURL pinning is unavailable. --- src/Actions/Integrations/ResolvePublicUrl.php | 1 + src/Data/ResolvedPublicUrl.php | 5 ++++- src/Filament/Pages/Integrations/OhDear.php | 8 +++++++- tests/Feature/Filament/Integrations/OhDearTest.php | 4 +++- tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php | 8 ++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Actions/Integrations/ResolvePublicUrl.php b/src/Actions/Integrations/ResolvePublicUrl.php index 86efb3a0..65b0949a 100644 --- a/src/Actions/Integrations/ResolvePublicUrl.php +++ b/src/Actions/Integrations/ResolvePublicUrl.php @@ -36,6 +36,7 @@ class ResolvePublicUrl '5f00::/16', 'fc00::/7', 'fe80::/10', + 'fec0::/10', 'ff00::/8', ]; diff --git a/src/Data/ResolvedPublicUrl.php b/src/Data/ResolvedPublicUrl.php index 65120fec..9eff3b88 100644 --- a/src/Data/ResolvedPublicUrl.php +++ b/src/Data/ResolvedPublicUrl.php @@ -16,6 +16,9 @@ public function __construct( */ public function curlResolve(): string { - return sprintf('%s:%d:%s', $this->host, $this->port, $this->address); + $host = str_contains($this->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 2c5d3db8..64944f74 100644 --- a/src/Filament/Pages/Integrations/OhDear.php +++ b/src/Filament/Pages/Integrations/OhDear.php @@ -107,11 +107,17 @@ public function importFeed(ImportOhDearFeed $importOhDearFeedAction, ResolvePubl $this->validate(); try { + 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' => [\CURLOPT_RESOLVE => [$resolvedUrl->curlResolve()]], + 'curl' => [$curlResolveOption => [$resolvedUrl->curlResolve()]], ]) ->connectTimeout(5) ->timeout(10) diff --git a/tests/Feature/Filament/Integrations/OhDearTest.php b/tests/Feature/Filament/Integrations/OhDearTest.php index 37f65a35..ba1ddd74 100644 --- a/tests/Feature/Filament/Integrations/OhDearTest.php +++ b/tests/Feature/Filament/Integrations/OhDearTest.php @@ -24,7 +24,7 @@ }); it('rejects private import URLs before sending a request', function () { - Http::fake(); + Http::preventStrayRequests(); livewire(OhDear::class) ->fillForm(['url' => 'http://169.254.169.254']) @@ -35,6 +35,8 @@ }); it('imports from a validated public URL', function () { + Http::preventStrayRequests(); + Http::fake([ 'https://93.184.216.34/json' => Http::response([ 'sites' => [], diff --git a/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php b/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php index cea4dd25..8a6c7a8e 100644 --- a/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php +++ b/tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php @@ -9,6 +9,13 @@ ->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([ @@ -19,6 +26,7 @@ '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 () { From d5be763b62a34022c45745251b1e1e4af4f53ec5 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 26 Aug 2026 17:08:38 +0100 Subject: [PATCH 3/3] Honor data object architecture Keep the resolved URL value object immutable while satisfying the package-wide BaseData contract enforced by the architecture suite. --- src/Data/ResolvedPublicUrl.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Data/ResolvedPublicUrl.php b/src/Data/ResolvedPublicUrl.php index 9eff3b88..03994585 100644 --- a/src/Data/ResolvedPublicUrl.php +++ b/src/Data/ResolvedPublicUrl.php @@ -2,13 +2,13 @@ namespace Cachet\Data; -final readonly class ResolvedPublicUrl +final class ResolvedPublicUrl extends BaseData { public function __construct( - public string $url, - public string $host, - public int $port, - public string $address, + public readonly string $url, + public readonly string $host, + public readonly int $port, + public readonly string $address, ) {} /**