The SSRF guard is exported, and the IPv6 half of it never ran
Affects: backend/src/services/webhook-dispatcher.ts, backend/test/webhook-dispatcher.test.ts
What was wrong
Outbound webhooks are the one place this API makes a network request to an address a tenant chose. assertSafeDestination exists to stop a tenant pointing a subscription at http://169.254.169.254/ and having the server fetch its own cloud metadata on their behalf. The function was there, it was called on every delivery, and its entire IPv6 half had never executed once.
Three separate defects, found by asserting each blocked range individually rather than asserting that the guard exists:
Brackets. new URL("https://[::1]/").hostname returns the string [::1] — brackets included. isIP("[::1]") is 0, so every IPv6 literal was classified as a hostname and sent down the DNS path, and the entire v6 blocklist below was unreachable code. It failed closed, but only by accident: getaddrinfo happens to reject a bracketed string as a hostname. An accident is not a control, and it is not one that survives a resolver change.
The mapped-address spelling. ::ffff:127.0.0.1 is loopback wearing a v6 suit. The code knew that and checked for it — against the dotted spelling. The WHATWG parser normalises the address to hex before hostname is ever read, so what actually arrives is ::ffff:7f00:1, the old .slice(7) produced 7f00:1, that matched no prefix and parsed to NaN octets, and the address was allowed. This one failed open, directly to the local machine, once the bracket defect above was fixed. Fixing one bug armed the other.
`fe80` is not `fe80::/10`. The link-local range runs fe80:: through febf::. Matching the literal "fe80" blocks fe80::1 and admits fe93::1, which is the same address space.
The decision
assertSafeDestination is exported, and each blocked range has its own test case.
The export is the part a reviewer will object to, so: this is a security control that was previously reachable only through dispatchWebhook. Testing it that way costs a live socket, a seeded subscription and a stubbed fetch per case, which is enough friction that the blocklist ended up covered by one case instead of twenty — and three bypasses sat in the file. Ten ranges asserted individually is what found this. The alternative on offer was "keep it private", and that is precisely what was in place while the bugs were there.
The guard now strips brackets before anything else looks at the string, checks IPv4-mapped addresses against the v4 rules (an address in that range is not really v6 at all), handles both spellings of the mapping, and writes link-local as fe[89ab] rather than as fe80.
What is still open
DNS rebinding. The guard resolves the hostname, checks the address, and then hands the original URL to fetch, which resolves it a second time. A resolver that answers differently between those two lookups defeats the check. Closing it means pinning the connection to the validated address, which means a custom agent and a good deal of machinery. It is recorded here rather than fixed because the attack requires the attacker to control authoritative DNS for a domain, and the ranges above are the exposure that a misconfigured or curious tenant actually reaches.