Summary
The SSRF protection added for alert-channel webhooks does not reject the non-global 198.18.0.0/15 IPv4 benchmarking range. An authenticated project member can configure a webhook to a service reachable on that internal range. Both the storage-time URL check and the delivery-time address check allow it, and the alert worker sends the POST.
RFC 6890 designates 198.18.0.0/15 as Benchmarking with Global: False and Forwardable: True. It can therefore be routed inside a deployment while being unreachable from the public Internet. This is a bypass of the webhook SSRF hardening introduced in the v4.5.2 security release.
Impact
A low-privilege authenticated tenant can make the webapp POST its fixed alert payload to HTTP services on an internally routed RFC 2544 benchmarking network. The response body is not returned, so the impact remains blind SSRF: internal reachability and timing/status probing plus limited state-changing POST requests. Exploitability depends on the deployment routing this special-purpose range internally.
Reproduction
The following harmless local lab uses a Docker network in the affected range and a go-httpbin canary:
docker network create --subnet 198.18.0.0/16 trigger-ssrf-lab
docker run -d --rm --name trigger-ssrf-canary \
--network trigger-ssrf-lab --ip 198.18.0.2 \
ghcr.io/mccutchen/go-httpbin:latest
Run safeWebhookFetch against a denied loopback control and the benchmarking-range canary:
await safeWebhookFetch("http://127.0.0.1:8080/anything", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ harmless: true }),
});
await safeWebhookFetch("http://198.18.0.2:8080/anything", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ harmless: true }),
});
Observed output using the current upstream implementation:
{"test":"guard","address":"127.0.0.1","result":"blocked"}
{"test":"guard","address":"100.64.0.1","result":"blocked"}
{"test":"guard","address":"198.18.0.2","result":"allowed","normalized":"http://198.18.0.2:8080/anything"}
{"name":"blocked-loopback-control","result":"blocked-or-failed","error":"Webhook URL points at a private/loopback/link-local address: 127.0.0.1"}
{"name":"allowed-benchmark-canary","result":"connected","status":200}
The canary recorded the backend-origin request:
status=200 method=POST uri=/anything client_ip=198.18.0.1
The same safeWebhookUrl.server.ts and safeWebhookFetch.server.ts files are present byte-for-byte in v4.5.4 and current main at commit b902e65dfbcaebb10abf4f136547948f4d4796a6.
Root Cause / Technical Details
apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts implements isUnsafeIPv4 as a hand-written list. It blocks 0/8, 127/8, RFC1918, 169.254/16, 100.64/10, multicast, and 240/4, but omits 198.18/15.
assertAddressAllowed delegates to the same incomplete function for DNS answers. assertSafeWebhookUrlLexical also delegates to it for IP literals. Consequently, neither the create-time check in CreateAlertChannelService nor the connection-bound check in safeWebhookFetch rejects a destination in 198.18/15.
Summary
The SSRF protection added for alert-channel webhooks does not reject the non-global
198.18.0.0/15IPv4 benchmarking range. An authenticated project member can configure a webhook to a service reachable on that internal range. Both the storage-time URL check and the delivery-time address check allow it, and the alert worker sends the POST.RFC 6890 designates
198.18.0.0/15as Benchmarking withGlobal: FalseandForwardable: True. It can therefore be routed inside a deployment while being unreachable from the public Internet. This is a bypass of the webhook SSRF hardening introduced in the v4.5.2 security release.Impact
A low-privilege authenticated tenant can make the webapp POST its fixed alert payload to HTTP services on an internally routed RFC 2544 benchmarking network. The response body is not returned, so the impact remains blind SSRF: internal reachability and timing/status probing plus limited state-changing POST requests. Exploitability depends on the deployment routing this special-purpose range internally.
Reproduction
The following harmless local lab uses a Docker network in the affected range and a go-httpbin canary:
Run
safeWebhookFetchagainst a denied loopback control and the benchmarking-range canary:Observed output using the current upstream implementation:
{"test":"guard","address":"127.0.0.1","result":"blocked"} {"test":"guard","address":"100.64.0.1","result":"blocked"} {"test":"guard","address":"198.18.0.2","result":"allowed","normalized":"http://198.18.0.2:8080/anything"} {"name":"blocked-loopback-control","result":"blocked-or-failed","error":"Webhook URL points at a private/loopback/link-local address: 127.0.0.1"} {"name":"allowed-benchmark-canary","result":"connected","status":200}The canary recorded the backend-origin request:
The same
safeWebhookUrl.server.tsandsafeWebhookFetch.server.tsfiles are present byte-for-byte in v4.5.4 and current main at commitb902e65dfbcaebb10abf4f136547948f4d4796a6.Root Cause / Technical Details
apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.tsimplementsisUnsafeIPv4as a hand-written list. It blocks 0/8, 127/8, RFC1918, 169.254/16, 100.64/10, multicast, and 240/4, but omits 198.18/15.assertAddressAlloweddelegates to the same incomplete function for DNS answers.assertSafeWebhookUrlLexicalalso delegates to it for IP literals. Consequently, neither the create-time check inCreateAlertChannelServicenor the connection-bound check insafeWebhookFetchrejects a destination in 198.18/15.