fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#664)

gotenberg_host was validated only with Laravel's 'url' rule, which permits
loopback/private/link-local hosts (e.g. http://127.0.0.1, http://10.0.0.1,
the cloud metadata endpoint http://169.254.169.254). When a PDF renders, the
server POSTs the document HTML to that host — an SSRF primitive.

- Adds App\Rules\SafeRemoteUrl: requires http(s) and rejects any host that
  resolves to a loopback/private/link-local/CGNAT/reserved address (IPv4 and
  IPv6), including literal-IP hosts.
- Wires it into PDFConfigurationRequest for gotenberg_host.
- Adds a defensive re-check in GotenbergPDFDriver before the outbound call to
  cover hosts set via env/seed/stale config or DNS rebinding (TOCTOU).

Adds unit tests for the rule + validator integration.
This commit is contained in:
Darko Gjorgjijoski
2026-06-12 09:18:57 +02:00
committed by GitHub
parent e92b08ef6a
commit d615cfb4ff
4 changed files with 178 additions and 0 deletions

View File

@@ -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',

122
app/Rules/SafeRemoteUrl.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class SafeRemoteUrl implements ValidationRule
{
/**
* Run the validation rule.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! self::isSafe($value)) {
$fail('The :attribute must be a valid, publicly reachable http(s) URL.');
}
}
/**
* A URL is safe when it is http(s) and every IP its host resolves to is a
* public address. This blocks SSRF to loopback, private, link-local
* (including the cloud metadata endpoint 169.254.169.254) and reserved
* ranges. Literal-IP hosts are checked directly.
*/
public static function isSafe(string $url): bool
{
$parts = parse_url($url);
if ($parts === false || empty($parts['host']) || empty($parts['scheme'])) {
return false;
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = trim($parts['host'], '[]');
$ips = self::resolve($host);
// Unresolvable host — reject rather than risk the request.
if (empty($ips)) {
return false;
}
foreach ($ips as $ip) {
if (! self::isPublicIp($ip)) {
return false;
}
}
return true;
}
/**
* @return array<int, string>
*/
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;
}
}

View File

@@ -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

View File

@@ -0,0 +1,45 @@
<?php
use App\Rules\SafeRemoteUrl;
use Illuminate\Support\Facades\Validator;
test('rejects internal, private and reserved hosts', function (string $url) {
expect(SafeRemoteUrl::isSafe($url))->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();
});