Skip to content
downpipes docs

Securing notification webhooks: SSRF defence, the private-sink override, and the DNS-rebinding caveat

A notification webhook is an outbound POST the engine makes to a sink you nominate, so a stalled or failing backup reaches your Slack, PagerDuty, or SIEM without anyone watching the console. Because the engine runs at the Cloudflare edge and POSTs to a URL you supply, a careless or hostile URL could try to turn it into a confused deputy that reaches an address it should never reach. This page is the dedicated account of how that is defended, for the self-hoster who runs the engine and configures the channels.

The defence is server-side request forgery screening, usually shortened to SSRF screening. The engine refuses an outbound webhook to a private, loopback, or link-local target by default, and it does so both when you save the channel and again when it sends. There is one deliberate override for a genuine private sink, and there is one limitation that the screening does not catch. Both are stated plainly here rather than left implied, because overstating the protection would be the more dangerous mistake.

What is screened, and when

The validator isAllowedWebhookUrl in engine/src/notify/types.ts (re-exported from engine/src/notify.ts) checks a customer-supplied URL at the authority boundary, the same discipline the engine applies to a downpipe id. A URL must clear every one of these before it is stored.

Rule What it requires Why
Parses as a URL A valid absolute URL, at most 2048 characters A malformed value is rejected before anything else
https only The scheme must be https:; http: is rejected The payload carries your own downpipe ids and names, so it is never sent in cleartext
No userinfo No username or password in the authority, so no user@host form A credentials-in-URL form is a host-spoof and phishing vector, and no real Slack, PagerDuty, or SIEM ingest URL needs it
Not a workers.dev host The host is not workers.dev or any subdomain of it A house rule: alerts point at a real custom-domain sink, and this also blocks an accidental loopback to a Worker preview
Not an internal target By default, the host does not resolve by its literal spelling to a private, loopback, or link-local address The SSRF default-deny, so a misconfigured or hostile channel cannot make the engine POST to an internal service or a metadata endpoint

The internal-target rule is the SSRF core, and it is the one with the override and the caveat. The classifier isInternalSinkHost treats a host as internal when its literal spelling is loopback, RFC 1918 private space, link-local including the cloud-metadata address, an IPv6 unique-local or link-local address, or an obviously internal name such as localhost or anything ending in .localhost.

Range or name Examples Class
127.0.0.0/8 127.0.0.1 Loopback
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 10.1.2.3, 192.168.0.5 RFC 1918 private
169.254.0.0/16 169.254.169.254 Link-local, includes cloud metadata
100.64.0.0/10 100.64.0.1 Carrier-grade NAT (RFC 6598)
0.0.0.0/8 0.0.0.0 This-host
::1, ::, fc00::/7, fe80::/10 ::1, fd00::1 IPv6 loopback, unique-local, link-local
localhost, *.localhost localhost, api.localhost Internal hostname

The classifier defends against obfuscated IPv4 too. The URL parser collapses a decimal, hexadecimal, octal, or IPv4-mapped IPv6 spelling to its canonical dotted-quad before the check runs, and an IPv4-mapped IPv6 form is re-checked against the embedded address so an internal v4 cannot ride in disguised as IPv6 (isInternalSinkHost and isInternalIpv4, engine/src/notify/types.ts).

The phrase that matters most on this page is “and when”. The screening is not config-time only.

Saving a channel asks you to re-authenticate

Writing or deleting a notification channel or rule is step-up gated on the engine, so the console asks for a fresh passkey assertion at the moment you save. The reason is what a channel write is: it names the destination your operational data is posted to, so a stale browser session that can quietly repoint it has redirected an egress path. The gate is on the write, not on the read, so viewing your channels asks for nothing.

The internal-target deny-list is applied twice. isAllowedWebhookUrl screens the URL when you save the channel, and deliverPayload re-screens the host at send time on every POST. So a channel that somehow reached storage on a path that skipped the save-time check, a future route, a migration, or a hand-edited storage value, still cannot POST to an internal target at send time (deliverPayload, engine/src/notify/types.ts; the channel adapter passes the per-channel opt-in through, engine/src/notify/channels/webhook.ts).

A rejection at save time is a precise 400 naming the reason, so you can correct the URL. A block at send time is a fail-open non-delivery, the same outcome as any other best-effort webhook failure: the alert is simply not delivered on that attempt, and nothing throws into the reconciliation loop.

The private-sink override is a deliberate decision, not a default

Some operators genuinely run their sink on a private network, a SIEM collector inside their own VPC reachable only on an RFC 1918 address. For that case there is a per-channel override, allowInternalSink. When it is set on a channel, that channel is allowed to POST to a private address, at both save time and send time.

The override is opt-in and per channel, and only a literal boolean true enables it. Absent or false leaves the default-deny in force and the channel saves normally; a non-boolean value, truthy or not, is rejected outright, validateChannel returns an error and the whole add or edit is refused rather than being saved with the default-deny silently applied. The flag is stored only when it is true and only on a URL-bearing channel, so it never lands on an email or PagerDuty channel where it would be meaningless (validateChannel, engine/src/notify-routing.ts, re-exported from engine/src/notify.ts).

Turning the override on is an explicit operator choice

allowInternalSink intentionally allows a private IP for the channel it is set on. It is the escape hatch for a real in-VPC collector, not a setting to leave on by default. Enabling it is your deliberate, auditable decision to send alerts to a private address, and it removes the SSRF default-deny for that one channel. Leave it off unless your sink genuinely sits on a private network, and set it on the narrowest channel that needs it.

Even with the override on, the other rules still hold for that channel. The URL must be https, must carry no userinfo, and must not be a workers.dev host. The override relaxes only the internal-target rule, and only for the channel it is set on.

The limitation that is not caught: DNS rebinding

The screening checks a host’s literal spelling, and where the host is a name, it checks the obvious internal names. What it does not do is resolve a hostname and pin the resolved address through to the moment of the POST. That leaves one gap, and it is documented rather than implied.

DNS rebinding is not blocked

A hostname that resolves to a public address when you save the channel, and to a private address when the engine later sends, is not caught. The save-time check sees a public address and stores the channel; the send-time check re-screens the same hostname, and if it now resolves to an internal address through a short-lived DNS record, the literal-spelling classifier does not block it because the host is still a name, not an internal-looking literal. Closing this needs resolve-then-pin at send time, a deeper change, and it is tracked as a follow-up. State of the screening today: it blocks literal internal addresses and obvious internal names, it does not block DNS rebinding (isInternalSinkHost limitation note, engine/src/notify/types.ts).

So the honest summary of the SSRF posture is this. By default an outbound webhook to a literal private, loopback, or link-local address, including the cloud-metadata address, is refused at both save time and send time. A per-channel override exists for a deliberate private sink and intentionally allows a private IP. And a hostname that rebinds from public to private between save and send is not caught. The protection is real and worth having, and it is not complete, which is why it is written out here in full.

What the payload carries, regardless of the destination

The destination is your own endpoint, and the payload is your own redaction-safe operational data. A notification body carries the event and severity, an RFC-3339 time, and a one-line detail. A downpipe-scoped event also carries the downpipe id and name, and its detail is the downpipe name and its state; an account-level event such as recovery-code-used, dual-control-disabled, or a role change omits the downpipe entirely (there is none to carry) and its detail names the actor or the action instead, for example who used a recovery code or who turned dual control off. It never carries a key, a record value, a selector, a destination credential, or a fingerprint, because the body is built only from the run-history surface the console already reads (the redaction note in engine/src/notify.ts; WebhookPayloadV1, engine/src/notify/channels/webhook.ts). The SSRF screening protects against the engine reaching the wrong host; the redaction discipline ensures that even your own host receives nothing sensitive beyond your own operational facts.

Whether the webhook URL itself reaches a tamper-evident record depends on which of the two webhook mechanisms this page describes you are using. The legacy, account-wide webhook policy is never written to the tamper-evident audit chain: a change to it is audited on a field-less target that records who changed it and when, never the URL (engine/docs/security/input-validation-and-limits.md, section 10). A notify channel, the current, per-channel way to configure a webhook, Slack, or Teams destination, works differently: its endpoint URL is treated as a bearer credential, so only its host, and any non-default port, rides into the signed, hash-chained config-history version, alongside a boolean recording that a URL is configured; the path, query string, and any token embedded in the URL are never stored there (ConfigNotifyChannel.urlHost and urlConfigured, engine/src/admin/config-snapshot.ts). That is a stricter redaction than the notifications screen’s own channel table, which shows a middle-truncated form of the full URL so the host stays recognisable. Adding or editing a channel does not append an entry to the audit chain at all (addNotifyChannel, engine/src/sched/scheduler-do-notify.ts).

Where this fits

This page is the egress-security detail for one part of the wider alerting feature. To add a channel, write routing rules, and read delivery history, see notifications: channels, routing rules, and delivery history. The override field described here is set per channel on that screen.

The two deliberate SSRF residuals across the whole platform, the webhook private-sink residual and the local-test loopback for the backup destination endpoint, are listed together in the threat model, which links back here for the operational detail. For the precise wording rule behind stating a limitation rather than implying complete protection, read precise claims and honesty.

Last updated .