mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 13:55:21 +00:00
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:
committed by
GitHub
parent
e92b08ef6a
commit
d615cfb4ff
@@ -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
122
app/Rules/SafeRemoteUrl.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
45
tests/Unit/Rules/SafeRemoteUrlTest.php
Normal file
45
tests/Unit/Rules/SafeRemoteUrlTest.php
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user