fix(validation): improve private URL handling and error feedback

Reject link-local targets even when allowlisted, link private-target errors to endpoint settings, and tighten persistent-volume table widths.
This commit is contained in:
Andras Bacsai
2026-08-14 13:34:00 +02:00
parent 857375f69c
commit a257224bb7
8 changed files with 126 additions and 185 deletions
+7 -175
View File
@@ -2,178 +2,10 @@
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class SafeExternalUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/**
* Run the validation rule.
*
* Validates that a URL points to an external, publicly-routable host.
* Blocks private IP ranges, reserved ranges, localhost, and link-local
* addresses to prevent Server-Side Request Forgery (SSRF).
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! filter_var($value, FILTER_VALIDATE_URL)) {
$fail('The :attribute must be a valid URL.');
return;
}
$scheme = strtolower(parse_url($value, PHP_URL_SCHEME) ?? '');
if (! in_array($scheme, ['https', 'http'])) {
$fail('The :attribute must use the http or https scheme.');
return;
}
$host = parse_url($value, PHP_URL_HOST);
if (! $host) {
$fail('The :attribute must contain a valid host.');
return;
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
$internalHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForDns, $internalHosts, true) || str_ends_with($hostForDns, '.local') || str_ends_with($hostForDns, '.internal')) {
$this->logBlockedHost($attribute, $value, $host);
$fail('The :attribute must not point to internal hosts.');
return;
}
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (! $this->isPublicIp($hostForIpCheck)) {
$this->logBlockedIp($attribute, $value, $host, $hostForIpCheck);
$fail('The :attribute must not point to a private or reserved IP address.');
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isPublicIp($resolvedIp)) {
$this->logBlockedIp($attribute, $value, $host, $resolvedIp);
$fail('The :attribute must not point to a private or reserved IP address.');
return;
}
}
}
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 isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
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 $url, string $host): void
{
Log::warning('External URL points to internal host', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $url, string $host, string $resolvedIp): void
{
Log::warning('External URL resolves to private or reserved IP', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'resolved_ip' => $resolvedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
}
/**
* Backwards-compatible name for outbound URL validation.
*
* External service URLs use the same private-target allowlist as webhooks
* and S3 endpoints.
*/
class SafeExternalUrl extends SafeWebhookUrl {}
+27 -3
View File
@@ -62,7 +62,7 @@ class SafeWebhookUrl implements ValidationRule
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
$this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.');
$fail($this->privateTargetMessage($attribute));
return;
}
@@ -70,7 +70,9 @@ class SafeWebhookUrl implements ValidationRule
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
$fail($this->isLinkLocalIp($hostForIpCheck)
? 'The :attribute must not point to link-local addresses.'
: $this->privateTargetMessage($attribute));
return;
}
@@ -88,13 +90,22 @@ class SafeWebhookUrl implements ValidationRule
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
$fail($this->isLinkLocalIp($resolvedIp)
? 'The :attribute must not resolve to a link-local address.'
: $this->privateTargetMessage($attribute));
return;
}
}
}
private function privateTargetMessage(string $attribute): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
return "The {$attribute} points to a local or private address that is not allowed. Configure allowed internal targets: {$settingsUrl}";
}
/**
* Build HTTP client options that pin the validated host to the resolved IPs.
*
@@ -334,6 +345,10 @@ class SafeWebhookUrl implements ValidationRule
$ip = $embeddedIpv4;
}
if ($this->isLinkLocalIp($ip)) {
return false;
}
if ($this->isPublicIp($ip)) {
return true;
}
@@ -350,6 +365,15 @@ class SafeWebhookUrl implements ValidationRule
return $this->isAllowlistedIp($ip);
}
private function isLinkLocalIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, '169.254.0.0/16');
}
return $this->ipInCidr($ip, 'fe80::/10');
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
+4 -4
View File
@@ -2470,11 +2470,11 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-table-grid {
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) 5rem 17.5rem;
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) 5rem 12rem;
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem 5rem 17.5rem;
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem 5rem 12rem;
}
.volumes-mobile-label {
@@ -2510,7 +2510,7 @@ input[type="search"]::-webkit-search-results-decoration {
@media (max-width: 1100px) {
.volumes-table-grid {
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 17.5rem;
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 12rem;
}
.volumes-table-grid > .volumes-col-source,
@@ -2519,7 +2519,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 17.5rem;
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 12rem;
}
.volumes-table-grid-with-pr > .volumes-col-source,
@@ -63,7 +63,18 @@
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
@php
preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches);
$validationLink = $validationLinkMatches[1] ?? null;
@endphp
<span class="text-red-500 label-text-alt">
@if ($validationLink)
{{ str($message)->beforeLast($validationLink)->trim() }}
<a class="font-medium underline" href="{{ $validationLink }}">Set them here.</a>
@else
{{ $message }}
@endif
</span>
</label>
@enderror
</div>
@@ -197,7 +197,9 @@ it('renders volumes as a data table with shared column headers', function () {
->toContain('@media (max-width: 768px)')
->toContain('.table-badge-success');
expect($css)->toContain('17.5rem');
expect($css)
->toContain('12rem')
->not->toContain('17.5rem');
// Settings form labels are 13px (not Tailwind text-sm 14px).
expect($css)
+21
View File
@@ -0,0 +1,21 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
it('renders a trailing validation error URL as a link', function () {
$settingsUrl = route('settings.advanced').'#endpoint-section';
$errors = new ViewErrorBag;
$errors->put('default', new MessageBag([
'endpoint' => "Local or private IP addresses are not allowed. Configure allowed internal targets: {$settingsUrl}",
]));
View::share('errors', $errors);
$html = Blade::render('<x-forms.input id="endpoint" />');
expect($html)
->toContain('href="'.$settingsUrl.'"')
->toContain('Set them here');
});
+39 -1
View File
@@ -1,10 +1,38 @@
<?php
use App\Models\InstanceSettings;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
uses(TestCase::class, RefreshDatabase::class);
it('accepts allowlisted private targets', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['192.168.1.0/24'],
]));
$validator = Validator::make(
['url' => 'http://192.168.1.23/api/v4'],
['url' => new SafeExternalUrl],
);
expect($validator->passes())->toBeTrue();
});
it('rejects allowlisted link-local targets', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['169.254.0.0/16'],
]));
$validator = Validator::make(
['url' => 'http://169.254.169.254/latest/meta-data'],
['url' => new SafeExternalUrl],
);
expect($validator->fails())->toBeTrue();
});
it('accepts valid public URLs', function () {
$rule = new SafeExternalUrl;
@@ -43,6 +71,16 @@ it('rejects private IPv4 addresses', function (string $url) {
'192.168.x range' => 'http://192.168.1.1',
]);
it('links private target errors to the outbound endpoint settings', function () {
$validator = Validator::make(
['url' => 'http://192.168.1.23'],
['url' => new SafeExternalUrl],
);
expect($validator->errors()->first('url'))
->toContain(route('settings.advanced').'#endpoint-section');
});
it('rejects cloud metadata IP', function () {
$rule = new SafeExternalUrl;
+13
View File
@@ -50,6 +50,19 @@ it('rejects link-local range', function () {
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
});
it('rejects link-local targets even when allowlisted', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['169.254.0.0/16'],
]));
$validator = Validator::make(
['url' => 'http://169.254.169.254/latest/meta-data'],
['url' => new SafeWebhookUrl],
);
expect($validator->fails())->toBeTrue();
});
it('rejects hostnames that resolve to blocked addresses', function (string $url, array $resolvedIps) {
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);