Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/Actions/Integrations/ResolvePublicUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace Cachet\Actions\Integrations;

use Cachet\Data\ResolvedPublicUrl;
use InvalidArgumentException;

class ResolvePublicUrl
{
/** @var list<string> */
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<string> */
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',
Comment thread
jbrooksuk marked this conversation as resolved.
];

/**
* 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<string>
*/
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);
}
}
24 changes: 24 additions & 0 deletions src/Data/ResolvedPublicUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Cachet\Data;

final class ResolvedPublicUrl extends BaseData
{
public function __construct(
public readonly string $url,
public readonly string $host,
public readonly int $port,
public readonly string $address,
) {}

/**
* Format the validated DNS result for cURL address pinning.
*/
public function curlResolve(): string
{
$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);
}
}
25 changes: 22 additions & 3 deletions src/Filament/Pages/Integrations/OhDear.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;

/**
* @property Schema $form
Expand Down Expand Up @@ -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());

Expand Down
35 changes: 35 additions & 0 deletions tests/Feature/Filament/Integrations/OhDearTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
});
34 changes: 34 additions & 0 deletions tests/Unit/Actions/Integrations/ResolvePublicUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

use Cachet\Actions\Integrations\ResolvePublicUrl;

it('resolves a public IP URL for a pinned request', function () {
$resolvedUrl = app(ResolvePublicUrl::class)->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);
Loading