Improve outbound URL validation

This commit is contained in:
Andras Bacsai committed 2026-07-02 14:46:46 +02:00
1 parent 63d6d835a9
commit c7f014017b
12 files changed
+440 -85

No files matched your search

+154 -37
View File
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
class SafeWebhookUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/**
* Run the validation rule.
*
@@ -39,63 +44,175 @@ class SafeWebhookUrl implements ValidationRule
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
$this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.');
return;
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to loopback or link-local addresses.');
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if ($this->isBlockedIp($hostForIpCheck)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to loopback or link-local addresses.');
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
foreach ($resolvedIps as $resolvedIp) {
if ($this->isBlockedIp($resolvedIp)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to loopback or link-local addresses.');
return;
}
}
}
private function isLoopback(string $ip): bool
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
if ($this->resolver instanceof Closure) {
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
private function isBlockedIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isBlockedIpv4($embeddedIpv4);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isBlockedIpv4($ip);
}
return $this->isBlockedIpv6($ip);
}
private function isBlockedIpv4(string $ip): bool
{
// 127.0.0.0/8, 0.0.0.0
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
return true;
}
// IPv6 loopback
$normalized = @inet_pton($ip);
return $normalized !== false && $normalized === inet_pton('::1');
}
private function isLinkLocal(string $ip): bool
{
// 169.254.0.0/16 — covers cloud metadata at 169.254.169.254
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$long = ip2long($ip);
if ($long === false) {
return false;
}
$long = ip2long($ip);
$unsigned = sprintf('%u', $long);
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
}
private function isBlockedIpv6(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
return true;
}
$bytes = unpack('C16', $packed);
if ($bytes === false) {
return false;
}
$firstByte = $bytes[1];
$secondByte = $bytes[2];
// fe80::/10 link-local and fc00::/7 unique local addresses.
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|| (($firstByte & 0xFE) === 0xFC);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $host): void
{
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $host, string $blockedIp): void
{
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'resolved_ip' => $blockedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
}