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. It does so both when you save the channel and again when it sends. There is one deliberate override for a private sink, and there is one limitation that the screening does not fully close.
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 | By design, 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 |
| On the egress allowlist | Only when an owner has set an outbound egress allowlist: the host matches an entry exactly, or matches a *.suffix entry | An account can limit its channels to hosts it names. An unset or empty list allows every host |
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, unspecified, 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, or octal spelling to its canonical dotted-quad before the check runs. 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. A passkey or Access sign-in in the last five minutes satisfies the gate. Otherwise the console asks you to re-authenticate when you save. An OIDC or SAML session always needs a fresh passkey assertion. 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. At send time it also resolves a hostname, as the next section describes. 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 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 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 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. A configured egress allowlist still applies. The override relaxes only the internal-target rule, and only for the channel it is set on.
The limitation that is not fully closed: DNS rebinding
The save-time check reads only the host’s literal spelling. At send time, the engine also resolves a hostname over DNS-over-HTTPS. It asks for the A and AAAA records. If any answer is a private, loopback, or link-local address, the engine refuses the POST and records internal-sink-resolved on the delivery history (screenResolvedSinkHost and deliverPayload, engine/src/notify/types.ts). This check refuses a name that resolved to a public address when you saved the channel and points at private space later.
DNS rebinding is narrowed, not closed
A Worker cannot pin the address the engine resolved for the POST that follows. The fetch resolves the name again. An attacker who changes the record between the two lookups can still win that race. The resolve check also fails open. If the resolver returns no address, or does not answer within 2 seconds, the engine sends the POST and records resolve-unavailable on the delivery history. A channel with the private-sink override skips the resolve check.
In summary, 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. The engine also refuses a hostname that resolves to such an address at send time. A per-channel override exists for a deliberate private sink and intentionally allows a private IP. A hostname that rebinds between the engine’s lookup and the POST is not caught, and neither is one that the resolver does not answer for. The screening does not close these two cases.
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. From engine 0.3.6, the generic webhook body also carries a dedupKey made from the downpipe id and the event name, or from the event name alone for an account-level event, and recovered: true on a recovery. 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.
The webhook URL itself never reaches a tamper-evident record in full. A notify channel is the one way to configure a webhook, Slack, or Teams destination, and its endpoint URL is treated as a bearer credential: 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 built from webhookHost, and urlConfigured, engine/src/admin/config-snapshot.ts). The notifications screen’s own channel table is close to as strict: for a URL that parses it shows the origin plus at most the last four characters of the path, and a shorter path elides entirely, so the host stays recognisable while the token in the path does not (channelTargetDisplay, console/src/screens/notifications/shared.ts).
From engine 0.3.6, a channel change that applies at once (config approval off) adds a notify-channel-change entry to the audit chain. The entry carries the closed change kind and the channel id, never the URL (recordNotifyApplied, engine/src/admin/router-ops.ts). While config approval is on, a channel change that waits for approval adds a config-change-propose entry. The approval that applies it adds a config-change-approve entry, not a notify-channel-change entry. Both carry only the closed change kind, never the URL (engine/src/sched/scheduler-do-change-control.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 are the webhook private-sink residual and the local-test loopback for the backup destination endpoint. They are listed together in the threat model, which links back here for the operational detail.
Last updated .