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
#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.
Summary
Two defects in
apps/web/src/lib/ssrf-guard.ts, both surfaced while reconciling #1428 against #1381 (which merged asfeae3d3and rewrote this module's rejection paths).1. Bracketed IPv6 literals never reach
ipIsPrivateURL.hostnamekeeps the brackets on an IPv6 literal —new URL('http://[::1]/').hostnameis[::1], not::1.net.isIPdoes not accept that spelling, so the literal branch is skipped:Consequences:
dns.lookupinstead of being range-checked.ipIsPrivatenever 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 inipIsPrivate/ipv6ToHextets(link-local, unique-local, IPv4-mapped in any spelling) is dead code for literals supplied in a URL.http://[2606:4700:4700::1111]/are rejected too, which this guard is not meant to do.Verified against
main(5934cbf): a test assertingdns.lookupis not consulted forhttp://[::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
ipIsPrivate, not resolved.http://[::1]/is rejected by the IP-literal branch; a public IPv6 literal is allowed.main.Relationship to #1427 / #1428
#1428 is the open PR for #1427 and proposed a broader redesign (a typed
SsrfGuardErrorwith a diagnosticreason, 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) againstmainon 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
SsrfGuardErrorquestion, which remains #1428's to settle.