fix(security): block private IPs in webhook SSRF guard; redact ClickHouse proxy error body#2593
fix(security): block private IPs in webhook SSRF guard; redact ClickHouse proxy error body#2593tsushanth wants to merge 2 commits into
Conversation
…ckHouse proxy error body Finding A (webhooks): validateWebhookUrl only blocked configured CLICKHOUSE_HOST and MONGO_URI hostnames. Direct private IP literals (RFC 1918, loopback, link-local 169.254.x, multicast, IPv6 ULA) passed through unchecked, allowing any team member to probe internal services via POST /webhooks/test. Add isPrivateIp() and check url.hostname before dispatching the webhook. Mirrors the existing blacklistedWebhookHosts check. Finding B (ClickHouse proxy): POST /clickhouse-proxy/test passed the caller-supplied host to fetch() with no protocol restriction, and reflected the raw upstream response body in error messages — turning a blind SSRF into a reflected one that leaks internal service content. Restrict to http/https and return a fixed error string instead of the upstream body. Reported: hyperdxio#2588 (GHSA-fgcx-qxjr-wx26)
|
|
@sushanth9 is attempting to deploy a commit to the HyperDX Team on Vercel. A member of the Team first needs to authorize it. |
| // IPv6 | ||
| if ( | ||
| ip === '::1' || // loopback | ||
| /^fe80:/i.test(ip) || // link-local | ||
| /^fc00:/i.test(ip) || // ULA | ||
| /^fd/i.test(ip) || // ULA | ||
| /^fd00:ec2::/i.test(ip) // AWS metadata IPv6 | ||
| ) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
IPv4-mapped IPv6 addresses bypass
isPrivateIp
Addresses like ::ffff:192.168.1.1 (IPv4-mapped) or ::ffff:169.254.169.254 (AWS metadata via IPv4-mapped notation) are not matched by any of the IPv4 ^192\.168\. / ^169\.254\. regexes (those only match decimal dot-notation) nor by the IPv6 patterns. Node.js's http module can connect to IPv4-mapped IPv6 addresses on dual-stack hosts, making this a bypass path. Checking for ^::ffff:/i and then inspecting the embedded IPv4 portion would close the gap.
Deep ReviewThe change adds SSRF hardening: a new 🔴 P0/P1 — must fix
🟡 P2 — recommended
🔵 P3 nitpicks (2)
Reviewers (9 dispatched, 2 returned before synthesis): correctness, security, adversarial, reliability, testing, maintainability, kieran-typescript, api-contract, project-standards. The two build/security findings above are independently verified from the repo state (lockfile contents, CI install policy) and the diff, not dependent on reviewer count. Testing gaps:
|
|
@tsushanth what do you think about leveraging a library like ip-address to do the heavy lifting? |
Replace hand-rolled regex isPrivateIp with ip-address (Address4/Address6) to correctly handle ULA fc00::/7, IPv4-mapped ::ffff: addresses, and all RFC 1918/6598/loopback/link-local ranges without bespoke pattern maintenance. Also extends the guard to the ClickHouse proxy /test endpoint, which previously only blocked non-http(s) protocols but allowed private IP literals. Shared isPrivateIp moved to utils/validators so both routers use the same implementation.
|
Good call — switched to the |
| addr.isPrivate() || | ||
| addr.isLinkLocal() || | ||
| addr.isMulticast() || | ||
| addr.isInSubnet(new Address4('100.64.0.0/10')) || // RFC 6598 shared |
| throw new Error(`SSRF AllowedDomainError: ${message}`); | ||
| } | ||
| // Block direct private/reserved IP literals to prevent SSRF | ||
| const hostname = url.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets |
There was a problem hiding this comment.
You have this regex in two places, might make sense to be a const?
| addr.isLinkLocal() || | ||
| addr.isMulticast() || | ||
| addr.isInSubnet(new Address4('100.64.0.0/10')) || // RFC 6598 shared | ||
| addr.isInSubnet(new Address4('0.0.0.0/8')) || // "this" network |
| addr.isMulticast() || | ||
| addr.isInSubnet(new Address4('100.64.0.0/10')) || // RFC 6598 shared | ||
| addr.isInSubnet(new Address4('0.0.0.0/8')) || // "this" network | ||
| addr.isInSubnet(new Address4('240.0.0.0/4')) // reserved/broadcast |
There was a problem hiding this comment.
question: why is this line needed?
Addresses the two findings from #2588 (originally reported via GHSA-fgcx-qxjr-wx26 on 1 June 2026, no response after 30 days).
Finding A — Webhook SSRF (
POST /webhooks/test)validateWebhookUrlonly blocked the hostnames extracted fromCLICKHOUSE_HOSTandMONGO_URI. Direct private IP literals (RFC 1918, loopback127.x, link-local169.254.x/ AWS metadata, RFC 6598, multicast, IPv6 ULA/loopback) were unchecked, so any team member could reach internal services.Fix: adds
isPrivateIp()and checksurl.hostnamebefore dispatching. Mirrors the existingblacklistedWebhookHostscheck in the same function.Finding B — ClickHouse proxy reflected SSRF (
POST /clickhouse-proxy/test)The endpoint passed the caller-supplied
hosttofetch()with no protocol restriction, and on a non-OK response it returnedawait result.text()verbatim — turning a blind SSRF into a reflected one that leaks the first bytes of internal service responses via the error message.Fix: rejects non-
http/httpsschemes; replaces the reflected error body with a fixed string.Known gap
DNS rebinding is not addressed: a hostname can resolve to a public IP at validation time and then rebind to a private IP before the fetch. Full protection requires resolving the IP once, caching it, and passing it directly to the HTTP client. Left for a follow-up as it requires a larger change to the fetch layer.
Fixes #2588