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 (packages/merchant-api/src/webhooks/url-guard.ts) is applied at endpoint creation and at delivery, because a check performed only at creation is defeated by DNS rebinding.

Layer 1: format validation at write time

isFormatSafe(url) runs as a Zod .refine on the create/update schema (packages/merchant-api/src/webhooks/schema.ts). A failing check surfaces as an HTTP 422 (Zod validation errors map to 422 in packages/errors). It rejects anything that isn’t a public https URL:
  • protocol must be https:
  • host, lowercased and unbracketed, must not be localhost, *.localhost, *.internal, *.local, or metadata.google.internal
  • if the host is an IP literal, it must not be in a private or reserved range
The IP checks cover IPv4 and bracketed IPv6: A malformed IPv4 literal (wrong octet count or out-of-range octet) is treated as private, so it fails closed.
This is an application-level denylist of well-known private and reserved ranges. It is not an exhaustive bogon filter, and it does not, on its own, replace network-level egress controls. It targets the specific SSRF path that matters here: a merchant-supplied URL fetched from inside the trust boundary with its response echoed back.

Layer 2: DNS resolution at delivery time

A hostname that passed Layer 1 can still resolve to a private IP. That is 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 before each POST, the dispatcher calls assertResolvable(url), which re-runs isFormatSafe, resolves the host with dns.lookup(host, { all: true }), and throws if any returned address is private. A blocked delivery is written as failed with body blocked destination, its attempt counter is incremented, and nextRetryAt is set to null, so it is not retried (packages/merchant-api/src/webhooks/dispatcher.ts).
The delivery-time check resolves the hostname itself, then hands the original URL to fetch, which performs its own independent DNS resolution. Between those two lookups there is a time-of-check-to-time-of-use window a determined attacker with fast-flipping DNS could still race. It substantially raises the bar against rebinding but does not fully close it; pinning the validated IP into the request (or forcing egress through a filtering proxy) would. This is a known residual gap, not a claim of complete rebinding immunity.

Additional delivery constraints

The dispatcher applies a few more limits that bound the blast radius of any single outbound call:
  • Request timeout. Each POST is aborted after 10s via AbortController.
  • Stored response is truncated. Only the first 2000 characters of the response body are persisted, capping how much an echoed response can return.
  • Signed, not authenticated by us. Every delivery carries an HMAC signature header (duro-signature) plus duro-event-id / duro-event-type; the receiver verifies it. See the webhook signing scheme.

Outbound we control is fixed

The WhatsApp client (packages/whatsapp/src/index.ts) is used to deliver customer identity OTP codes. It only ever calls https://graph.facebook.com/{apiVersion}/{phoneNumberId}/messages, where apiVersion and phoneNumberId come from server configuration, never from a request. The host is constant and no part of the URL is merchant-controlled, so this path carries no SSRF surface. When the WhatsApp credentials are not configured, the factory returns a no-op client that sends nothing (WhatsAppFactory.fromConfig). The guard exists specifically for the merchant-controlled case. It is unit-tested across the matrix above (https vs non-https, loopback, metadata, private ranges, bracketed IPv6, malformed input) in packages/merchant-api/tests/url-guard.test.ts. That is the egress story. Jump to the API Reference to start firing requests.