diff --git a/app/Http/Requests/PDFConfigurationRequest.php b/app/Http/Requests/PDFConfigurationRequest.php index 3e6ef3ec..4cfaf646 100644 --- a/app/Http/Requests/PDFConfigurationRequest.php +++ b/app/Http/Requests/PDFConfigurationRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\SafeRemoteUrl; use Illuminate\Foundation\Http\FormRequest; class PDFConfigurationRequest extends FormRequest @@ -37,6 +38,7 @@ class PDFConfigurationRequest extends FormRequest 'gotenberg_host' => [ 'required', 'url', + new SafeRemoteUrl, ], 'gotenberg_papersize' => [ 'required', diff --git a/app/Rules/SafeRemoteUrl.php b/app/Rules/SafeRemoteUrl.php new file mode 100644 index 00000000..1f01245b --- /dev/null +++ b/app/Rules/SafeRemoteUrl.php @@ -0,0 +1,122 @@ + + */ + protected static function resolve(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP)) { + return [$host]; + } + + $ips = []; + $records = @dns_get_record($host, DNS_A + DNS_AAAA); + + if ($records) { + foreach ($records as $record) { + if (! empty($record['ip'])) { + $ips[] = $record['ip']; + } elseif (! empty($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + + if (empty($ips)) { + $resolved = gethostbynamel($host); + if ($resolved !== false) { + $ips = $resolved; + } + } + + return $ips; + } + + protected static function isPublicIp(string $ip): bool + { + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $long = ip2long($ip); + $blockedRanges = [ + ['127.0.0.0', '127.255.255.255'], // loopback + ['169.254.0.0', '169.254.255.255'], // link-local + cloud metadata + ['100.64.0.0', '100.127.255.255'], // CGNAT + ['0.0.0.0', '0.255.255.255'], // "this" network + ]; + + foreach ($blockedRanges as [$start, $end]) { + if ($long >= ip2long($start) && $long <= ip2long($end)) { + return false; + } + } + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $lower = strtolower($ip); + if ($lower === '::1' + || str_starts_with($lower, 'fe80') + || str_starts_with($lower, 'fc') + || str_starts_with($lower, 'fd')) { + return false; + } + } + + return true; + } +} diff --git a/app/Services/PDFDrivers/GotenbergPDFDriver.php b/app/Services/PDFDrivers/GotenbergPDFDriver.php index 73b7c5fa..12b187c8 100644 --- a/app/Services/PDFDrivers/GotenbergPDFDriver.php +++ b/app/Services/PDFDrivers/GotenbergPDFDriver.php @@ -2,6 +2,7 @@ namespace App\Services\PDFDrivers; +use App\Rules\SafeRemoteUrl; use Gotenberg\Gotenberg; use Gotenberg\Stream; use Illuminate\Http\Response; @@ -43,6 +44,14 @@ class GotenbergPDFDriver } $host = config('pdf.connections.gotenberg.host'); + + // Defense in depth against SSRF: a host that bypassed request-time + // validation (env/seed/stale config, or DNS rebinding) must still not + // be able to target internal/private addresses. + if (! SafeRemoteUrl::isSafe((string) $host)) { + throw new \RuntimeException('Refusing to render PDF: unsafe Gotenberg host.'); + } + $request = Gotenberg::chromium($host) ->pdf() ->margins(0, 0, 0, 0) // Margins can be set using CSS diff --git a/tests/Unit/Rules/SafeRemoteUrlTest.php b/tests/Unit/Rules/SafeRemoteUrlTest.php new file mode 100644 index 00000000..daa9e4ca --- /dev/null +++ b/tests/Unit/Rules/SafeRemoteUrlTest.php @@ -0,0 +1,45 @@ +toBeFalse(); +})->with([ + 'http://127.0.0.1', + 'http://169.254.169.254', // cloud metadata + 'http://10.0.0.5', + 'http://192.168.1.1', + 'http://172.16.0.1', + 'http://100.64.0.1', // CGNAT + 'http://[::1]', + 'http://0.0.0.0', + 'ftp://8.8.8.8', // non-http scheme + 'not-a-url', +]); + +test('allows public http(s) addresses', function (string $url) { + expect(SafeRemoteUrl::isSafe($url))->toBeTrue(); +})->with([ + 'http://8.8.8.8', + 'https://1.1.1.1', + 'http://93.184.216.34', +]); + +test('fails validation for an internal gotenberg host', function () { + $validator = Validator::make( + ['gotenberg_host' => 'http://169.254.169.254'], + ['gotenberg_host' => [new SafeRemoteUrl]] + ); + + expect($validator->fails())->toBeTrue(); +}); + +test('passes validation for a public gotenberg host', function () { + $validator = Validator::make( + ['gotenberg_host' => 'https://1.1.1.1'], + ['gotenberg_host' => [new SafeRemoteUrl]] + ); + + expect($validator->fails())->toBeFalse(); +});