+ */
+ private function allowlistEntries(): array
+ {
+ $entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? [];
+
+ if (is_string($entries)) {
+ $entries = explode(',', $entries);
+ }
+
+ if (! is_array($entries)) {
+ return [];
+ }
+
+ return array_values(array_filter(array_map(
+ fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'),
+ $entries,
+ )));
+ }
+
+ private function allowLocalhost(): bool
+ {
+ return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false);
+ }
+
+ private function instanceSettings(): ?InstanceSettings
+ {
+ try {
+ return InstanceSettings::query()->find(0);
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ private function ipInCidr(string $ip, string $cidr): bool
+ {
+ [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
+ if ($network === null || $prefix === null || ! is_numeric($prefix)) {
+ return false;
+ }
+
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ return $this->ipv4InCidr($ip, $cidr);
+ }
+
+ if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+ return false;
+ }
+
+ $prefix = (int) $prefix;
+ if ($prefix < 0 || $prefix > 128) {
+ return false;
+ }
+
+ $ipBytes = @inet_pton($ip);
+ $networkBytes = @inet_pton($network);
+ if ($ipBytes === false || $networkBytes === false) {
+ return false;
+ }
+
+ return $this->binaryInCidr($ipBytes, $networkBytes, $prefix);
+ }
+
+ private function ipv4InCidr(string $ip, string $cidr): bool
+ {
+ [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
+ if ($network === null || $prefix === null || ! is_numeric($prefix)) {
+ return false;
+ }
+
+ $prefix = (int) $prefix;
+ if ($prefix < 0 || $prefix > 32) {
+ return false;
+ }
+
+ $ipLong = ip2long($ip);
+ $networkLong = ip2long($network);
+ if ($ipLong === false || $networkLong === false) {
+ return false;
+ }
+
+ $mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix));
+
+ return ($ipLong & $mask) === ($networkLong & $mask);
+ }
+
+ private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool
+ {
+ $bytes = intdiv($prefix, 8);
+ $bits = $prefix % 8;
+
+ if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) {
+ return false;
+ }
+
+ if ($bits === 0) {
return true;
}
- $long = ip2long($ip);
- if ($long === false) {
- return false;
- }
+ $mask = 0xFF << (8 - $bits) & 0xFF;
- $unsigned = sprintf('%u', $long);
- $linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
- $linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
-
- 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);
+ return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
diff --git a/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php b/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php
new file mode 100644
index 000000000..ea34d74fa
--- /dev/null
+++ b/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php
@@ -0,0 +1,23 @@
+json('webhook_allowed_internal_hosts')->nullable();
+ $table->boolean('webhook_allow_localhost')->default(false);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn(['webhook_allowed_internal_hosts', 'webhook_allow_localhost']);
+ });
+ }
+};
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php
index fb7da30a7..3a49d0cfa 100644
--- a/resources/views/livewire/settings/advanced.blade.php
+++ b/resources/views/livewire/settings/advanced.blade.php
@@ -70,6 +70,15 @@ class="flex flex-col h-full gap-8 sm:flex-row">
environments!
@endif
+ Webhook/S3 Endpoint Controls
+
+
+
+
MCP Server
$endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
@@ -43,19 +45,15 @@
it('accepts real-world S3 endpoints', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
- ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
+ ['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]],
);
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
})->with([
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
- 'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
- 'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
- 'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
- 'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
- 'Custom domain MinIO' => 'https://minio.example.com',
+ 'Custom public domain S3-compatible endpoint' => 'https://example.com',
]);
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
@@ -91,3 +89,18 @@
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
'internal TLD' => 'http://backend.internal',
]);
+
+it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
+
+ $validator = Validator::make(
+ ['endpoint' => $endpoint],
+ ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
+ );
+
+ expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}");
+})->with([
+ 'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']],
+ 'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
+ 'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
+]);
diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php
index 69dc6fbb5..957ee5da9 100644
--- a/tests/Unit/SafeWebhookUrlTest.php
+++ b/tests/Unit/SafeWebhookUrlTest.php
@@ -1,13 +1,15 @@
['93.184.216.34']);
$validUrls = [
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
@@ -22,17 +24,6 @@
}
});
-it('accepts private network IPs for self-hosted deployments', function (string $url) {
- $rule = new SafeWebhookUrl;
-
- $validator = Validator::make(['url' => $url], ['url' => $rule]);
- expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
-})->with([
- '10.x range' => 'http://10.0.0.5/webhook',
- '172.16.x range' => 'http://172.16.0.1:8080/hook',
- '192.168.x range' => 'http://192.168.1.50:8080/webhook',
-]);
-
it('rejects loopback addresses', function (string $url) {
$rule = new SafeWebhookUrl;
@@ -117,3 +108,153 @@
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
+
+it('rejects private and reserved network targets by default', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
+})->with([
+ 'private 10/8' => 'http://10.0.0.5/webhook',
+ 'private 172.16/12' => 'http://172.16.0.1:8080/hook',
+ 'private 192.168/16' => 'http://192.168.1.50:8080/webhook',
+ 'shared address space' => 'http://100.64.0.1/webhook',
+ 'zero network peer alias' => 'http://0.0.0.1/webhook',
+ 'multicast' => 'http://224.0.0.1/webhook',
+ 'benchmark range' => 'http://198.18.0.1/webhook',
+ 'documentation range' => 'http://192.0.2.10/webhook',
+]);
+
+it('rejects hostname forms that resolve to loopback', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}");
+})->with([
+ 'decimal IPv4' => 'http://2130706433:8888/exfil',
+ 'hex IPv4' => 'http://0x7f000001:8888/exfil',
+ 'octal IPv4' => 'http://017700000001:8888/exfil',
+ 'short dotted IPv4' => 'http://127.1:8888/exfil',
+ 'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil',
+]);
+
+it('rejects internal DNS suffixes by default', function (string $url) {
+ $rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
+})->with([
+ '.local host' => 'http://receiver.local/webhook',
+ '.cluster.local host' => 'http://service.cluster.local/webhook',
+]);
+
+it('rejects unresolvable hostnames by default', function () {
+ $rule = new SafeWebhookUrl(fn (string $host): array => []);
+
+ $validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
+});
+
+it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
+
+ $rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}");
+})->with([
+ 'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']],
+ 'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']],
+]);
+
+it('requires explicit localhost opt in in addition to allowlist', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']]));
+
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in');
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true]));
+
+ $validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
+
+ expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in');
+});
+
+it('builds HTTP client options that pin resolved DNS for the request', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]));
+
+ $options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook');
+
+ expect($options['allow_redirects'])->toBeFalse();
+
+ if (defined('CURLOPT_RESOLVE')) {
+ expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
+ }
+});
+
+it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
+ expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
+ ->toThrow(RuntimeException::class, 'unsafe IP address');
+});
+
+it('builds MinIO client resolve options for S3 backup uploads', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]));
+
+ $options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
+
+ expect($options)->toContain('localhost:9000=127.0.0.1');
+});
+
+it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
+ $rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
+
+ $validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection');
+
+ expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook'))
+ ->toThrow(RuntimeException::class, 'trailing dot');
+});
+
+it('rejects reserved IPv6 ranges by default', function (string $url) {
+ $rule = new SafeWebhookUrl;
+
+ $validator = Validator::make(['url' => $url], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}");
+})->with([
+ 'documentation IPv6' => 'http://[2001:db8::1]/webhook',
+ 'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook',
+ '6to4' => 'http://[2002::1]/webhook',
+]);
+
+it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) {
+ $rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]);
+
+ $validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}");
+})->with([
+ '2001:db8::1',
+ '64:ff9b::1',
+ '2002::1',
+]);
+
+it('redacts webhook URLs for logs', function () {
+ expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
+ ->toBe('https://hooks.slack.com');
+});
From 3988ad6921a5415782eb323d6347831cbe96194a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:47:55 +0200
Subject: [PATCH 9/9] fix(webhooks): resolve hostnames using custom DNS servers
Keep webhook SSRF DNS checks active when general DNS validation is disabled, and include localhost loopback resolution in safe URL validation.
---
app/Rules/SafeWebhookUrl.php | 60 +++++++++++++++++++++++++++++++
tests/Unit/SafeWebhookUrlTest.php | 20 +++++++++++
2 files changed, 80 insertions(+)
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
index e171ba46c..478b05197 100644
--- a/app/Rules/SafeWebhookUrl.php
+++ b/app/Rules/SafeWebhookUrl.php
@@ -6,6 +6,8 @@
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
+use PurplePixie\PhpDns\DNSQuery;
+use PurplePixie\PhpDns\DNSTypes;
use Throwable;
class SafeWebhookUrl implements ValidationRule
@@ -208,6 +210,15 @@ private function resolveHost(string $host): array
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
+ if ($host === 'localhost') {
+ return ['127.0.0.1', '::1'];
+ }
+
+ $customDnsServers = $this->customDnsServers();
+ if ($customDnsServers !== []) {
+ return $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
+ }
+
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
@@ -234,6 +245,55 @@ private function resolveHost(string $host): array
return array_values(array_unique($ips));
}
+ /**
+ * @param array $dnsServers
+ * @return array
+ */
+ private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
+ {
+ $ips = [];
+
+ foreach ($dnsServers as $dnsServer) {
+ foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
+ try {
+ $query = new DNSQuery($dnsServer, 53, 5);
+ $records = $query->query($host, $type);
+
+ if ($records === false || $query->hasError()) {
+ continue;
+ }
+
+ foreach ($records as $record) {
+ if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) {
+ $ips[] = $record->getData();
+ }
+ }
+ } catch (Throwable) {
+ continue;
+ }
+ }
+ }
+
+ return array_values(array_unique($ips));
+ }
+
+ /**
+ * @return array
+ */
+ private function customDnsServers(): array
+ {
+ $servers = $this->instanceSettings()?->custom_dns_servers ?? '';
+
+ if (! is_string($servers)) {
+ return [];
+ }
+
+ return array_values(array_filter(array_map(
+ fn (string $server): string => trim($server),
+ explode(',', $servers),
+ ), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false));
+ }
+
private function isAllowedIp(string $ip, string $host): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php
index 957ee5da9..de0c06260 100644
--- a/tests/Unit/SafeWebhookUrlTest.php
+++ b/tests/Unit/SafeWebhookUrlTest.php
@@ -159,6 +159,26 @@
expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
});
+it('keeps webhook DNS resolution enabled when general DNS validation is disabled', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['is_dns_validation_enabled' => false]));
+
+ $rule = new SafeWebhookUrl(fn (string $host): array => ['127.0.0.1']);
+
+ $validator = Validator::make(['url' => 'http://rebinding.example.test/webhook'], ['url' => $rule]);
+
+ expect($validator->fails())->toBeTrue('Expected webhook SSRF DNS checks to remain enabled');
+});
+
+it('reads configured custom DNS servers for webhook hostname resolution', function () {
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, invalid, 2606:4700:4700::1111']));
+
+ $method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers');
+ $method->setAccessible(true);
+
+ expect($method->invoke(new SafeWebhookUrl))
+ ->toBe(['1.1.1.1', '2606:4700:4700::1111']);
+});
+
it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));