fix: close the webhook SSRF denylist gaps (ENG-2310) - #129
Conversation
The webhook URL classifier relied entirely on net/netip's predicates, which model only RFC1918 + fc00::/7, 127/8 + ::1, 169.254/16 + fe80::/10, ff02:: and the single unspecified address. Every other private or reserved range passed both the create/update check and the dial-time check, most notably CGNAT 100.64.0.0/10 — Tailscale addresses tailnets out of it, and it is a range the Formbricks side already blocks and has a test for. Add a CIDR table for the ranges netip has no predicate for, alongside the existing predicates rather than replacing them: IsLinkLocalUnicast covers the whole of fe80::/10, which a string-prefix classifier would not, so swapping the predicates out for a prefix table would open a gap instead of closing one. IsLinkLocalMulticast also becomes IsMulticast, which covers 224/4 and ff00::/8 in full — the old check leaked every multicast scope above ff02::. Newly rejected: 0.0.0.0/8 (IsUnspecified matches only 0.0.0.0 itself), 100.64.0.0/10, 192.0.0.0/24, the three TEST-NETs, 198.18.0.0/15, 240.0.0.0/4, 255.255.255.255, 168.63.129.16 (Azure WireServer, outside 169.254/16), 64:ff9b::/96 and 64:ff9b:1::/48 (NAT64), 2002::/16 (6to4), fec0::/10, and the remaining multicast scopes. Blocking CGNAT would otherwise be a silent breaking change for operators whose receivers sit on a tailnet, and there was no way to opt out, so add WEBHOOK_ALLOWED_CIDRS to re-permit specific ranges. It is scoped to the range check and does not override WEBHOOK_BLACKLIST, which stays an explicit deny. A malformed entry fails startup rather than being skipped: this list widens what is reachable, so a typo must not be silently absorbed. validateWebhookHost becomes a thin wrapper over resolveWebhookHost. The two were ~90% duplicated, which is how create-time and dial-time validation could drift apart in the first place; now they are provably the same check.
The integration fixtures target 192.0.2.1, which the widened SSRF classifier now correctly rejects as a reserved documentation range. Allowlist just that range in the harness rather than repointing the fixtures at a hostname: the literal keeps them hermetic (no DNS in the test path) and it exercises the new WEBHOOK_ALLOWED_CIDRS plumbing end to end.
Self-review findings on the CIDR list added earlier in this branch.
netip.Prefix.Contains is documented to return false for an address carrying
an IPv6 zone, and url.Parse preserves the zone through u.Hostname() — so
appending %25eth0 skipped every entry in blockedPrefixes:
[64:ff9b::a9fe:a9fe%25eth0] reached IMDS. The netip predicates evaluate
zoned addresses correctly, so loopback/RFC1918/link-local never leaked; only
the CIDR-matched ranges did, which is to say the half this branch added.
classify now rejects zoned addresses outright (a zone means "via this
interface", never a valid webhook target) and isPrivateOrReserved strips the
zone before the CIDR walk so it cannot fail open for any other caller.
Also newly blocked, all the same class of IPv6-wrapped IPv4 destination the
branch already targets:
- ::/96 IPv4-compatible IPv6, deprecated (::7f00:1 == 127.0.0.1,
::a9fe:a9fe == IMDS). Unmap() does not collapse this, only
::ffff:0:0/96, and mapped public addresses stay reachable.
- 2001::/32 Teredo, which tunnels IPv4 exactly as the 6to4 we already block
- 100::/64 discard-only (RFC 6666)
- 2001:db8::/32 documentation, the IPv6 analogue of the TEST-NETs above
Two more review fixes:
- The rejection reason was collapsed into one message when the two
validators were merged, so a host the operator had put in
WEBHOOK_BLACKLIST was reported as "private/internal". Verified against a
live stack: a hostname resolving to a blacklisted public address now
reports "blacklisted" again.
- WEBHOOK_ALLOWED_CIDRS was silent. An allowlist re-opens internal ranges to
anyone who can create a webhook, so NewSSRFPolicy logs a warning naming
the ranges when one is in effect.
Dropped the redundant 255.255.255.255/32 (inside 240.0.0.0/4) and the
never-read ssrfPolicy field on WebhookSenderImpl — the policy is enforced in
the transport's DialContext, and a field of that name implied Send consulted
it.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (13)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. WalkthroughThe change adds 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 32 functions across 11 files. (2 skipped: 2 unsupported.) Full details: Description checkExplanation The description is complete and directly related to the changes. It explains the SSRF gaps, implementation approach, allowlist behavior, configuration failures, testing steps, manual verification, checklist status, and review findings.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
BhagyaAmarasinghe
left a comment
There was a problem hiding this comment.
Requesting changes for one low-severity but reproducible SSRF-policy gap. The new classifier still admits IANA special-purpose IPv6 ranges that can be internally routed; details inline.
Addresses review: blockedPrefixes omitted 5f00::/16 (SRv6 SIDs) and
3fff::/20 (documentation). IANA marks 5f00::/16 forwardable but not
globally reachable, so an SRv6-enabled deployment kept an
internal-destination path; both were accepted by isPrivateOrReserved and
by resolveWebhookHost, and the secured dialer then treated the address as
eligible.
Adds four prefixes:
- 5f00::/16 SRv6 SIDs (RFC 9602) — the reported gap
- 3fff::/20 documentation (RFC 9637) — the reported gap
- 2001:20::/28 ORCHIDv2 (RFC 7343) — same class, found by sweeping the
registry rather than reading the list
- ::ffff:0:0:0/96 IPv4-translated (RFC 2765 / SIIT), the sixth
IPv4-wrapper format after IPv4-mapped, IPv4-compatible,
NAT64, 6to4 and Teredo. Unmap() only collapses
::ffff:0:0/96, so ::ffff:0:7f00:1 (127.0.0.1) and
::ffff:0:a9fe:a9fe (IMDS) reached the CIDR walk as
ordinary global unicast
The IPv6 list is now the IANA special-purpose registry filtered to
Globally Reachable = False, minus what the netip predicates cover. That
derivation is recorded above blockedPrefixes, along with why the
Globally-Reachable = True entries are deliberately absent — 2001:30::/28
(DRIP) sits immediately above ORCHIDv2, so the /28 boundary is
load-bearing and now has a control asserting it stays public.
Tests: default rejection for every new range, top-of-range and
either-side boundary controls, and allowlist round-trips through the real
service path (blocked by default, reachable when named, one range's entry
not re-opening another, blacklist still winning). 10 unit cases and 5
service cases go red without the prefix additions.
Also gives two test fixtures a non-nil webhook. A URL that was wrongly
*accepted* previously panicked on a nil result, which aborts the test
binary and hides every remaining subtest — precisely when you need to see
them. They now fail as clean assertions.
What does this PR do?
Closes the webhook SSRF denylist gaps in ENG-2310.
isPrivateOrReservedrelied entirely onnet/netip's predicates, which model only RFC1918 +fc00::/7,127/8+::1,169.254/16+fe80::/10,ff02::and the single unspecified address. Every other private or reserved range passed both the create/update check and the dial-time check.The one that motivated the ticket: CGNAT
100.64.0.0/10. Tailscale addresses tailnets out of it, so on a tailnet deployment a webhook could reach internal services — and it is a range the Formbricks side already blocks and has a test for, so this was a plain inconsistency between the two products rather than an open question.Newly rejected:
100.64.0.0/100.0.0.0/8IsUnspecified()matches only0.0.0.0itself, so0.1.2.3passed64:ff9b::/96,64:ff9b:1::/4864:ff9b::a9fe:a9feis IMDS2002::/162002:7f00:1::1is127.0.0.1168.63.129.16169.254/16fec0::/10198.18.0.0/15,240.0.0.0/4,255.255.255.255192.0.0.0/24, TEST-NET-1/2/3IsLinkLocalMulticast()caught onlyff02::and224.0.0.0/24Approach
Two things worth flagging for review, because both are the opposite of what the ticket suggested:
"fe80:", covering onlyfe80::/16, whereasIsLinkLocalUnicast()covers the fullfe80::/10.fe9f::1andfebf::1are blocked here today and allowed on the Formbricks side. There are explicit no-regression tests for those two addresses.IsLinkLocalMulticast()becomesIsMulticast(), which covers224/4andff00::/8in full — so multicast needs no CIDR entries at all.validateWebhookHostis now a thin wrapper overresolveWebhookHost. The two were ~90% duplicated, which is how create-time and dial-time validation could drift apart in the first place; they are now provably the same check.Breaking change + escape hatch
Blocking CGNAT is potentially breaking: an operator whose webhook receiver sits on a tailnet would start getting 400s on upgrade, and there was no way to opt out — Hub has no equivalent of Formbricks'
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS.So this adds
WEBHOOK_ALLOWED_CIDRS, a comma-separated allowlist that re-permits specific ranges. It is deliberately scoped:WEBHOOK_BLACKLIST, which stays an explicit deny that wins;parseBlacklist) — this list widens what is reachable, so a typo must not be silently absorbed;Documented in
.env.exampleandcharts/hub/values.yaml.How should this be tested?
Automated:
go test ./internal/...—TestIsPrivateOrReservedcovers ~50 vectors: every newly-blocked range, the predicate-covered ranges as regression cases, and public controls (8.8.8.8,100.128.0.1immediately above CGNAT,172.32.0.1immediately above RFC1918,64:ff9c::1immediately above NAT64) that catch a prefix written one bit too wide.TestSSRFPolicy_Permitscovers blacklist/allowlist precedence.TestWebhooksService_CreateWebhook_SSRFRangeCoveragedrives the ranges through the real create path, and..._AllowedCIDRproves the allowlist toggles behaviour.TestLoad_WebhookAllowedCIDRsproves a malformed value failsLoad(), not just the setter.make tests(integration) — needs a DB; the harness allowlists TEST-NET-1 because the fixtures target192.0.2.1.I also confirmed the new tests can fail: reverting
isPrivateOrReservedto the four-predicate version turns 26 cases red, and no public-address case fails, so they aren't just asserting "block everything".Manual, against a local stack (
DATABASE_URLon a scratch DB,API_KEY=…,PORT=8477) — this is the config→classifier wiring a unit test can't prove:100.64.0.1, no allowlistwebhook URL host is not allowed (private/internal)[64:ff9b::a9fe:a9fe], no allowlistexample.com(control)100.64.0.1withWEBHOOK_ALLOWED_CIDRS=100.64.0.0/1010.0.0.1with that same allowlist[64:ff9b::a9fe:a9fe]with that allowlist100.64.0.1with allowlist andWEBHOOK_BLACKLIST=100.64.0.1(blacklisted)— denylist winsWEBHOOK_ALLOWED_CIDRS=100.64.0.0/10,oopsChecklist
Required
make buildmake tests(integration tests intests/)make fmtandmake lint; no new warningsgit pull origin mainmigrations/with goose annotations and ranmake migrate-validate— n/a, no schema changeAppreciated
docs/if changes were necessary —.env.exampleand the Helm values carry the new settingmake tests-coveragefor meaningful logic changesNotes for the reviewer
0.2.0→0.8.5. Not a fresh regression, and Medium severity, so per policy no backport — flag if you disagree.mainand release5.3.4, so that half is genuinely unfixed rather than deliberately declined. ENG-2218 closed the same NAT64 class as Done, but that was a transitiveip-addressbump andip-addresshas no first-party import anywhere in the monorepo — it fixed nothing in this path. A companion PR covers the Formbricks side.Self-review findings (addressed in b26d553)
I reviewed my own diff adversarially rather than re-reading it, and the CIDR mechanism I'd added turned out to be trivially bypassable.
1. IPv6 zone identifiers bypassed the entire CIDR list.
netip.Prefix.Containsis documented to return false for an address carrying a zone, and Go'surl.Parsepreserves the zone throughu.Hostname(). Sohttps://[64:ff9b::a9fe:a9fe%25eth0]/skipped every entry inblockedPrefixesand reached IMDS.Scope: the
netippredicates evaluate zoned addresses correctly (IsLinkLocalUnicast("fe80::1%eth0")is true), so loopback / RFC1918 / link-local never leaked — only the CIDR-matched ranges did, which is exactly the half this branch adds. Not a regression againstmain, but it would have shipped a fix that a%25eth0suffix defeats.Fix:
classifyrejects zoned addresses outright (a zone means "reach this via that interface" — never a valid webhook target), andisPrivateOrReservedstrips the zone before the CIDR walk so it cannot fail open for any other caller. Both are tested, and 10 cases go red without the fix.2. Four more ranges of the same class.
::/96IPv4-compatible IPv6 (::7f00:1is127.0.0.1,::a9fe:a9feis IMDS —Unmap()only collapses::ffff:0:0/96, so this was classified as global unicast), Teredo2001::/32(tunnels IPv4 exactly as the 6to4 we already block), plus2001:db8::/32documentation and100::/64discard-only. Boundary controls assert::ffff:93.184.216.34and2001:4860:4860::8888stay reachable.3. The rejection reason regressed when I merged the two validators.
permits()collapsed "blacklisted" and "private/internal" into one message, so a host the operator had themselves put inWEBHOOK_BLACKLISTwas reported asprivate/internal— the opposite of actionable. Split back into aclassify→ reason → message chain and verified live:https://dns.google/withWEBHOOK_BLACKLIST=8.8.8.8now reports(blacklisted)through the DNS path.4.
WEBHOOK_ALLOWED_CIDRSwas silent. An allowlist re-opens internal ranges to anyone who can create a webhook, so it should never be in effect without being visible.NewSSRFPolicynow logs a warning naming the ranges.5. Two cleanups. Dropped the redundant
255.255.255.255/32(already inside240.0.0.0/4) and the never-readssrfPolicyfield onWebhookSenderImpl— the policy is enforced in the transport'sDialContext, and a field of that name impliedSendconsulted it.Smoke test against a live stack
Fresh DB + API on a scratch port, hitting
POST /v1/webhooksfor real:[64:ff9b::a9fe:a9fe%25eth0],[::7f00:1%25eth0],[fec0::1%25eth0]private/internal— zone bypass closed[::7f00:1],[::a9fe:a9fe],[2001:0:1234::1],[2001:db8::1],[100::1]private/internalevil@127.0.0.1127.0.0.1.(trailing dot)blacklisted—canonicalizeHosttrims the dot, so the blacklist entry still matchesdns.googlewithWEBHOOK_BLACKLIST=8.8.8.8blacklisted— the reason survives the DNS path100.64.0.1withWEBHOOK_ALLOWED_CIDRS=100.64.0.0/1010.0.0.1and the zoned NAT64 address, same allowlistexample.comAlso confirmed no unvalidated egress path exists:
webhook_sender.go:126is the only place a webhook URL is fetched, and it uses the client whoseDialContextre-validates.golangci-lint run ./...clean, full unit suite andmake tests(integration) green.Review round 2 (addressed in c7b4b28)
@BhagyaAmarasinghe caught that
blockedPrefixesstill omitted5f00::/16(SRv6 SIDs) and3fff::/20(documentation) — correct, and the5f00::/16one matters most: IANA marks it forwardable but not globally reachable, so an SRv6-enabled deployment kept a real internal-destination path. Both were accepted byisPrivateOrReservedand byresolveWebhookHost, so the secured dialer treated them as eligible.Rather than add just those two, I swept the registry. Four prefixes added:
5f00::/163fff::/202001:20::/28::ffff:0:0:0/96The last one is the same wrapper class as the transition ranges already here, and the sixth IPv4-wrapper format after IPv4-mapped, IPv4-compatible, NAT64, 6to4 and Teredo.
Unmap()collapses only::ffff:0:0/96, so::ffff:0:7f00:1(127.0.0.1) and::ffff:0:a9fe:a9fe(IMDS) reached the CIDR walk as ordinary global unicast. The Formbricks side had the identical gap — fixed there in #8946.The derivation is now recorded in the file, so the next addition is a lookup rather than a guess: the IPv6 entries are the IANA special-purpose registry filtered to Globally Reachable = False, minus what the
netippredicates already cover. The registry's Globally Reachable = True entries are deliberately absent —2001:3::/32(AMT),2001:4:112::/48and2620:4f:8000::/48(AS112),2001:30::/28(DRIP) and192.88.99.0/24(6to4 relay anycast) are public infrastructure, and blocking them would reject legitimate targets.That last exclusion makes the ORCHIDv2 boundary load-bearing:
2001:30::/28(DRIP) sits immediately above2001:20::/28, so a prefix written one bit too wide there would blackhole globally-reachable space. There is now a control asserting2001:30::1stays public.Tests
As requested — default rejection and explicit allowlisting for the new ranges:
2001:2f:ffff:…,3fff:fff:ffff:…,5f00:ffff::1).2001:1f::1,2001:30::1,3ffe::1,4000::1,5eff::1,6000::1,::ffff:1:0:0all stay reachable.CreateWebhook, soresolveWebhookHostis exercised, not justclassify): blocked by default → reachable when the operator names the range → an entry for one new range does not re-open another →WEBHOOK_BLACKLISTstill wins over an allowlist covering it.10 unit cases and 5 service cases go red without the prefix additions.
One test-harness fix worth calling out
Two fixtures built the mock repo without a webhook, so a URL that was wrongly accepted hit a nil dereference. A panic aborts the whole test binary — which meant exactly one of my five new service cases reported, and the other four never ran. They now fail as clean assertions instead, so the red state is legible.
golangci-lint run ./...clean, full unit suite green.