Skip to content

fix(webhook): block SSRF via IP normalization bypasses - #5786

Open
pacocartones wants to merge 1 commit into
Helicone:mainfrom
pacocartones:fix/ssrf-ip-normalization
Open

fix(webhook): block SSRF via IP normalization bypasses#5786
pacocartones wants to merge 1 commit into
Helicone:mainfrom
pacocartones:fix/ssrf-ip-normalization

Conversation

@pacocartones

Copy link
Copy Markdown

What

Harden webhook destination validation against SSRF via IP address normalization bypasses.

isPrivateOrReservedHostname in webhookSender.ts previously only recognized dotted-decimal IPv4 literals (parts.length === 4). Any private/reserved target reachable through a non-dotted-decimal encoding, or via IPv6, slipped past the allowlist and the webhook fetch would still connect.

Root cause (why the naive check was incomplete)

Two distinct gaps, and it's worth separating them because they explain the size of the change:

  1. IPv4 alternate encodings (http://2130706433/, 0x7f000001, 0177.0.0.1, 127.1).
    These are, in practice, already neutralized at the call site: new URL(destination).hostname canonicalizes them to 127.0.0.1 before the check runs. So they were covered by accident. The fix now handles them explicitly and defensively so the function is correct independent of who calls it.

  2. The real, live bypass was IPv6 + CGNAT. new URL() does not flatten these to something the old check caught:

    • IPv6 loopback / unspecified: [::1], [::]
    • IPv6 link-local: fe80::/10
    • IPv6 ULA: fc00::/7
    • IPv4-mapped IPv6: [::ffff:127.0.0.1] (URL yields [::ffff:7f00:1]), [::ffff:169.254.169.254] (cloud metadata!)
    • Carrier-grade NAT: 100.64.0.0/10

    A https://[::ffff:169.254.169.254]/latest/meta-data/ destination would previously pass validation and let a webhook hit the cloud metadata endpoint.

The fix

isPrivateOrReservedHostname now:

  • Parses IPv4 in any inet_aton-style encoding (decimal, hex, octal, dotless/short) into canonical octets, then checks it against the private/reserved ranges (0/8, 10/8, 127/8, 169.254/16, 172.16/12, 192.168/16, and 100.64/10 CGNAT).
  • Fully expands IPv6 literals (including :: compression, zone IDs, and embedded IPv4 tails) and blocks loopback, unspecified, link-local, ULA, and IPv4-mapped/compatible addresses (inspecting the embedded IPv4 against the same range logic).

Both isPrivateOrReservedHostname and validateWebhookDestination are exported so the behavior can be unit-tested directly.

Tests

Added valhalla/jawn/src/lib/clients/__tests__/webhookSender.ssrf.test.ts, a discriminant suite covering:

  • Must-block: every bypass vector above, plus the classic dotted-decimal cases.
  • Must-allow: public destinations (example.com, 8.8.8.8, 1.1.1.1, public IPv6 2606:4700:4700::1111) and range boundaries chosen to catch off-by-one errors (172.15.0.1, 172.32.0.1, 192.169.0.1, 11.0.0.1, 100.63.0.1, 100.128.0.1).

Verified against the pre-fix logic the suite fails exactly the 11 bypass vectors (IPv6 + CGNAT + IPv4-mapped); with the fix the full suite passes 51/51. tsc --noEmit on jawn reports no new errors from these files.

npx jest src/lib/clients/__tests__/webhookSender.ssrf.test.ts
# Tests: 51 passed, 51 total

Scope

  • valhalla/jawn/src/lib/clients/webhookSender.ts (validation logic only; the sendToWebhook / sendTestWebhook flow and payload handling are unchanged).
  • valhalla/jawn/src/lib/clients/__tests__/webhookSender.ssrf.test.ts (new).

The webhook destination allowlist only recognized dotted-decimal IPv4
literals, so private/reserved targets could be reached through alternate
encodings that new URL() and the underlying network stack still resolve:

- decimal (https://2130706433/ -> 127.0.0.1)
- hex (https://0x7f000001/) and octal (https://0177.0.0.1/)
- dotless/short forms (https://127.1/)
- IPv6 loopback/unspecified ([::1], [::])
- IPv6 link-local (fe80::/10) and ULA (fc00::/7)
- IPv4-mapped IPv6 ([::ffff:127.0.0.1], [::ffff:169.254.169.254])
- carrier-grade NAT range (100.64.0.0/10)

isPrivateOrReservedHostname now canonicalizes IPv4 (any inet_aton-style
encoding) and fully parses IPv6 literals, checking the embedded IPv4 of
mapped/compatible addresses. Adds a discriminant unit test covering the
bypass vectors and public-address negatives.

isPrivateOrReservedHostname and validateWebhookDestination are exported
for unit testing.
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
helicone Skipped Skipped Aug 22, 2026 4:05am
helicone-bifrost Skipped Skipped Aug 22, 2026 4:05am
helicone-eu Skipped Skipped Aug 22, 2026 4:05am

Request Review

@vercel
vercel Bot temporarily deployed to Preview – helicone August 22, 2026 04:05 Inactive

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@vercel
vercel Bot temporarily deployed to Preview – helicone-bifrost August 22, 2026 04:05 Inactive
@vercel
vercel Bot temporarily deployed to Preview – helicone-eu August 22, 2026 04:05 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant