Skip to content

ssrf-guard: bracketed IPv6 literals skip the private-IP branch, and the module has no tests #1484

Description

@groupthinking

Summary

Two defects in apps/web/src/lib/ssrf-guard.ts, both surfaced while reconciling #1428 against #1381 (which merged as feae3d3 and rewrote this module's rejection paths).

1. Bracketed IPv6 literals never reach ipIsPrivate

URL.hostname keeps the brackets on an IPv6 literal — new URL('http://[::1]/').hostname is [::1], not ::1. net.isIP does not accept that spelling, so the literal branch is skipped:

const host = u.hostname.toLowerCase().replace(/\.$/, '');   // "[::1]"
if (net.isIP(host)) {                                        // false
  if (ipIsPrivate(host)) throw new Error('Blocked private IP literal');
  return u;
}
// ...falls through to dns.lookup("[::1]")

Consequences:

  • Every IPv6 literal — private or public — is handed to dns.lookup instead of being range-checked. ipIsPrivate never sees it.
  • http://[::1]/ is still rejected, but only incidentally: the resolver errors on a bracketed name and the DNS branch converts that into a rejection. The loopback range check plays no part. All of the careful IPv6 work in ipIsPrivate/ipv6ToHextets (link-local, unique-local, IPv4-mapped in any spelling) is dead code for literals supplied in a URL.
  • Public IPv6 literals such as http://[2606:4700:4700::1111]/ are rejected too, which this guard is not meant to do.

Verified against main (5934cbf): a test asserting dns.lookup is not consulted for http://[::1]/ fails, with the mock recording a call for "[::1]".

Fix: strip the surrounding brackets before net.isIP.

2. No test coverage

#1381 rewrote all of this module's rejection paths to close the CWE-209 DNS oracle and shipped without tests. ssrf-guard.ts — a security-critical module — currently has none, so the indistinguishability property #1381 argued for is unpinned and a future edit can silently reopen the oracle.

The gap is not hypothetical: writing the tests is what exposed defect 1 above.

Acceptance criteria

  • A bracketed IPv6 literal is range-checked by ipIsPrivate, not resolved.
  • http://[::1]/ is rejected by the IP-literal branch; a public IPv6 literal is allowed.
  • The four DNS outcomes (resolver rejection, transient failure, zero results, private result) remain byte-identical to the caller, asserted by comparing branches against each other rather than against a literal.
  • The operator-side signal is asserted to still differ, so the cause is moved to the logs rather than lost.
  • The IP-literal message stays deliberately distinct from the DNS one (fix(web): stop leaking upstream and Stripe error details to clients #1381's documented choice), pinned by a test so a future uniformity pass has to change a failing test rather than silently flip it.
  • Tests proven non-vacuous against both the pre-fix(web): stop leaking upstream and Stripe error details to clients #1381 guard and current main.

Relationship to #1427 / #1428

#1428 is the open PR for #1427 and proposed a broader redesign (a typed SsrfGuardError with a diagnostic reason, plus collapsing all six rejection paths onto one exported constant). It was authored before #1381 merged, states "No file overlap — #1381 changes neither file", and is now conflicted (mergeable_state: dirty) against main on both files it touches.

This issue is deliberately narrower: keep #1381's merged behaviour, fix the bracket bug, and add the regression coverage. It does not decide the SsrfGuardError question, which remains #1428's to settle.

Activity

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

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions