Skip to main content
Duro makes outbound HTTP to two kinds of destination: ones we control (the WhatsApp Cloud API, at a fixed graph.facebook.com URL) and ones the merchant controls (webhook endpoints). The second kind is the dangerous one, and it gets a dedicated guard.

The threat

A webhook URL is typed by the merchant, and the delivery’s response body is persisted and rendered in the dashboard’s delivery inspector. That combination is a classic read-SSRF: The fetch originates from inside the trust boundary, so it can reach internal services and cloud metadata that the merchant never could directly — and then read the response back out.

The guard, in two layers

WebhookUrlGuard blocks this at creation and at delivery, because a single check is insufficient against DNS rebinding.

Layer 1 — format validation at write time

isFormatSafe(url) rejects anything that isn’t a public https endpoint:
  • protocol must be https
  • host must not be localhost, *.localhost, *.internal, *.local, or metadata.google.internal
  • if the host is an IP literal, it must not be private/reserved
The private-range checks cover the full set, for IPv4 and bracketed IPv6:
RangeWhy it’s blocked
10/8, 172.16/12, 192.168/16RFC-1918 private
127/8, ::1loopback
169.254/16link-local + cloud metadata
100.64/10CGNAT
198.18/15benchmarking
0/8, 255/8unspecified / broadcast
fc00::/7, fe80::/10IPv6 ULA + link-local
::ffff:0:0/96IPv4-mapped (extract and re-check the v4)

Layer 2 — DNS resolution at delivery time

A hostname that passed Layer 1 can still resolve to a private IP — that’s DNS rebinding: register evil.com, point it at a public IP to pass creation, then flip it to 169.254.169.254 before delivery. So at delivery, assertResolvable(url) resolves the host and rejects if any returned address is private. A blocked delivery fails terminally with blocked destination and is not retried.

Outbound we control is fixed

The WhatsApp client only ever calls https://graph.facebook.com/{version}/{phoneNumberId}/messages — a constant URL with no user-controlled host — so it carries no SSRF surface. The guard exists specifically for the merchant-controlled case. The guard is unit-tested across the whole matrix above (https/non-https, loopback, metadata, private ranges, bracketed IPv6, garbage input). It’s the concrete answer to “what stops a malicious merchant from turning your webhook system into a credential reader.” That’s the system. Jump to the API Reference to start firing requests.