Skip to content

fix(web): range-check bracketed IPv6 literals in the SSRF guard - #1486

Merged
groupthinking merged 4 commits into
mainfrom
claude/clever-heisenberg-b7sdbg
Aug 7, 2026
Merged

fix(web): range-check bracketed IPv6 literals in the SSRF guard#1486
groupthinking merged 4 commits into
mainfrom
claude/clever-heisenberg-b7sdbg

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1484

Outcome

An IPv6 literal in a URL is now range-checked by ipIsPrivate instead of being sent to the resolver.

URL.hostname keeps the brackets on an IPv6 literal — new URL('http://[::1]/').hostname is [::1] — and net.isIP does not accept that spelling. The literal branch was therefore skipped for every IPv6 literal:

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]")

http://[::1]/ was 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 played no part, which made the IPv6 handling in ipIsPrivate/ipv6ToHextets (link-local, unique-local, IPv4-mapped in any spelling) dead code for literals supplied in a URL. It also rejected public IPv6 literals, which this guard is meant to allow.

This 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.

Scope

Risk

  • Risk level: low
  • Failure mode: the change is a widening — a public IPv6 literal that was previously rejected is now allowed, which is the guard's intended contract. Private IPv6 literals move from "rejected because the resolver choked on brackets" to "rejected because the range check recognised them", so the same URLs are refused, now deliberately. The realistic risk is a caller that depended on all IPv6 literals being refused; no such caller exists in-tree (assertPublicHttpUrl has one live call site, transcription-service.ts).
  • Rollback: revert this single commit. No migration, config, or schema change; no response shape or status code changes.

Verification

Head 393f197. Measured, not inferred.

  • Focused tests — 11 new cases, all passing.

  • Non-vacuity proven against two baselines, by running the suite against each rather than reasoning about them:

    Baseline Result
    pre-fix(web): stop leaking upstream and Stripe error details to clients #1381 guard (feae3d3^) — the original DNS oracle 9 of 11 fail; the 2 that pass are the controls (public host allowed, IP-literal distinct)
    current main (5934cbf), bracket fix reverted 3 of 11 fail — both IPv6-literal tests plus the pre-DNS test, which recorded dns.lookup being called with "[::1]"
    this head 11 pass
  • The load-bearing assertion is indistinguishability. The DNS tests compare the branches against each other, not 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. A separate assertion requires the operator-side log to still differ, so the cause is moved to the logs rather than lost.

  • fix(web): stop leaking upstream and Stripe error details to clients #1381's deliberate exception is pinned, not overridden. A test asserts the IP-literal message stays distinct from the DNS one, so a future uniformity pass has to change a failing test — and read the comment explaining why — rather than silently flip a decision that was already reviewed and merged.

  • Full web suite — 56 files, 334 passed, 0 failed. No regressions. (The billing-chat-gating timeout previously tracked in billing-chat-gating free-tier test depends on ambient AI_GATEWAY_API_KEY and passes vacuously in CI #1116 no longer reproduces; test: make web suite hermetic against ambient AI gateway keys #1230's hermetic gateway-key defaults are on main.)

  • npx tsc --noEmit — clean

  • npm run lint — clean

  • Required CI — pending first run on this head.

  • Review threads resolved — none open yet.

Production evidence

Not applicable as a deployed artefact. This is a library-level change with no UI surface, and the observable effect is a rejection path taken for a different reason — which the regression tests measure directly. Demonstrating it against a preview would mean submitting an internal address to /api/transcribe.

Agent handoff

Note for reviewers

This PR came out of a PR-remediation sweep, not a feature request. Two things worth flagging beyond the diff:

  1. test(security): cover SSRF private-address detection main's tests miss #1428 is conflicted and its premise is stale. It states "No file overlap — fix(web): stop leaking upstream and Stripe error details to clients #1381 changes neither file", but fix(web): stop leaking upstream and Stripe error details to clients #1381 merged as feae3d3 and changed both files it touches (ssrf-guard.ts +38, transcription-service.ts +39). test(security): cover SSRF private-address detection main's tests miss #1428 is now mergeable_state: dirty, and fix(web): stop leaking upstream and Stripe error details to clients #1381 already delivers its headline outcome — collapsing the four DNS outcomes onto one constant with the cause logged server-side. Its remaining delta is the SsrfGuardError API and uniformity across all six paths, which is a real but narrower question than its description implies. It needs a reconciliation decision under gate 6 of MERGE_POLICY.md, not a rebase.
  2. This is the fix main needs regardless of how test(security): cover SSRF private-address detection main's tests miss #1428 is settled — the bracket bug is present on main today and is orthogonal to the SsrfGuardError design.

Generated by Claude Code

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
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 7, 2026 9:23pm

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 51b79790-85e6-4513-be5c-e61f3bf9d375

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@groupthinking groupthinking added javascript Pull requests that update javascript code tests labels Aug 7, 2026 — with Claude
@groupthinking
groupthinking marked this pull request as ready for review August 7, 2026 21:02
@groupthinking

Copy link
Copy Markdown
Owner Author

Promoting from draft: high-priority SSRF correctness fix (Closes #1484). Will land after #1480 (vitest CI gate) so the new tests actually block regressions.

Copy link
Copy Markdown
Owner Author

Confirmed, and worth recording precisely because it caveats this PR's own Verification section: no job in ci.yml runs the apps/web vitest suite today.

  • testPYTHONPATH=src python -m pytest tests/unit/ …. Python only.
  • lint-frontendcd apps/web && npm run lint. ESLint only.
  • build — Next build; guards / validate — neither runs vitest.

So the 11 tests added here would sit at green on every required check without being executed once. The 334 passed / 0 failed in the description is a local npx vitest run, not something CI reproduces — sequencing behind #1480 is what turns that from an assertion into a gate, so no objection to holding it.

One consequence worth flagging for #1480's scope: the same gap means the billing-chat-gating timeout tracked in #1116 and the hermetic-env fix in #1230 were also never CI-verified. Whatever #1480 turns on will be the first run of ~334 web tests in CI, so it may surface pre-existing failures unrelated to this PR. Might be worth letting #1480 go red once on its own before stacking anything behind it.

No action needed from me — I'll leave this parked and won't push to it. Happy to rebase onto main once #1480 lands if it has drifted by then.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@coderabbitai full review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@groupthinking: I will run a complete review of #1486, including the SSRF guard change and its new tests.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 45 minutes.

Copy link
Copy Markdown
Owner Author

Heads-up on a collision: #1483 and this PR independently salvaged the same two cases from #1428, within about 90 seconds of each other. Two of your the guard still guards tests —

  • rejects a private address hiding behind an IPv4-mapped IPv6 spelling
  • rejects when any resolved address is private, even if another is public

— are the same scenarios as two of the four in ssrf-guard-resolution-bypass.test.ts on #1483. Different filenames, so no git conflict, but duplicate coverage that would land twice.

This PR should be the survivor. It carries a real production fix that #1483 doesn't have, and its test set is otherwise a superset. I'll close #1483 once this merges rather than before, so the coverage isn't lost if this stalls.

I checked the guard change and it looks right. net.isIP('[::1]') is 0, so bracketed literals did fall through to dns.lookup, and the return u on the public-literal path means http://[2606:4700:4700::1111]/x was previously rejected by resolver error rather than allowed. The strip happening after the BLOCKED_HOSTNAMES check is harmless as written — that set is {'localhost', 'metadata.google.internal'}, no IPv6 literals — though it's worth knowing if an address is ever added there.

Two assertions from #1483 worth absorbing, if you want them:

  1. Pin that the DNS branch was reached on the two shared cases:

    expect(lookup).toHaveBeenCalledWith('sneaky.example.com', { all: true });

    You already use the mirror of this (expect(lookup).not.toHaveBeenCalled()) for the pre-DNS cases, and this PR is exactly what makes the positive form more valuable: it adds a new pre-DNS branch. If a future change ever routed a hostname into the literal path, these two tests would still throw and still pass, while no longer testing resolution at all. I verified that failure mode on test(web): cover SSRF guard resolution bypasses salvaged from #1428 #1483 by short-circuiting sneaky.example.com before DNS and flattening its message — the message assertion survives, only the call assertion catches it.

  2. A third resolved address with the private one in tail position:

    lookup.mockResolvedValueOnce([
      { address: '93.184.216.34', family: 4 },
      { address: '151.101.1.140', family: 4 },
      { address: '172.16.31.9', family: 4 },
    ]);

    The two-address case already kills a resolved[0]-only scan; this one additionally kills a "check a prefix" bug. Cheap, and it exercises the 172.16/12 range nothing else here touches.

Neither is a blocker — your loggedText() approach for asserting operator-side signal is better than what I wrote, and I'd rather see one good file than two overlapping ones.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Red-team pass on this diff, since CodeRabbit is rate-limited (next slot ~45 min) and never actually reviewed it. The bracket fix itself is correct — all six control cases behave exactly as the description claims. But the widening reaches further than the description accounts for, and one case lands on the address this module's own docstring names as its reason for existing.

Finding: the widening allows IPv6 encodings of private IPv4

Before this change every IPv6 literal was rejected (incidentally — the resolver chokes on the bracketed name). After it, literals are range-checked and public ones are allowed. ipIsPrivate covers ::, ::1, fe80::/10, fc00::/7, and IPv4-mapped/compatible — but not the transition prefixes that encode an IPv4 destination inside a syntactically public IPv6 address.

Measured by extracting ipIsPrivate/ipv6ToHextets verbatim from 393f197 and driving them directly:

literal verdict note
::1, ::ffff:127.0.0.1, 0:0:0:0:0:ffff:7f00:1 BLOCK controls — correct
fe80::1, fc00::1 BLOCK controls — correct
2606:4700:4700::1111 ALLOW control — correct
64:ff9b::a9fe:a9fe ALLOW NAT64 → 169.254.169.254
64:ff9b::7f00:1 ALLOW NAT64 → 127.0.0.1
64:ff9b::a00:1 ALLOW NAT64 → 10.0.0.1
2002:7f00:1:: ALLOW 6to4 → 127.0.0.1
2002:a00:1:: ALLOW 6to4 → 10.0.0.1
fec0::1 ALLOW site-local (deprecated)
::ffff:0:7f00:1 ALLOW IPv4-translated ::ffff:0:0:0/96 — the check requires h[4] === 0, this has h[4] === 0xffff
100::1 ALLOW discard prefix 100::/64

64:ff9b::a9fe:a9fe is the cloud-metadata endpoint the header comment cites verbatim ("cloud-metadata address (e.g. 169.254.169.254)"), expressed in a spelling the guard now passes.

Severity — deliberately not overstated. This is not a live exploit on the current deployment: reaching those IPv4 destinations requires the egress path to have a NAT64 or 6to4 relay, which Vercel's serverless egress almost certainly does not. It is a latent gap in a defense-in-depth control, and it is newly reachable — these literals were all rejected before this PR. Worth closing while the file is already open rather than leaving it for whoever next widens the guard.

Suggested patch

In ipIsPrivate, before the final return false:

// Transition prefixes embed an IPv4 destination inside a syntactically
// public IPv6 address. Decode the embedded address and re-check it, so a
// NAT64/6to4 spelling can't launder a private or metadata target.
if (h[0] === 0x0064 && h[1] === 0xff9b) {           // 64:ff9b::/96 (NAT64, RFC6052)
  return ipIsPrivate(`${h[6] >> 8}.${h[6] & 0xff}.${h[7] >> 8}.${h[7] & 0xff}`);
}
if (h[0] === 0x2002) {                               // 2002::/16 (6to4, RFC3056)
  return ipIsPrivate(`${h[1] >> 8}.${h[1] & 0xff}.${h[2] >> 8}.${h[2] & 0xff}`);
}
if ((h[0] & 0xffc0) === 0xfec0) return true;         // fec0::/10 site-local (deprecated)
if (h[0] === 0x0100 && h[1] === 0 && h[2] === 0 && h[3] === 0) return true; // 100::/64 discard

The ::ffff:0:0:0/96 case is covered by relaxing the existing mapped/compatible test to accept h[4] === 0xffff && h[5] === 0 alongside the current h[4] === 0 && (h[5] === 0xffff || h[5] === 0).

Two things I did not do

Separately, confirming the sequencing note above from the workflow files rather than by inspection: no job in ci.yml (guards, build, lint-python, lint-frontend, test) runs vitest. The only vitest invocation anywhere in .github/workflows/ is npx vitest run tests/e2e/ in e2e-tests.yml — a different directory, and continue-on-error: true, so it cannot block regardless. Holding this behind #1480 is the right call; until then these 11 tests would sit green in CI without ever executing.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Candidate files =='
fd -a -i 'ssrf-guard|ci\.ya?ml|e2e-tests\.ya?ml' . | sort

echo '== Source/test outlines =='
while IFS= read -r f; do
  case "$f" in
    *ssrf-guard*.ts)
      echo "--- $f"
      ast-grep outline "$f" --items all || true
      ;;
  esac
done < <(fd -a -i 'ssrf-guard' . | sort)

echo '== SSRF guard and test contents =='
while IFS= read -r f; do
  echo "--- $f"
  cat -n "$f"
done < <(fd -a -i 'ssrf-guard' . | sort)

echo '== Workflow vitest and web-test references =='
rg -n -i -C 3 'vitest|apps/web|npm run test|continue-on-error' .github/workflows 2>/dev/null || true

Length of output: 1234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== SSRF guard outline =='
ast-grep outline apps/web/src/lib/ssrf-guard.ts --items all || true

echo '== SSRF guard =='
cat -n apps/web/src/lib/ssrf-guard.ts

echo '== Primary SSRF tests =='
cat -n apps/web/src/lib/__tests__/ssrf-guard.test.ts

echo '== DNS leakage SSRF tests =='
cat -n apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts

echo '== Workflow files and web-test references =='
fd -a -t f . .github/workflows | sort
rg -n -i -C 3 'vitest|apps/web|npm run test|continue-on-error' .github/workflows || true

Length of output: 73821


@groupthinking: Confirmed. This is a valid security regression in this PR.

assertPublicHttpUrl() now strips brackets and sends every IPv6 literal to ipIsPrivate(). On Line 57 of apps/web/src/lib/ssrf-guard.ts, unrecognized IPv6 forms return false. The guard then returns the URL on Line 128. Therefore, the reported NAT64, 6to4, site-local, and IPv4-translated forms are allowed as literals.

This change makes http://[64:ff9b::a9fe:a9fe]/ pass the guard. A NAT64-capable egress path can route that address to 169.254.169.254. The deployment condition limits immediate exploitability, but it does not remove the newly introduced bypass in this defense-in-depth control.

The fix should block this PR until it includes range checks and regression tests for:

  • 64:ff9b::/96 with embedded private IPv4 values.
  • 2002::/16 with embedded private IPv4 values.
  • fec0::/10.
  • ::ffff:0:0:0/96 IPv4-translated values.
  • Public control cases for NAT64 and 6to4 embedded public IPv4 values.

One correction to the proposed NAT64 condition: it must check the exact 64:ff9b::/96 prefix. Checking only h[0] === 0x0064 && h[1] === 0xff9b blocks 64:ff9b::/32, not only the well-known NAT64 /96 prefix. It should also require h[2] through h[5] to be zero before decoding h[6] and h[7].

The 100::/64 discard prefix should be blocked if this guard defines all non-public addresses as blocked. It is not an embedded-private-IPv4 bypass like NAT64 and 6to4.

I also confirmed the CI concern. ci.yml does not run the apps/web Vitest suite. e2e-tests.yml runs only tests/e2e/, and its Vitest step is non-blocking. The new unit tests do not currently gate this change.


✏️ Learnings added
Learnt from: groupthinking
URL: https://github.com/groupthinking/EventRelay/pull/1486

Timestamp: 2026-08-07T21:12:55.177Z
Learning: In `apps/web/src/lib/ssrf-guard.ts`, IPv6 literals must be checked for transition prefixes that encode IPv4 destinations before `assertPublicHttpUrl` allows them. At minimum, handle `64:ff9b::/96` NAT64, `2002::/16` 6to4, and `::ffff:0:0:0/96` IPv4-translated addresses by decoding the embedded IPv4 address and applying `ipIsPrivate`; block `fec0::/10` site-local addresses. The NAT64 check must match the exact `/96` prefix, not only `64:ff9b::/32`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Copy link
Copy Markdown
Owner Author

Correction to my earlier comment — and #1483 is now closed

I framed the overlap as being between this PR and #1483. That was wrong about the source. #1428 merged at 20:57:02 as 75ef3ca, reworked by a parallel session into ssrf-guard-private-address-detection.test.ts (+76, test-only). Both this PR and #1483 branched from 75ef3ca and re-derived cases already sitting in their own base commit. I was working from a pre-merge fetch; my apologies for the misdirection.

So the duplication to check is against main, not against #1483 (which I've now closed):

Case On main in ssrf-guard-private-address-detection.test.ts In this PR's ssrf-guard.test.ts
Loopback via expanded 0:0:0:0:0:ffff:7f00:1
Multi-answer, public first + 10.0.0.5 private ✅ (same addresses)
Public IPv6 answer allowed

That's two of your eleven tests already covered on main, in a file your PR body doesn't mention — understandably, since it landed while you were writing. Worth a look before merge: main would end up with ssrf-guard-dns-leakage.test.ts, ssrf-guard-private-address-detection.test.ts, and ssrf-guard.test.ts, the last two overlapping on two cases. Folding your unique cases into the existing files, or noting why a third file earns its place, would leave this area easier to read.

None of that touches your production fix, which is the part main actually needs and which neither the merged PR nor #1483 has. The bracket bug is real and still on main; your diagnosis and diff both look right to me.

The two additions I offered earlier still stand and are unaffected by this correction — the toHaveBeenCalledWith(host, { all: true }) assertion (more valuable here precisely because you add a pre-DNS branch) and the tail-position three-address case. They're not on main either.


Generated by Claude Code

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

Copy link
Copy Markdown
Owner Author

Confirmed and fixed in 24be5e3 — this was a real regression I introduced, and blocking the PR on it was the right call.

I traced it before acting rather than taking the report on faith. For 64:ff9b::a9fe:a9fe: h[0]=0x0064, so (h[0] & 0xffc0) === 0xfe80 is false, (h[0] & 0xfe00) === 0xfc00 is false, and the IPv4-mapped test needs h[0]===0 — every branch misses, and ipIsPrivate falls to return false. The guard then returns the URL. Allowed.

The uncomfortable part is the direction of travel: before 393f197 these were rejected, but only incidentally, because the bracketed name went to dns.lookup and the resolver errored. Making the literal branch work converted an accidental block into an allow — strictly worse than the bug it fixed. My PR description called the change "a widening" and argued it was safe because the guard is meant to allow public IPv6; I didn't enumerate the forms that are syntactically public but carry an IPv4 destination. That was the gap.

Now decoded and re-checked against the IPv4 rules:

Prefix Where the IPv4 sits
64:ff9b::/96 NAT64 low 32 bits (h[6], h[7])
2002::/16 6to4 h[1], h[2]not the low bits
::ffff:0:0:0/96 IPv4-translated low 32 bits; h[4] holds the 0xffff, which is why the existing mapped/compatible test missed it

Blocked outright: fec0::/10, 100::/64, and the remainder of 64:ff9b::/32.

On your correction about the NAT64 prefix — you're right, and I implemented it as specified. The check requires h[2] through h[5] to be zero before decoding; matching only h[0]/h[1] would assert 64:ff9b::/32, a wider claim than "the low 32 bits are an IPv4 address". For the rest of that /32 — including RFC 8215 local-use 64:ff9b:1::/48 — I block outright rather than guess where its embedded IPv4 sits, since RFC 6052 permits several prefix lengths and it is local-use by definition.

I also took the 100::/64 suggestion. It isn't an embedded-IPv4 bypass, but this guard does define non-public as blocked, so it belongs.

Verification, measured:

  • +3 tests, 14 total in the file, all passing. Two are public-embedded controls (64:ff9b::5db8:d822, 2002:5db8:d822:: → 93.184.216.34) so the new checks cannot pass by over-blocking NAT64 and 6to4 wholesale — which would be a different bug wearing this fix's clothes.
  • One test asserts the same rules apply to a resolved address, not just a literal. ipIsPrivate guards both paths, and without that the fix would only move the bypass one DNS lookup away.
  • Non-vacuity: against 393f197 (bracket fix, no transition prefixes) exactly the 2 new blocking tests fail and the other 12 pass.
  • Full web suite 337 passed / 0 failed; tsc --noEmit and eslint clean.

Your CI observation matches mine independently — ci.yml runs pytest only, and I'd add that e2e-tests.yml's non-blocking Vitest step means even that wouldn't have caught this. None of the above is enforced until #1480 lands, which is a good argument for the sequencing already noted on this PR.


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA a61062e.
Ensure 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 Files

None

Copy link
Copy Markdown
Owner Author

The transition-prefix finding above is fixed in #1512, on its own branch off main. Summarising the resolution here so this PR isn't left carrying a blocker it doesn't need to.

One correction to my own red-team framing: I called it "a security regression in this PR," and CodeRabbit agreed. That framing is too narrow. The gap is on main today, independent of anything here — dns.lookup answers go straight into ipIsPrivate at ssrf-guard.ts:150-151, and nothing stops a hostname from carrying an AAAA record of 64:ff9b::a9fe:a9fe. A URL literal is not the only way in. This PR widens the literal path onto missing checks; it does not create them.

That changes what should happen to this PR: the finding is not yours to fix, and shouldn't block you. #1512 fixes the range checks (ipIsPrivate); this one fixes which inputs reach them (assertPublicHttpUrl). Different functions, no overlapping lines, different test filenames. They compose, and landing both is strictly better than either alone — though #1512 is the one that closes the live gap, so it's the higher-priority of the two.

Both of CodeRabbit's corrections were adopted rather than my original sketch:

  • NAT64 matches the exact 64:ff9b::/96 (h[2]h[5] zero), not 64:ff9b::/32. The looser test would decode the wrong bytes on RFC 6052's /40../64 embeddings and return a verdict about an address never present in the input. There's a test pinning that a non-/96 form isn't mis-decoded.
  • 100::/64 is blocked as a discard prefix, not as an embedded-private-IPv4 bypass, and the comment says which it is.

Public embedded addresses are decoded and allowed rather than blanket-blocked, so a legitimate NAT64/6to4 translation of a public host still passes — three control tests cover that.

Verification on #1512: reverting only ssrf-guard.ts fails 6 of 10 new tests; the 4 that pass are the controls, which must pass on both baselines. Full apps/web suite 336 passed / 0 failed, tsc and eslint clean.

Neither PR can currently show a green CI, and that is not about either diff. GitHub Actions is not executing jobs for this repo right now — 278 runs queued, one in_progress, and no run has reached a success/failure conclusion since ~20:49 UTC; the last ~120 completions are all skipped or cancelled. main itself has CI, CodeQL, Security Scan and Coverage queued since 20:49. So required checks here are not pending, they are unscheduled. The repo owner has been notified. Both PRs stay draft/unmerged until the queue drains — the local runs stand in for CI, they don't replace it.

Sequencing unchanged from the earlier note: this one still wants #1480 first, since no job in ci.yml runs the apps/web vitest suite today.


Generated by Claude Code

groupthinking pushed a commit that referenced this pull request Aug 7, 2026
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

Copy link
Copy Markdown
Owner Author

Merge the current head, not 393f197 — the first commit opened a metadata-endpoint bypass

Automated PR-remediation sweep, triggered by a webhook on #1483. Three things, one of them load-bearing.

1. 393f197 allowed http://[64:ff9b::a9fe:a9fe]/ through the guard

The bracket strip is correct, but on its own it routes IPv6 literals into ipIsPrivate for the first time — and main's ipIsPrivate ends its IPv6 branch with return false for anything it doesn't recognise. The transition prefixes are syntactically public while carrying an IPv4 destination in their bits, so they fell straight through to return u.

Measured by executing main's ipIsPrivate (which 393f197 did not change) behind 393f197's bracket strip:

addr                       ipIsPrivate   verdict at 393f197
[64:ff9b::a9fe:a9fe]       false         *** ALLOWED ***   NAT64  -> 169.254.169.254
[64:ff9b::7f00:1]          false         *** ALLOWED ***   NAT64  -> 127.0.0.1
[2002:a9fe:a9fe::]         false         *** ALLOWED ***   6to4   -> 169.254.169.254
[2002:0a00:0005::]         false         *** ALLOWED ***   6to4   -> 10.0.0.5
[::ffff:0:a9fe:a9fe]       false         *** ALLOWED ***   IPv4-translated -> 169.254.169.254
[fec0::1]                  false         *** ALLOWED ***   site-local
[100::1]                   false         *** ALLOWED ***   discard-only
[::1]                      true          BLOCKED           (control)
[2606:4700:4700::1111]     false         ALLOWED           (control — correct)

On main today all nine are refused, but only incidentally: with no bracket strip they reach dns.lookup('[64:ff9b::a9fe:a9fe]'), and the resolver errors on a bracketed name. So 393f197 would have converted an accidental block into an actual bypass of an unauthenticated route (/api/transcribe) reaching the cloud metadata endpoint.

The current head 24be5e3 closes it. Same harness:

[64:ff9b::a9fe:a9fe]       true          BLOCKED
[64:ff9b::7f00:1]          true          BLOCKED
[2002:a9fe:a9fe::]         true          BLOCKED
[2002:0a00:0005::]         true          BLOCKED
[::ffff:0:a9fe:a9fe]       true          BLOCKED
[fec0::1]                  true          BLOCKED
[100::1]                   true          BLOCKED
[::1]                      true          BLOCKED           (control)
[2606:4700:4700::1111]     false         ALLOWED           (control — no over-blocking)

Consequence for reviewers: this PR is only safe at 24be5e3 or later. Reviewing the first commit in isolation, or reverting the second while keeping the first, reintroduces the bypass. Credit to CodeRabbit for catching it.

2. The Verification section is stale

It is written against 393f197: "11 new cases", "ssrf-guard.ts — six lines of logic, plus the comment", and a non-vacuity table measured on that head. The current head is 24be5e314 cases, and ssrf-guard.ts is +50/−5, since ipIsPrivate gained NAT64, 6to4, IPv4-translated, fec0::/10 and 100::/64 handling. The two-baseline table no longer describes what would be merged.

3. "the first coverage this module has had" is not correct

main already carries two test files for this module:

  • apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts — 5 cases
  • apps/web/src/lib/__tests__/ssrf-guard-private-address-detection.test.ts — 3 cases

The claim appears in the PR body and in the header docstring of the new ssrf-guard.test.ts, so it would land in the repo as a comment that is false on arrival. Worth fixing before merge.

It also means roughly half the new file re-tests existing coverage. Duplicated:

New case Already covered by
reports a non-existent host and a privately-resolving host identically dns-leakage: "does not reveal whether a probed internal name exists"
gives every DNS outcome the same caller-visible message dns-leakage: "is indistinguishable from a transient resolver failure"
keeps the hostname, errno, resolver text out of the message dns-leakage: "does not surface resolver text when the lookup rejects"
does not leak the resolved private address to the caller dns-leakage: "still blocks hosts that resolve to a private address"
allows a host that resolves to a public address dns-leakage: "still allows a genuinely public host"
rejects a private address hiding behind an IPv4-mapped IPv6 spelling private-address-detection: "rejects loopback hiding behind an expanded IPv4-mapped IPv6 answer"
rejects when any resolved address is private, even if another is public private-address-detection: "rejects when any answer is private, even if a public one comes first"

The other 7 are genuinely new and are the ones worth keeping — the two bracket-literal cases, the pre-DNS assertion, the three transition-prefix cases (including the control that proves it isn't just over-blocking), and the pinning of #1381's deliberate IP-literal message split.

Related: #1483 was closed unmerged, and that looks right — ssrf-guard-private-address-detection.test.ts on main already carries its cases. Its one case with no equivalent anywhere is "a private address that appears after several public ones" (a 3-record answer guarding against an early-exit that only inspects a prefix), if you want to salvage that single test into this file.

Terminal state

HALTED(awaiting_merge_approval) — no automerge label, main is protected, so per §8 of the runbook this stops here rather than merging. No changes pushed to this branch.


Generated by Claude Code

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

Copy link
Copy Markdown
Owner Author

Absorbed the two offered assertions — a61062e

Pushed to this branch rather than leaving them on a closed PR, since #1483 is now closed and they were the only part of it not already covered by main or by this file.

1. expect(lookup).toHaveBeenCalledWith(host, { all: true }) on all four resolution cases (sneaky, mixed, tail, nat64).

2. A three-address case with the private address in tail position (172.16.31.9).

Non-vacuity, by mutation against the real guard

Not by inspection — both mutants were applied to ssrf-guard.ts, the suite run, and the guard restored bit-for-bit afterwards.

Mutation Result
for (const r of resolved.slice(0, 2)) — prefix-only scan 1 failed | 14 passed; the one failure is the new tail case. The existing two-address case survives, which is exactly why it doesn't cover this.
Short-circuit all four hostnames before the bracket strip, throwing the flattened NOT_PUBLIC 4 failed | 11 passed — and every failure is a toHaveBeenCalledWith. Not one message assertion caught it.

That second row is the whole argument. Today a pre-DNS rejection throws Blocked host or Blocked private IP literal, so rejectionOf + loggedText() would fail loudly and the call assertion looks redundant. It stops being redundant the moment those literals are flattened to NOT_PUBLIC as well — then a hostname absorbed by a pre-DNS branch is indistinguishable from a resolution rejection and the test goes quiet while still passing. This PR is what makes that reachable: the bracket strip is a new pre-DNS branch that did not exist before it.

git diff against the previous branch tip for ssrf-guard.ts is empty — test-only commit.

Verification on a61062e

  • npx vitest run (apps/web) — 57 files, 341 passed, 0 failed
  • npx tsc --noEmit — clean
  • npm run lint — clean

Two notes on state, not on the diff

main was already merged in by a parallel push (fbed5f8) while I was working; I rebased onto it rather than pushing a second merge. Gate 7 is satisfied — main merges cleanly, and test-frontend (#1480, merged 21:09) now reports against a current base.

Three SSRF test files now coexist on the merged tree. main carries ssrf-guard-private-address-detection.test.ts from #1428, which overlaps two cases with this file (expanded IPv4-mapped loopback; multi-answer public-first + 10.0.0.5, same addresses). Not a correctness problem and not worth churning this PR over — flagging it so the duplication is a recorded decision rather than an accident. ssrf-guard-dns-leakage.test.ts is a distinct concern and should stay separate.

Terminal state: HALTED(awaiting_merge_approval). Required checks are still running on the new head; the merge itself is a human call under MERGE_POLICY.md §8 and I am not taking it.


Generated by Claude Code

@groupthinking
groupthinking merged commit f876787 into main Aug 7, 2026
23 of 26 checks passed
@groupthinking
groupthinking deleted the claude/clever-heisenberg-b7sdbg branch August 7, 2026 21:23
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GRV-398

Copy link
Copy Markdown
Owner Author

Red-team pass on a61062e (the synchronize). No correctness defect in the new assertions — I checked the address choices and they hold: 172.16.31.9 is inside 172.16/12, 93.184.216.34 and 151.101.1.140 are public, and 64:ff9b::a9fe:a9fe does encode 169.254.169.254. The mutation evidence in the commit message is the right kind of evidence and I have no quarrel with it.

Two findings about the surrounding state rather than this diff.

1. There are now three test files for ssrf-guard.ts, from three PRs that did not know about each other.

File Arrived in Cases
ssrf-guard-dns-leakage.test.ts feae3d3#1381 5
ssrf-guard-private-address-detection.test.ts 75ef3ca#1428 3
ssrf-guard.test.ts 393f197 — this PR 15

The commit message calls out the #1428 overlap ("overlaps two cases with this file") and that is accurate — rejects loopback hiding behind an expanded IPv4-mapped IPv6 answer and rejects when any answer is private, even if a public one comes first both have near-twins here. The #1381 file wasn't mentioned and overlaps four more: its does not surface resolver text…, is indistinguishable from a transient resolver failure, still blocks hosts that resolve to a private address, and still allows a genuinely public host all restate behaviour this file also pins.

So roughly 6 of 23 cases across the three files are duplicates. Not a merge blocker — but the pattern is that each PR touching this module adds a fourth file, and the cost lands on whoever next changes the guard's contract.

2. The three files disagree about how to pin the rejection message, and the disagreement is invisible while everything passes.

Counting assertions on the literal 'Host does not resolve to a public address':

  • ssrf-guard.test.ts0
  • ssrf-guard-private-address-detection.test.ts1
  • ssrf-guard-dns-leakage.test.ts3

This PR's file is deliberately literal-free; its stated design is to compare branches against each other, because a test asserting "some static string is returned" would still pass if two branches returned two different static strings — the same oracle #1381 set out to close. That reasoning is sound. But the two files already on main pin the literal in four places.

The consequence is specific to the SsrfGuardError / flattened-NOT_PUBLIC redesign that this PR's body correctly leaves to #1428's successor: when that lands and the constant's text changes, the two older files fail on the string while this file stays green. The tempting repair is to update the literals — which is mechanical and looks safe, but those same assertions in ssrf-guard-dns-leakage.test.ts:42,82,83 are what pin the leak property (not.toContain(token), not.toContain('10.0.3.14')). A careless pass could weaken them while chasing the rename.

Worth deciding before that redesign starts, not during it: either consolidate the three files, or export the message as a constant so the literal exists once and the rename can't split the suite.

Neither finding changes my read that this PR should land. CI on this head is still queued behind a saturated Actions backlog (249 runs queued against ~22 concurrent slots at the time of writing), so I have not verified the suite independently — the 341-passed figure is the author's, and test-frontend is the check that will settle it.


Generated by Claude Code

groupthinking pushed a commit that referenced this pull request Aug 7, 2026
The inline hostname list on POST /api/workflows/video-to-actions enumerated
localhost, 127.0.0.1, 0.0.0.0, ::1, .local and .internal. Measured against the
predicate itself, that allowed:

  http://169.254.169.254/          -> "169.254.169.254"   ALLOWED
  http://10.0.0.1/                 -> "10.0.0.1"          ALLOWED
  http://192.168.1.1/              -> "192.168.1.1"       ALLOWED
  http://[::1]/                    -> "[::1]"             ALLOWED
  http://[0:0:0:0:0:ffff:7f00:1]/  -> "[::ffff:7f00:1]"   ALLOWED

Two separate defects. The list omits every RFC1918 range, 169.254/16 and CGNAT.
And `host === '::1'` is unreachable: `URL.hostname` keeps the brackets on an
IPv6 literal, so the comparison never matches — the same bug #1486 fixed in
ssrf-guard.ts, re-created here in a new file.

assertPublicHttpUrl already covers all of it, including the NAT64/6to4/
IPv4-translated encodings that #1486 added, so this deletes the list rather
than extending it. #1486 merged at 21:23, which is what makes the guard a
settled dependency to import.

The catch is deliberately bare. Interpolating the guard's message would
re-open the CWE-209 DNS oracle #1381 closed on /api/transcribe: "does not
resolve" and "resolves to a private address" must be indistinguishable to the
caller. The guard already logs the real cause for operators.

New: apps/web/src/app/api/__tests__/workflows-video-to-actions-route.test.ts,
12 cases. Verified non-vacuous by restoring the old hand-rolled check and
re-running — 8 failed | 4 passed, and the eight failures are exactly the seven
literals above plus the resolves-to-private case. The four survivors are the
ones the old list did handle (127.0.0.1, localhost), the indistinguishability
assertion, and the public-host control.

Also asserts `start` is never called on a rejected target: a refused URL that
still kicked off a durable run would move the fetch somewhere harder to see
rather than prevent it.

Merges main (#1486) for the guard. apps/web vitest: 60 files, 361 passed,
0 failed. tsc --noEmit and eslint clean.

Not addressed here, and left on the PR thread: the status route returns
err.message plus a config hint to the caller, and getRun(runId) has no
ownership binding. The first is the same CWE-209 pattern; the second is a
design question about whether runs are per-user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GG1fyRobfHwhdFaqgqKvP
groupthinking pushed a commit that referenced this pull request Aug 7, 2026
…e AI budget

Self-review follow-up. The exemption looked the prefix up with the same loose
startsWith used for class membership, so a future sibling surface whose name
merely starts with an exempted prefix -- /api/workflows-admin -- would silently
inherit the GET carve-out and drop onto the looser budget.

No route in the tree does this today (checked every directory under
apps/web/src/app/api), so this is latent rather than live. It is worth closing
while the file is open: the same shape, an incidental block quietly becoming an
allow, is what #1486 had to fix in the SSRF guard.

Class membership keeps its original loose matching. Narrowing that would move
routes off the stricter budget, which this change has no business doing; the
exemption is the widening, so only the exemption is tightened.
groupthinking added a commit that referenced this pull request Aug 13, 2026
…t bucket (#1518)

* fix(web): stop workflow status polls draining the shared AI rate-limit bucket

#1507 added /api/workflows to AI_ROUTE_PREFIXES. isAiRoute classified by path
prefix alone, so the polled GET status endpoint was metered against the AI
budget (default 12/min) while pollVideoToActions polled at 40/min. The 12th
request 429'd ~17s into a 30s window, before a transcript fetch plus an agent
call could finish.

The bucket is keyed by class, not path, so every AI prefix shares one ai:<ip>
counter -- a single Studio run also 429'd /api/chat, /api/transcribe and
/api/pipeline as collateral.

Move the classifier into auth-paths.ts, which exists as the home for path
policy free of Next.js request types so vitest can import it offline, and make
it method-aware. GET/HEAD on /api/workflows falls to the general budget; POST
stays AI-class because starting a run does real model work. The exemption is
keyed per-prefix rather than exempting GET globally, so it cannot widen another
route that later serves model work over GET. An omitted method defaults to POST
so the failure mode is the stricter limit.

Also retune the poller to 30 attempts x 2s: 30 req/min leaves roughly half the
general allowance for the rest of the page, and the wall-clock window doubles to
60s, which better fits the work the run actually does.

Closes #1517

* fix(web): require a segment boundary before exempting a route from the AI budget

Self-review follow-up. The exemption looked the prefix up with the same loose
startsWith used for class membership, so a future sibling surface whose name
merely starts with an exempted prefix -- /api/workflows-admin -- would silently
inherit the GET carve-out and drop onto the looser budget.

No route in the tree does this today (checked every directory under
apps/web/src/app/api), so this is latent rather than live. It is worth closing
while the file is open: the same shape, an incidental block quietly becoming an
allow, is what #1486 had to fix in the SSRF guard.

Class membership keeps its original loose matching. Narrowing that would move
routes off the stricter budget, which this change has no business doing; the
exemption is the widening, so only the exemption is tightened.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update javascript code tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants