Skip to content

fix(security): close SSRF guard bypass via IPv4-mapped IPv6 - #152

Open
ar2rsawseen wants to merge 2 commits into
mainfrom
fix/ssrf-ipv4-mapped-ipv6
Open

fix(security): close SSRF guard bypass via IPv4-mapped IPv6#152
ar2rsawseen wants to merge 2 commits into
mainfrom
fix/ssrf-ipv4-mapped-ipv6

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Fixes an unauthenticated full-read SSRF in the Streamable HTTP transport, reported by roopd3v (r00p) against v1.4.0, and hardens the surrounding DNS handling to match countly-server's SSRF protection.

The caller-controlled X-Countly-Server-Url header is validated by assertSafeServerHost before the server issues an outbound request forwarding its configured COUNTLY_AUTH_TOKEN. The old guard classified IPs with a dotted-quad regex plus a few IPv6 string prefixes, so an IPv4-mapped IPv6 address like http://[::ffff:127.0.0.1] — which Node normalizes to [::ffff:7f00:1] and the OS routes to IPv4 loopback — matched neither branch and slipped through.

Reproduction (before fix)

With COUNTLY_MAX_BODY_BYTES=0, a single unauthenticated POST /mcp with X-Countly-Server-Url: http://[::ffff:127.0.0.1]:<port> caused the server to fetch loopback and return the internal response body to the caller, with the server's configured token attached:

method: GET  host: [::ffff:7f00:1]:<port>  path: /o/ping  countly-token: <SERVER_TOKEN>

(Under the default positive COUNTLY_MAX_BODY_BYTES, the body-size middleware consumes the stream first, so the SSRF is only reachable in that non-default config — but the guard flaw itself is real and version-independent.)

Commit 1 — close the IPv4-mapped IPv6 guard bypass

Replaces the hand-rolled IP classification with ipaddr.js range() detection, mirroring countly-server's api/utils/ssrf-protection.js:

  • Only globally-routable unicast addresses are allowed; every other range (loopback, private, link-local, unique-local, CGNAT, multicast, reserved, unspecified, NAT64, …) is blocked.
  • IPv4-mapped IPv6 is unwrapped to its embedded IPv4 and re-classified.
  • Strips IPv6 brackets before classifying.
  • Blocks the .internal TLD and known cloud-metadata / k8s hostnames.
  • Rejects URLs with embedded credentials (user:pass@host).

Commit 2 — connect-time DNS validation (closes the DNS gaps)

The syntactic check above only classifies IP literals. A caller-supplied hostname was never resolved, leaving two gaps:

  1. Plain DNS bypassssrf.attacker.com → A 169.254.169.254 passed the guard outright.
  2. DNS rebinding (TOCTOU) — a low-TTL record answering public-then-private defeats any parse-time-only check.

Ports countly-server's safeLookup: a dns.lookup-compatible function that re-classifies the resolved address (shared ipaddr.js check, IPv4-mapped unwrapped) and fails with ESSRFBLOCKED for any non-public target. Wired — together with maxRedirects: 0 — into the per-request axios client via http/https Agents, so the socket only ever connects to a public unicast IP and a 30x can't bounce to an internal target.

Scoped to the caller-controlled path only. RequestState gains serverUrlFromCaller, set by the HTTP middleware when the header/param is present. The operator's trusted COUNTLY_SERVER_URL (frequently a private-IP on-prem box) skips the guard and keeps working — verified by test.

Verification

  • Full suite: 888 passed / 9 skipped; changed files lint clean.
  • End-to-end against the rebuilt server:
    • ::ffff:127.0.0.1 header → HTTP 400, 0 canary hits.
    • lvh.me / nip.io hostname resolving to 127.0.0.1 → blocked at connect (Blocked SSRF target … resolved to non-public IP), 0 canary hits.
    • Trusted COUNTLY_SERVER_URL=http://127.0.0.1:<port> with no caller header → still reaches the target (on-prem private IP unaffected).

Behavior note

TEST-NET (RFC5737) documentation ranges (e.g. 203.0.113.0/24) are now correctly rejected as reserved, matching countly-server. The on-prem test IP was updated to a routable public address accordingly.

Follow-up (not in this PR)

  • The default HTTP transport appears broken: under the default 1 MiB body limit, the streaming body-size middleware consumes the request before the MCP transport reads it, so every request returns Parse error: Invalid JSON. Separate bug, flagged for triage.

🤖 Generated with Claude Code

The HTTP transport's caller-controlled X-Countly-Server-Url header is
validated by assertSafeServerHost before the server issues an outbound
request (forwarding its configured COUNTLY_AUTH_TOKEN). The previous
guard classified IPs with a dotted-quad regex plus a handful of IPv6
string prefixes, so an IPv4-mapped IPv6 address such as
`http://[::ffff:127.0.0.1]` (which Node normalizes to `[::ffff:7f00:1]`
and the OS routes to IPv4 loopback) matched neither branch and slipped
through — a full-read SSRF against loopback/private/metadata targets.

Replace the hand-rolled classification with ipaddr.js range() detection,
mirroring countly-server's api/utils/ssrf-protection.js: only globally
routable `unicast` addresses are allowed; IPv4-mapped IPv6 is unwrapped
to its embedded IPv4 and re-classified. Also:

- block the .internal TLD and known cloud-metadata / k8s hostnames
- strip IPv6 brackets before classifying
- reject URLs with embedded credentials (user:pass@host)

Adds regression tests covering ::ffff:127.0.0.1, ::ffff:169.254.169.254,
the hex-collapsed and bracketed forms, IPv4-mapped RFC1918, and embedded
credentials. Note: TEST-NET (RFC5737) documentation ranges are now
correctly rejected as `reserved`, matching countly-server behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 13:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…r URLs

The syntactic host check only classifies IP literals; a caller-supplied
hostname is never resolved, so a name whose A/AAAA record points at a
private/loopback/metadata IP passed straight through, and DNS-rebinding
(resolve-public-then-flip) defeated any parse-time-only check.

Port countly-server's safeLookup: a dns.lookup-compatible function that
re-classifies the resolved address (via the shared ipaddr.js range check,
IPv4-mapped unwrapped) and fails the lookup with ESSRFBLOCKED for any
non-public target. Wire it — plus maxRedirects: 0 — into the per-request
axios client through http/https Agents, but ONLY for the caller-controlled
path: RequestState gains serverUrlFromCaller, set by the HTTP middleware
when the X-Countly-Server-Url header / URL param is present. The operator's
trusted COUNTLY_SERVER_URL (often a private-IP on-prem host) skips the
guard and keeps working.

This closes both remaining DNS gaps at connect time: (1) a hostname that
simply resolves to an internal IP, and (2) DNS-rebinding TOCTOU.

Adds safeLookup unit tests (public pass-through, loopback/metadata/
IPv4-mapped block, options.all form, callback-as-second-arg form).

Co-Authored-By: Claude <noreply@anthropic.com>
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.

2 participants