test(web): cover SSRF guard resolution bypasses salvaged from #1428 - #1483
test(web): cover SSRF guard resolution bypasses salvaged from #1428#1483groupthinking wants to merge 3 commits into
Conversation
#1428 set out to close the SSRF guard's DNS oracle (CWE-209). That fix landed first via #1381 (feae3d3), which rewrote the same two files and collapsed every DNS-path rejection into one `NOT_PUBLIC` literal. #1428 is therefore superseded on the security outcome and now conflicts with main. Its detection cases were not superseded, and nothing else covers them. `ssrf-guard-dns-leakage.test.ts` asks whether a rejection tells the caller too much; these ask the prior question of whether the guard rejects at all. Both scenarios are ways a private destination survives a resolution that looks public: one hides the address in an alternate IPv6 spelling, the other behind a public sibling record. A regression in either is silent — the guard returns a URL and the caller fetches it. Ported to the merged API, since #1428's cases were written against a `SsrfGuardError` type that #1381 did not introduce. Verified non-vacuous by mutation rather than assertion: - replacing the mapped-IPv4 decode with `return false` fails only the expanded IPv4-mapped IPv6 case (1 failed, 3 passed) - inspecting only `resolved[0]` fails only the two multi-answer cases (2 failed, 2 passed) The all-public control survives both, which is what makes it a control. Guard restored bit-for-bit after each mutation; no production code changes. 9 passed across both SSRF guard files; tsc --noEmit and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MdQ7ABmZMShRGQznQbPS4v
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review Two things worth attacking here, since this PR's whole value is that the tests are meaningful:
Context you may not have: this is salvage from #1428, which I closed as superseded by #1381 ( Note for the record: auto-review skipped again with "Auto reviews are limited based on label configuration", listing 26 labels under a Required labels (at least one) (1) header. That Generated by Claude Code |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== Changed files and diff ==='
git diff --no-ext-diff --unified=80 "$(git merge-base HEAD origin/main)" HEAD -- \
'apps/web/src/lib/__tests__/ssrf-guard-resolution-bypass.test.ts' \
'apps/web/src/lib/ssrf-guard.ts' || true
echo '=== Candidate files ==='
fd -t f 'ssrf-guard' apps/web/src/lib 2>/dev/null || true
echo '=== SSRF guard outline ==='
guard_file="$(fd -t f -a '^ssrf-guard\.ts$' apps/web/src/lib | head -n 1)"
if [ -n "${guard_file:-}" ]; then
ast-grep outline "$guard_file" --view expanded || true
echo '=== SSRF guard source ==='
cat -n "$guard_file"
fi
echo '=== Resolution-bypass test outline and source ==='
test_file="$(fd -t f -a '^ssrf-guard-resolution-bypass\.test\.ts$' apps/web/src/lib | head -n 1)"
if [ -n "${test_file:-}" ]; then
ast-grep outline "$test_file" --view expanded || true
cat -n "$test_file"
fi
echo '=== Existing SSRF tests using assertPublicHttpUrl ==='
rg -n -C 5 --glob '*ssrf-guard*.test.ts' \
'\bassertPublicHttpUrl\s*\(|Host does not resolve to a public address|dns\.lookup' \
apps/web/src/lib || trueLength of output: 35991
The four hostnames reach The mutation results are consistent with this control flow. The assertions are load-bearing for the target conditions on the current implementation. I found one test-durability improvement. Each rejection test should also assert that For example, after each rejection assertion: expect(dns.lookup).toHaveBeenCalledWith('sneaky.example.com', { all: true });Apply the equivalent assertion to the other three cases. The all-public control already prevents an unconditional rejection regression. The call assertions prevent a per-host pre-DNS rejection from becoming a false positive. I do not consider the duplicated ✏️ Learnings added
|
Addresses CodeRabbit's review finding on #1483. Each case asserted only that `assertPublicHttpUrl` throws the `NOT_PUBLIC` literal. That is load-bearing today, because a pre-DNS rejection throws a different literal (`Blocked host`) and would fail the assertion loudly. It stops being load-bearing the moment those literals are flattened into `NOT_PUBLIC` as well — then a hostname added to `BLOCKED_HOSTNAMES` would short-circuit before `dns.lookup` and the test would keep passing while testing nothing about resolution. That refactor is not hypothetical. #1428, the PR these cases came from, unified all six rejection paths onto one message, and `main`'s transcription-service already flattens all five at the call-site boundary. Asserting `dns.lookup` was called with the hostname and `{ all: true }` pins the branch under test, so the coverage survives that change. Verified the new assertions are themselves non-vacuous: short-circuiting `sneaky.example.com` before resolution *and* flattening its message to `NOT_PUBLIC` leaves the `rejects.toThrow` assertion passing and is caught only by the call assertion (1 failed, 3 passed) — AssertionError: expected "vi.fn()" to be called with arguments: [ 'sneaky.example.com', { all: true } ] Guard restored bit-for-bit; `git diff` against main is empty. 9 passed across both SSRF guard files; tsc --noEmit and eslint clean. Not taken from the same review: exporting `NOT_PUBLIC` from ssrf-guard.ts to remove the duplicated literal. CodeRabbit and I agree it would add API surface without improving runtime behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MdQ7ABmZMShRGQznQbPS4v
Review round resolved — call assertion applied in
|
|
Promoting from draft: residual SSRF resolution-bypass regression tests salvaged from #1428. Complements main's fix; pure test coverage, no behavior change. |
Superseded by #1486 — holding open until that merges#1486 was opened ~90 seconds after this PR and independently salvaged the same two cases from #1428. Its It should win. It carries a real production fix this PR does not: Not closing yet. #1486 is unmerged, and closing a superseded PR before its replacement lands risks losing the coverage entirely if the replacement stalls — the distinction I applied to #1428, which I closed only because #1381 had already merged. I'll close this once #1486 is in. Two assertions here aren't in #1486, and I've offered both on that PR rather than duplicating files:
On the "no competing PR" checkboxI ticked it in this PR's Agent handoff, and at the time it was accurate — #1486 did not exist. It is no longer accurate, and this comment is the correction rather than a silent edit to the body. CIStill not a useful signal on either PR: every Actions job on Terminal state: Generated by Claude Code |
Independent verification on
|
Mutation to ssrf-guard.ts |
Result |
|---|---|
Remove the expanded IPv4-mapped IPv6 decoding in ipIsPrivate |
✅ killed — rejects a loopback address written in expanded IPv4-mapped IPv6 form |
for (const r of resolved.slice(0, 1)) — inspect only the first answer |
✅ killed — both the multi-record and tail-position cases |
Worth noting: ssrf-guard-dns-leakage.test.ts caught neither mutation. It passed clean through both. That is the strongest evidence for this PR's premise — these are genuinely uncovered paths, not a second angle on covered ones. Guard restored bit-for-bit afterwards; git diff origin/main 6b64f30 -- apps/web/src/lib/ssrf-guard.ts is empty, and the diff excluding __tests__/ is empty too, so "test-only" holds.
#1480 merged at 21:09:37, so test-frontend (ci.yml:103, npx vitest run, hermetic env per #1230) is now a live job on main. The "land after #1480" ordering in the PR body is satisfied.
That mattered more than it looks: before #1480, ci.yml's test job ran pytest tests/unit/ only — no job executed apps/web vitest at all. Merging this PR beforehand would have added four tests that nothing ran.
Full suite green on the merged tree, run under the exact env test-frontend uses:
Test Files 57 passed (57)
Tests 330 passed (330)
Action taken: this PR's head predated #1480, so test-frontend had never run against it — which is why the last open checkbox could not be ticked. I've triggered an update from main; the job will now report on the new head.
No new findings. Terminal state stays HALTED(awaiting_merge_approval) — the merge itself is a human call, and I'm not taking it.
Generated by Claude Code
Closing — this duplicates code that was already in its own base commitCorrecting what I said earlier. I described this PR as salvaging cases from a "superseded" #1428. In fact #1428 merged at 20:57:02 as
I was working from a fetch taken before that merge and didn't re-read the PR before acting on it. My comment on #1428 claiming to close it was also a no-op — it was already closed-as-merged. My earlier note here that this was "superseded by #1486" was likewise imprecise: #1486 duplicates it too, but the thing that actually superseded both is The delta that is realTwo things here are not in
Neither justifies a separate PR against an area that already has one merged file and one open PR. Not a wasted verificationThe mutation testing done here still holds and transfers: breaking the mapped-IPv4 decode, restricting the loop to Terminal state: Generated by Claude Code |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
CI has no concurrency group, so pushing a new commit to a PR leaves the previous run queued or in flight. Those runs still count against the account's concurrent-job allowance while testing a SHA that is no longer the head of anything. Measured on the live queue: 29 CI runs queued across 17 branches, i.e. 12 duplicates of a branch already represented. Five were PR branches holding a superseded run alongside their current head (#1483 and #1486 among them, both being actively pushed to). Meanwhile main's own post-merge runs had been queued 29 minutes. Group key is the PR number for `pull_request` and the ref for `push`, so a PR's runs only ever cancel each other, never another PR's. `cancel-in-progress` is gated to pull_request events on purpose: each commit landing on main must keep its own post-merge run, because that run is the record of whether main was green at that SHA. A rapid series of merges must not cancel each other — which an ungated `cancel-in-progress: true` would do. This reclaims wasted capacity; it does not raise the ceiling. A queue saturated by genuinely distinct branches still waits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017yTj3erqg8xRb79v2djrmH
Absorbs the two assertions offered from #1483 before it was closed, so the coverage survives that PR rather than being lost with it. 1. `expect(lookup).toHaveBeenCalledWith(host, { all: true })` on all four resolution cases. Today a pre-DNS rejection throws `Blocked host` or `Blocked private IP literal`, so `rejectionOf` plus the `loggedText()` assertion would already fail loudly. It stops being load-bearing the moment those literals are flattened to `NOT_PUBLIC` too — then a hostname absorbed by a pre-DNS branch is indistinguishable from a resolution rejection and the test goes quiet. This PR is what makes that reachable: it adds a new pre-DNS branch (the bracket strip) that did not exist before. 2. A three-address case with the private address in tail position. The existing two-address case kills a `resolved[0]`-only scan; this additionally kills a "check a prefix of the answers" bug, and is the only case here exercising 172.16/12. Both verified non-vacuous by mutation against the real guard, not by inspection: - `for (const r of resolved.slice(0, 2))` — prefix-only scan: 1 failed | 14 passed, and the one failure is the new tail case. - short-circuit the four hostnames before the bracket strip, throwing the flattened `NOT_PUBLIC`: 4 failed | 11 passed. Every failure is a `toHaveBeenCalledWith` assertion; not one message assertion caught it, which is precisely the silent-hollowing scenario these guard against. Guard restored bit-for-bit after each mutation; `git diff` against the branch tip for `ssrf-guard.ts` is empty. No production code changes in this commit. Also merges `main` (5 commits) so `test-frontend` (#1480) reports against a current base. Note that `main` now carries `ssrf-guard-private-address-detection.test.ts` from #1428, which overlaps two cases with this file — called out on the PR rather than reorganised here. apps/web vitest: 57 files, 341 passed, 0 failed. tsc --noEmit and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GG1fyRobfHwhdFaqgqKvP
* fix(web): range-check bracketed IPv6 literals in the SSRF guard Closes #1484 `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]/` yields `[::1]` — and `net.isIP` does not accept that spelling, so the IP-literal branch was skipped and every IPv6 literal was handed to `dns.lookup` instead. `ipIsPrivate` never saw it. `http://[::1]/` was still rejected, but only incidentally: the resolver errors on a bracketed name and the DNS branch turns that into a rejection. The loopback range check played no part, which made all of the IPv6 handling in `ipIsPrivate`/`ipv6ToHextets` dead code for literals supplied in a URL. It also rejected *public* IPv6 literals, which this guard is meant to allow. Also adds the module's first tests. #1381 rewrote every rejection path here to close the CWE-209 DNS oracle and shipped without any, leaving the indistinguishability property it argued for unpinned. Writing them is what exposed the bracket bug. The tests assert the DNS branches against *each other* rather than against a literal message — a test that only checked "some static string is returned" would still pass if two branches returned two different static strings, which is the same oracle. They also assert the operator-side log still distinguishes the causes, so the reason is moved to the logs rather than lost, and pin #1381's deliberate choice to keep the IP-literal message distinct. Non-vacuity measured, not inferred: - pre-#1381 guard (feae3d3^): 9 of 11 fail; the 2 that pass are the controls (public host allowed, IP-literal distinct) - current main without the bracket fix: 3 of 11 fail - this head: 11 pass Full web suite at this head: 56 files, 334 passed, 0 failed. tsc --noEmit clean; eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAaz25m9uWf7BnXGaytGpx * fix(web): block IPv6 transition prefixes that encode a private IPv4 Fixes a regression introduced by the bracket strip in 393f197, caught by CodeRabbit on #1486. Routing IPv6 literals into `ipIsPrivate` exposed that its IPv6 branch ends in `return false` for any form it does not recognise. The transition prefixes are syntactically public but carry an IPv4 destination in their bits, so a NAT64- or 6to4-capable egress path translates them to the address they encode. `64:ff9b::a9fe:a9fe` reaches 169.254.169.254. Before 393f197 these were rejected, but only incidentally — the bracketed name went to `dns.lookup` and the resolver errored. Making the literal branch work therefore turned an accidental block into an allow, which is strictly worse than the bug it fixed. Now decoded and re-checked against the IPv4 rules: - 64:ff9b::/96 NAT64 (well-known prefix) - 2002::/16 6to4, whose IPv4 sits in h[1]/h[2], not the low bits - ::ffff:0:0:0/96 IPv4-translated — h[4] holds the 0xffff, so the existing mapped/compatible test did not cover it And blocked outright: - fec0::/10 site-local - 100::/64 discard-only - the rest of 64:ff9b::/32, e.g. RFC 8215 local-use 64:ff9b:1::/48, which is local-use by definition The NAT64 test matches the exact /96 by requiring h[2]..h[5] to be zero. Testing only h[0]/h[1] would claim 64:ff9b::/32, which is a wider assertion than "the low 32 bits are an IPv4 address". Tests: +3 cases, including public-embedded controls for NAT64 and 6to4 so the checks cannot pass by over-blocking, and one asserting the same rules apply to a *resolved* address — otherwise the fix would just move the bypass one DNS lookup away. Non-vacuity measured: against 393f197 (bracket fix, no transition prefixes) exactly the 2 new blocking tests fail and the other 12 pass. Full web suite at this head: 56 files, 337 passed, 0 failed. tsc --noEmit clean; eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAaz25m9uWf7BnXGaytGpx * test(web): pin the DNS branch and a tail-position private answer Absorbs the two assertions offered from #1483 before it was closed, so the coverage survives that PR rather than being lost with it. 1. `expect(lookup).toHaveBeenCalledWith(host, { all: true })` on all four resolution cases. Today a pre-DNS rejection throws `Blocked host` or `Blocked private IP literal`, so `rejectionOf` plus the `loggedText()` assertion would already fail loudly. It stops being load-bearing the moment those literals are flattened to `NOT_PUBLIC` too — then a hostname absorbed by a pre-DNS branch is indistinguishable from a resolution rejection and the test goes quiet. This PR is what makes that reachable: it adds a new pre-DNS branch (the bracket strip) that did not exist before. 2. A three-address case with the private address in tail position. The existing two-address case kills a `resolved[0]`-only scan; this additionally kills a "check a prefix of the answers" bug, and is the only case here exercising 172.16/12. Both verified non-vacuous by mutation against the real guard, not by inspection: - `for (const r of resolved.slice(0, 2))` — prefix-only scan: 1 failed | 14 passed, and the one failure is the new tail case. - short-circuit the four hostnames before the bracket strip, throwing the flattened `NOT_PUBLIC`: 4 failed | 11 passed. Every failure is a `toHaveBeenCalledWith` assertion; not one message assertion caught it, which is precisely the silent-hollowing scenario these guard against. Guard restored bit-for-bit after each mutation; `git diff` against the branch tip for `ssrf-guard.ts` is empty. No production code changes in this commit. Also merges `main` (5 commits) so `test-frontend` (#1480) reports against a current base. Note that `main` now carries `ssrf-guard-private-address-detection.test.ts` from #1428, which overlaps two cases with this file — called out on the PR rather than reorganised here. apps/web vitest: 57 files, 341 passed, 0 failed. tsc --noEmit and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GG1fyRobfHwhdFaqgqKvP --------- Co-authored-by: Claude <noreply@anthropic.com>
…1508) * fix(review): make the label-gate override actually bind (inheritance: false) #1425 set `reviews.auto_review.labels: []` to clear an inherited required-labels gate that was deadlocking CodeRabbit auto-review against `auto_apply_labels: true`. The gate is still active. Evidence: PRs opened after #1425 landed on main still receive the CodeRabbit commit status "Review skipped: excluded by label configuration" — #1483 (21:02Z) and #1494 (21:10Z), both non-draft, both based on a main that already carried the empty list. That message can only be emitted by a label gate that is still evaluating, so the override was not taking effect. Cause: with `inheritance: true` the org/dashboard config layers underneath this file, and a key set to an empty collection reads as unset rather than as "override with nothing" — so the inherited list survived. `inheritance` is the only in-file lever over that, hence flipping it off. Note the failure mode this restores protection against: auto-label.yml labels a PR by changed path, but it is an Actions job and queues behind whatever else is running. CodeRabbit evaluates the gate on PR open. When the Actions queue is saturated the PR is still unlabelled at that moment, the gate skips it, and — per the comment already in this file — labelling afterwards does not retro-trigger a review. The PR is then silently never reviewed. Trade-off: settings this file leaves unset now fall back to the CodeRabbit schema default instead of the dashboard. The file already sets the review, tooling, labeling, chat, knowledge-base and issue-enrichment blocks explicitly, which is what its header claims it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017yTj3erqg8xRb79v2djrmH * test(review): pin inheritance to false, with the reason CI caught this: `test_inheritance_enabled` asserts `.coderabbit.yaml`'s `inheritance is True`, which aa31594 changed to false. 1 failed, 8069 passed — a real assertion, not a flake. The test was a snapshot of the values chosen when the CodeRabbit config was version-controlled; its docstring restated the setting rather than defending it. Changing the setting deliberately means the test changes with it, so this flips the assertion and renames it to match. The docstring now carries the reason instead of the restatement. The failure mode is invisible — flipping inheritance back to true does not fail anything, it just silently stops auto-review from ever starting — so the test is the only place that will tell someone why the value is what it is. Verified by replicating the file's config assertions directly; pytest is not installed in this sandbox, so CI is the real check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017yTj3erqg8xRb79v2djrmH --------- Co-authored-by: Claude <noreply@anthropic.com>
Canonical issue
Closes #1495
Outcome
Locks in two SSRF-guard behaviours that nothing currently tests: a private destination must not survive resolution by hiding inside an alternate IPv6 spelling, or behind a public sibling DNS record.
mainalready handles both correctly — this adds no fix, only the regression lock. A regression in either would be silent:assertPublicHttpUrlwould return a URL and the caller would fetch it.Why this exists
#1428 set out to close the same CWE-209 DNS oracle as #1427. That fix landed first through #1381, which rewrote
ssrf-guard.tsand collapsed every DNS-path rejection into oneNOT_PUBLICliteral. #1428's security outcome is therefore fully superseded.Its detection cases were not superseded, and no other test covers them. The existing
ssrf-guard-dns-leakage.test.tsasks whether a rejection tells the caller too much; these ask the prior question of whether the guard rejects at all.Ported to the merged API — #1428's cases were written against a
SsrfGuardErrortype carrying areasonfield, which #1381 did not introduce.Scope
apps/web/src/lib/__tests__/ssrf-guard-resolution-bypass.test.ts(new)Risk
Verification
Production evidence
N/A — test-only change.
Agent handoff
Land after #1480 so new tests are actually gated. Complementary to #1486.