Files
InvoiceShelf/app/Rules/SafeRemoteUrl.php
Darko Gjorgjijoski d615cfb4ff 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.
2026-06-12 09:18:57 +02:00

123 lines
3.4 KiB
PHP

<?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;
}
}