Skip to content

fix(security): block private IPs in webhook SSRF guard; redact ClickHouse proxy error body#2593

Open
tsushanth wants to merge 2 commits into
hyperdxio:mainfrom
tsushanth:fix/2588-ssrf-webhook-clickhouse
Open

fix(security): block private IPs in webhook SSRF guard; redact ClickHouse proxy error body#2593
tsushanth wants to merge 2 commits into
hyperdxio:mainfrom
tsushanth:fix/2588-ssrf-webhook-clickhouse

Conversation

@tsushanth

Copy link
Copy Markdown

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)

validateWebhookUrl only blocked the hostnames extracted from CLICKHOUSE_HOST and MONGO_URI. Direct private IP literals (RFC 1918, loopback 127.x, link-local 169.254.x / AWS metadata, RFC 6598, multicast, IPv6 ULA/loopback) were unchecked, so any team member could reach internal services.

Fix: adds isPrivateIp() and checks url.hostname before dispatching. Mirrors the existing blacklistedWebhookHosts check in the same function.

Finding B — ClickHouse proxy reflected SSRF (POST /clickhouse-proxy/test)

The endpoint passed the caller-supplied host to fetch() with no protocol restriction, and on a non-OK response it returned await 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/https schemes; 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

…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)
@changeset-bot

changeset-bot Bot commented Jul 5, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 2e33c57

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

@sushanth9 is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two SSRF vulnerabilities: the webhook test endpoint (POST /webhooks/test) now blocks private/reserved IP literals via a new isPrivateIp() helper built on the ip-address library, and the ClickHouse proxy test endpoint (POST /clickhouse-proxy/test) gains both a protocol whitelist and the same private-IP guard, while the reflected response body is replaced with a fixed error string.

  • validators.ts: Adds isPrivateIp() using ip-address v10 for proper subnet-based checks covering RFC 1918, loopback, link-local, RFC 6598, multicast, 0.0.0.0/8, 240.0.0.0/4, and IPv6 ULA/loopback/link-local plus IPv4-mapped addresses — addressing the previously flagged fc00::/7 and ::ffff:x.x.x.x bypass gaps.
  • clickhouseProxy.ts: Protocol guard rejects file://, gopher://, etc., and isPrivateIp() blocks direct private-IP targets before the outbound fetch; raw result.text() body reflection removed and replaced with a fixed message.
  • template.ts: validateWebhookUrl now calls isPrivateIp() on the resolved hostname after the existing blocklist check, with structured logging on rejection.

Confidence Score: 5/5

Safe to merge — both SSRF fixes are well-scoped and use a proven library for subnet checking; the only open item is the acknowledged DNS-rebinding gap deferred to a follow-up.

The isPrivateIp() helper is implemented correctly using the ip-address library's native subnet and classification methods rather than hand-rolled regexes, and the IPv4-mapped and full ULA (fc00::/7) cases flagged in prior review rounds are now addressed. The ClickHouse proxy no longer reflects response bodies. The small robustness gap (URL constructor call outside the catch block) does not create a security window given Zod's pre-validation.

The new guard block in clickhouseProxy.ts (lines 97–107) is the only area worth a second look — the new URL() parse sits outside the try/catch, which is harmless in practice but could be tidied up.

Important Files Changed

Filename Overview
packages/api/src/utils/validators.ts New isPrivateIp() helper using ip-address v10; correctly handles IPv4, IPv6 (ULA via isULA(), loopback, link-local, multicast) and IPv4-mapped addresses via to4() recursion.
packages/api/src/routers/api/clickhouseProxy.ts Protocol whitelist and private-IP guard added before fetch; body reflection removed. URL parse sits outside the try/catch — low risk given Zod pre-validation but worth wrapping.
packages/api/src/tasks/checkAlerts/template.ts validateWebhookUrl now calls isPrivateIp() after the existing blocklist check; IPv6 bracket stripping is correct; structured logging on rejection is consistent with the existing pattern.
packages/api/package.json Adds ip-address ^10.2.0 — well-maintained library with to4(), isULA(), isLoopback() etc. all present in this version.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant ClickhouseProxy as POST /clickhouse-proxy/test
    participant WebhookHandler as POST /webhooks/test
    participant isPrivateIp as isPrivateIp()
    participant ExternalHost as External Host

    Client->>ClickhouseProxy: "{ host, username, password }"
    ClickhouseProxy->>ClickhouseProxy: new URL(host) — protocol check (http/https only)
    ClickhouseProxy->>isPrivateIp: hostname (brackets stripped)
    isPrivateIp-->>ClickhouseProxy: true → 400 Invalid host
    isPrivateIp-->>ClickhouseProxy: false → proceed
    ClickhouseProxy->>ExternalHost: "fetch(host/?query=SELECT 1)"
    ExternalHost-->>ClickhouseProxy: result
    alt result.ok
        ClickhouseProxy-->>Client: "200 { success: true/false }"
    else !result.ok
        ClickhouseProxy-->>Client: result.status + fixed error string (body NOT reflected)
    end

    Client->>WebhookHandler: webhook payload
    WebhookHandler->>WebhookHandler: blacklistedWebhookHosts check
    WebhookHandler->>isPrivateIp: url.hostname (brackets stripped)
    isPrivateIp-->>WebhookHandler: true → throw SSRF AllowedDomainError
    isPrivateIp-->>WebhookHandler: false → dispatch webhook
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant ClickhouseProxy as POST /clickhouse-proxy/test
    participant WebhookHandler as POST /webhooks/test
    participant isPrivateIp as isPrivateIp()
    participant ExternalHost as External Host

    Client->>ClickhouseProxy: "{ host, username, password }"
    ClickhouseProxy->>ClickhouseProxy: new URL(host) — protocol check (http/https only)
    ClickhouseProxy->>isPrivateIp: hostname (brackets stripped)
    isPrivateIp-->>ClickhouseProxy: true → 400 Invalid host
    isPrivateIp-->>ClickhouseProxy: false → proceed
    ClickhouseProxy->>ExternalHost: "fetch(host/?query=SELECT 1)"
    ExternalHost-->>ClickhouseProxy: result
    alt result.ok
        ClickhouseProxy-->>Client: "200 { success: true/false }"
    else !result.ok
        ClickhouseProxy-->>Client: result.status + fixed error string (body NOT reflected)
    end

    Client->>WebhookHandler: webhook payload
    WebhookHandler->>WebhookHandler: blacklistedWebhookHosts check
    WebhookHandler->>isPrivateIp: url.hostname (brackets stripped)
    isPrivateIp-->>WebhookHandler: true → throw SSRF AllowedDomainError
    isPrivateIp-->>WebhookHandler: false → dispatch webhook
Loading

Reviews (2): Last reviewed commit: "fix(security): use ip-address library fo..." | Re-trigger Greptile

Comment thread packages/api/src/routers/api/clickhouseProxy.ts
Comment thread packages/api/src/tasks/checkAlerts/template.ts Outdated
Comment on lines +239 to +248
// 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;
}

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.

P2 security 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.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Deep Review

The change adds SSRF hardening: a new isPrivateIp() helper (packages/api/src/utils/validators.ts) backed by the ip-address library, a private-IP + protocol guard on the ClickHouse proxy POST /test endpoint, and a private-IP guard plus error-body redaction in validateWebhookUrl. The security direction is sound, but one build-blocking omission and several coverage/hardening gaps remain.

🔴 P0/P1 — must fix

  • packages/api/src/package.json:39ip-address@^10.2.0 is added to dependencies but yarn.lock is not updated (the @hyperdx/api workspace entry lists no ip-address; the lock resolves only 10.1.0 and 9.0.5), so a Yarn 4 install under CI=true (immutable) fails and the module cannot resolve for typecheck or runtime, taking down webhook delivery and the proxy route.
    • Fix: Run yarn install to regenerate and commit yarn.lock with the resolved ip-address version, then confirm typecheck and startup succeed.

🟡 P2 — recommended

  • packages/api/src/utils/validators.ts:35isPrivateIp returns false for any non-IP-literal string, so both guards allow http://localhost:8123, http://[hostname], or an attacker-controlled domain whose A record points at 127.0.0.1/169.254.169.254, reaching internal services with no DNS rebinding required.
    • Fix: Resolve the hostname (or block obvious loopback aliases like localhost) and evaluate the resolved address through isPrivateIp before dispatching; if left for the acknowledged follow-up, gate the endpoints so the guard is not relied on as complete SSRF protection.
  • packages/api/src/utils/validators.ts:6 — the new security-critical isPrivateIp and both endpoint guards ship with no tests, even though packages/api/src/utils/__tests__/validators.test.ts already exists, leaving subnet boundaries (RFC 6598, ::ffff: IPv4-mapped recursion, reserved ranges) unverified against silent regressions.
    • Fix: Add unit tests for isPrivateIp across IPv4/IPv6 private, loopback, link-local, RFC 6598, reserved, and IPv4-mapped cases (asserting public IPs and non-IP strings return false), plus endpoint tests that the guards reject private hosts and non-http(s) protocols.
    • testing
  • packages/api/src/routers/api/clickhouseProxy.ts:129 — replacing the reflected upstream body with a fixed string removes the only signal operators had for why a connection test failed (auth, wrong database, etc.), degrading a user-facing diagnostic flow.
    • Fix: Log the upstream response body server-side (redacted from the client response) so the leak is closed while debuggability is preserved.
🔵 P3 nitpicks (2)
  • packages/api/src/tasks/checkAlerts/template.ts:281 — the strip-brackets + isPrivateIp guard is duplicated verbatim across the webhook and proxy call sites, risking divergence that silently reopens the hole if only one site is updated.
    • Fix: Extract a shared helper (e.g. assertHostNotPrivate(url)) in validators.ts and call it from both sites.
    • maintainability
  • packages/api/src/tasks/checkAlerts/template.ts:222 — a stray double blank line was introduced between notifyChannel and blacklistedWebhookHosts.
    • Fix: Collapse to a single blank line.
    • maintainability

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:

  • No coverage for either SSRF call site (clickhouseProxy POST /test, validateWebhookUrl) confirming private-IP/protocol rejection fires end-to-end, including bracketed IPv6 literals (http://[::1]/).
  • After the lockfile is regenerated, confirm the ip-address v10 API surface used (Address4.isPrivate/isLoopback/isLinkLocal/isMulticast, Address6.isULA, to4) actually exists so the import typechecks and does not throw at runtime.

@brandon-pereira

brandon-pereira commented Jul 7, 2026

Copy link
Copy Markdown
Member

@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.
@tsushanth

Copy link
Copy Markdown
Author

Good call — switched to the ip-address library (Address4/Address6). This correctly handles ULA fc00::/7, IPv4-mapped ::ffff: bypass, and the other edge cases greptile flagged. Also moved isPrivateIp to utils/validators so the ClickHouse proxy /test endpoint now shares the same guard.

addr.isPrivate() ||
addr.isLinkLocal() ||
addr.isMulticast() ||
addr.isInSubnet(new Address4('100.64.0.0/10')) || // RFC 6598 shared

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can use isCGNAT()

throw new Error(`SSRF AllowedDomainError: ${message}`);
}
// Block direct private/reserved IP literals to prevent SSRF
const hostname = url.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is isUnspecified()

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question: why is this line needed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server-Side Request Forgery in webhook test and ClickHouse connection test endpoints

4 participants