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, ormetadata.google.internal - if the host is an IP literal, it must not be in a private or reserved range
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: registerevil.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).
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) plusduro-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.