Skip to content

test(security): cover SSRF private-address detection main's tests miss - #1428

Merged
groupthinking merged 2 commits into
mainfrom
claude/clever-heisenberg-eh4att
Aug 7, 2026
Merged

test(security): cover SSRF private-address detection main's tests miss#1428
groupthinking merged 2 commits into
mainfrom
claude/clever-heisenberg-eh4att

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1427

This PR changed shape. It opened as a fix for the DNS oracle in assertPublicHttpUrl. While it was in review, a parallel session fixed the same defect and landed it on main (a66e1c58c93de8 range). The conflict is resolved entirely in favour of mainssrf-guard.ts and transcription-service.ts are now byte-identical to origin/main, and none of this branch's implementation survives. What remains is test coverage main does not have.

Outcome

Three regression tests for the guard's detection logic — which resolved answers are rejected — as distinct from ssrf-guard-dns-leakage.test.ts, which covers what a rejection says.

Net diff: one new file, +76 lines, zero implementation change.

Why main's version won, and mine lost

Worth recording, because the two designs disagree on something real and main's is better:

  • main collapses the message at the call site, next to the HTTP response, and keeps informative messages inside the guard. Mine uniformed everything inside the guard, which discards detail operators can use and buys nothing the call-site collapse doesn't already provide.
  • main deliberately leaves the IP-literal and scheme branches distinguishable, reasoning that the caller supplied that input, so naming the rule confirms nothing and denies nothing. Mine suppressed those too. That is over-suppression, not defence — and forcing it through would have been a quiet regression against a documented decision.

Had this branch merged as originally written, it would have reverted both choices.

What main does not cover

ssrf-guard-dns-leakage.test.ts asserts message indistinguishability thoroughly. None of its five cases exercise whether a given resolved address is rejected at all. This PR adds:

Case Why it is not redundant
Loopback behind a fully-expanded IPv4-mapped IPv6 answer (0:0:0:0:0:ffff:7f00:1) net.isIPv4 does not match this spelling, so only the hextet expansion catches that the low 32 bits decode to 127.0.0.1. A resolver may return the expanded form.
A genuine public IPv6 answer still allowed Control. Without it the case above would be satisfied by a guard that simply rejected all IPv6.
A private address in a multi-answer response where a public one sorts first Every answer must clear the check, not resolved[0].

This gap matters more after the leakage fix than before it. Every non-public outcome now reports one identical message, so a detection regression no longer looks any different from a working rejection — "blocked" and "blocked for the wrong reason" are now the same string. Detection tests are what still separate them.

Verification

Head 07a0ab2. Non-vacuity established by mutation, not inspection:

  • Restricting the loop to resolved.slice(0, 1) → fails only the multi-answer case.
  • Replacing the IPv4-mapped decode with return false → fails only the mapped-loopback case.
  • main's five existing tests pass against both mutants. This is the direct evidence the coverage is additive rather than duplicated — a guard that lets loopback through unnoticed is green on main today and red with this PR.
  • npx tsc --noEmit — clean
  • npm run lint — clean
  • npm test326 passed (56 files), zero failures

The billing-chat-gating timeout this branch previously reported as pre-existing is gone; #1230 fixed it on main.

Risk

  • Risk level: minimal. Test-only; no runtime, config, or schema surface.
  • Failure mode: if the mutation evidence above is wrong, the cost is three redundant tests. Nothing can break in production from this diff.
  • Rollback: delete the file.

Production evidence

Not applicable — test-only diff, no deployable surface.

Agent handoff

Agent provenance

Agent-authored. No agent-lock-manifest is filled in: the manifest declares a run_id and agent_login that the truth gate treats as evidence expected to be corroborated by append-only result comments. There is no dispatch record behind this change, and fabricating those values would inject false evidence into that mechanism.

`assertPublicHttpUrl` threw a distinct message per rejection cause, and
`transcription-service.ts` interpolated that message into `result.error`,
which `/api/transcribe` returns verbatim. Since that route is
unauthenticated, a caller could distinguish

    Rejected audioUrl: Host does not resolve
    Rejected audioUrl: Host resolves to a private address

and enumerate internal hostnames one guess at a time, without
credentials and without completing a fetch.

The leak was worse than the distinct literals suggest: `dns.lookup`
rejects with ENOTFOUND rather than resolving empty, so the resolver's
own `getaddrinfo ENOTFOUND <host>` propagated out uncaught — carrying
the hostname and the resolver verdict into the response body. The
`resolved.length === 0` branch was effectively unreachable.

Every rejection now throws `SsrfGuardError`, whose `message` is the
single constant `SSRF_REJECTION_MESSAGE` and whose `reason` carries the
specific cause for server-side logs only. The DNS lookup is wrapped so
resolver failures become the same uniform rejection. The call site logs
`reason` via `console.error` and returns the constant.

Detection logic is untouched: the same URLs are accepted and rejected as
before, only the reporting changed.

Tests: 7 new cases in `apps/web/src/lib/__tests__/ssrf-guard.test.ts`,
the first coverage this module has had. The load-bearing assertion is
indistinguishability — a non-existent host and a private host must
produce byte-identical caller-visible messages — because asserting
merely that "a static string" is returned would still pass if the two
branches returned two different static strings. Each also asserts the
diagnostic survives in `reason`, so suppression is proven without
proving silence.

Verified non-vacuous: 6 of the 7 fail against the pre-fix guard. The
7th is the no-regression control (a public host is still allowed) and
passes on both.

  npx tsc --noEmit    clean
  npm run lint        clean
  npm test            258 passed, 1 failed (47 files)

The single failure is `billing-chat-gating.test.ts` "blocks free tier
after daily quota" (5000 ms timeout). Confirmed pre-existing by
re-running it with these changes stashed on the same checkout, where it
fails identically at 5005 ms. Tracked in #1116.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng3VUMYcPDELaNpKPgd83e
@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 8:59pm

@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: 87c3c288-3a6d-4630-b8f3-4c2162ee1e3e

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.

@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 07a0ab2.
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

@github-actions github-actions Bot added javascript Pull requests that update javascript code tests labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: BLOCKED

Reasons: invalid_payload

Machine-readable verdict
{
  "details": {
    "collection_errors": [
      "incomplete_linked_issue_contract",
      "missing_intent_snapshot",
      "missing_agent_run_id",
      "missing_agent_login"
    ],
    "invalid_fields": [
      "policy.agent_login",
      "policy.run_id"
    ]
  },
  "reasons": [
    "invalid_payload"
  ],
  "verdict": "blocked"
}

Workflow evidence

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

The skip above was a label race, not a configuration mismatch — auto-label applied javascript and tests after your run had already started, and both are in the required set.

Worth flagging what to attack, because the diff is small and the interesting claim is not visible in it. This PR asserts that a caller-visible error message varying by cause is itself the vulnerability, and it changes only reporting — not one range check in ipIsPrivate moved. Two directions I would rather you falsify than agree with:

  1. Did I actually close the oracle, or just move it? The uniform message has to hold across every rejection path, including ones no test names. If any path still throws a bare Error — or if SsrfGuardError is ever constructed with a reason that leaks into message — the oracle survives with extra steps. The dns.lookup wrapper is the subtle one: it exists because lookup rejects with ENOTFOUND rather than resolving empty, which means the pre-fix resolved.length === 0 branch was effectively unreachable and the real leak was an uncaught rejection carrying getaddrinfo ENOTFOUND <host>.

  2. Did I weaken the guard while quieting it? Making every rejection look identical is exactly the kind of change that can accidentally turn a rejection into an acceptance. The three "the guard still guards" tests are my control, but they are my own tests — please check the accept/reject behaviour independently, particularly the IPv4-mapped IPv6 path (0:0:0:0:0:ffff:7f00:1) and the mixed public/private multi-address case.

One deliberate trade-off to weigh in on: operators lose the cause from the HTTP response and must read it from logs (console.error at the call site, carrying err.reason). I think that is correct for an unauthenticated route. If you think the logging is too thin to diagnose a real incident, say so.

agent-completion/truth-gate — red, not required, unfixable from this branch

Recording rather than leaving an unexplained red mark. pr-checks.yml:4 runs on pull_request_target, so the workflow executes from base, not from this head — no commit here can change it. The head ref claude/clever-heisenberg-eh4att matches the agent-dispatch pattern, so the full contract applies, including an agent-lock-intent-snapshot:v1 comment authored by github-actions[bot] that must strictly predate the PR. #1427 was filed by hand minutes ago and never dispatched, so no snapshot exists and one written now would fail the predates-check. The invalid_fields (policy.agent_login, policy.run_id) are the deliberately-empty manifest; filling them would inject fabricated evidence into the mechanism rather than satisfy it.

It is not in MERGE_POLICY.md gate 2's required list, and that file names this very check (line 162) as its own worked example of a check red on ~100% of PRs including merged ones. #1409 is the open fix for the arming defect.

dependency-review green, Vercel preview Ready.


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 '== PR metadata and checks =='
gh pr view 1428 --repo groupthinking/EventRelay --json headRefOid,baseRefName,files,labels,statusCheckRollup --jq '{
  head: .headRefOid,
  base: .baseRefName,
  labels: [.labels[].name],
  files: [.files[].path],
  checks: [.statusCheckRollup[] | {name, conclusion, status}]
}'

echo '== Changed-file AST outlines =='
for f in \
  apps/web/src/lib/ssrf-guard.ts \
  apps/web/src/lib/transcription-service.ts \
  apps/web/src/lib/__tests__/ssrf-guard.test.ts
do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all --view expanded || true
  fi
done

echo '== PR diff =='
gh pr diff 1428 --repo groupthinking/EventRelay -- \
  apps/web/src/lib/ssrf-guard.ts \
  apps/web/src/lib/transcription-service.ts \
  apps/web/src/lib/__tests__/ssrf-guard.test.ts

echo '== All guard imports/calls and response propagation candidates =='
rg -n -C 5 --glob '*.ts' --glob '*.tsx' \
  '(assertPublicHttpUrl|SsrfGuardError|SSRF_REJECTION_MESSAGE|\.reason\b|error\?\.includes\s*\()' \
  apps/web/src 2>/dev/null || true

Length of output: 6334


🏁 Script executed:

#!/bin/bash
set -u

echo '== Focused diff from merge base =='
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main)"
git diff --no-ext-diff --unified=80 "$base"...HEAD -- \
  apps/web/src/lib/ssrf-guard.ts \
  apps/web/src/lib/transcription-service.ts \
  apps/web/src/lib/__tests__/ssrf-guard.test.ts || true

echo '== Guard implementation =='
cat -n apps/web/src/lib/ssrf-guard.ts 2>/dev/null || true

echo '== Transcription guard call context =='
rg -n -C 18 --glob 'transcription-service.ts' \
  '(assertPublicHttpUrl\s*\(|SsrfGuardError|SSRF_REJECTION_MESSAGE|console\.error\s*\()' \
  apps/web/src/lib 2>/dev/null || true

echo '== Guard regression tests =='
cat -n apps/web/src/lib/__tests__/ssrf-guard.test.ts 2>/dev/null || true

echo '== Every throw/reject path in the guard and every project guard call =='
rg -n -C 4 --glob 'ssrf-guard.ts' \
  'throw\s+new\s+|throw\s+|new\s+SsrfGuardError\s*\(|dns\.lookup\s*\(' \
  apps/web/src/lib 2>/dev/null || true
rg -n -C 6 --glob '*.ts' --glob '*.tsx' \
  '\bassertPublicHttpUrl\s*\(' \
  apps/web/src 2>/dev/null || true

echo '== /api/transcribe error response and client categorization =='
rg -n -C 12 --glob 'route.ts' --glob '*.ts' --glob '*.tsx' \
  '(fetchTranscript\s*\(|result\.error|error\?\.includes\s*\(|transcribe)' \
  apps/web/src/app apps/web/src 2>/dev/null || true

Length of output: 138615


@groupthinking: Full static review complete. I found no blocking issue.

  • Every rejection path in assertPublicHttpUrl creates SsrfGuardError.
  • SsrfGuardError.message always equals SSRF_REJECTION_MESSAGE.
  • SsrfGuardError.reason does not affect the caller-visible message.
  • The dns.lookup catch converts resolver failures, including ENOTFOUND and EAI_AGAIN, to the same public message.
  • The only live guard call site logs reason and returns the constant message. It does not return reason.
  • The IP-range detection logic is unchanged.
  • The IPv4-mapped IPv6 and mixed public/private DNS answer paths still reject.
  • The test suite directly checks indistinguishability and the retained server-side diagnostics.

The logging is sufficient for this route. The log prefix identifies the guard event, and reason retains the host, address, or resolver failure needed for diagnosis.

At review time, required CI jobs were still in progress. agent-completion/truth-gate remained red as documented, and it is not a required merge gate.

You are interacting with an AI system.

Copy link
Copy Markdown
Owner Author

Green gate reached — HALTED(awaiting_merge_approval)

Head 32632fd. Every unconditionally-required check passes. The only remaining step is a human merge.

Required checks, per MERGE_POLICY.md gate 2

Check Result
validate success
CI (guards, lint-python, lint-frontend, build, test) success — 5/5 jobs, zero failures in the run
CodeQL success
gitleaks (working tree) success
dependency-review success
Canonical issue and evidence success

Security contexts, all green: Security Scan - python, Security Scan - javascript, bandit, python-safety, npm-audit, trivy. Vercel preview Ready; Vercel Agent Review success. E2E Pipeline Tests skipped.

Generate and Upload Coverage was still running when I wrote this — the Python coverage suite, on step 6 of 8. Recording that as in-flight rather than claiming it green.

Neutral, not failing: PR Governance, Agent completion enforcement, and the capital-Trivy duplicate that #1410 documents as a permanently-neutral twin of the lowercase job.

Not green: agent-completion/truth-gate — analysed in the comment above. Not in the required list, and unfixable from this branch because pull_request_target runs the workflow from base. It is the sole cause of mergeable_state: unstable, and it has now fired twice on this head with a byte-identical verdict.

Review

CodeRabbit ran a full review and returned no blocking issues, confirming each claim against the source rather than against my description of it — that every rejection path constructs SsrfGuardError, that message always equals SSRF_REJECTION_MESSAGE, that reason never influences the caller-visible message, that the dns.lookup catch folds ENOTFOUND/EAI_AGAIN into the same message, and that the only live call site logs reason without returning it.

It also checked the direction I most wanted falsified — whether quieting the guard had weakened it — and confirmed the IP-range detection logic is unchanged, with the IPv4-mapped IPv6 and mixed public/private answer paths still rejecting. Zero review threads open.

What is worth re-checking before merge

The fix rests on one claim: an error message that varies by cause is the vulnerability, and the remedy is to make every rejection indistinguishable. If you disagree that a uniform message is right for this route, the change is easy to reason about — it is one constant, one error class, and one try/catch around dns.lookup.

The trade-off is real and deliberate: operators can no longer read the cause from an HTTP response and must read it from logs. That is why the console.error at the call site carries err.reason, and why two of the seven tests assert the diagnostic survives — so this cannot silently degrade into dropping the signal.

Staged merge command

gh pr merge 1428 --squash --repo groupthinking/EventRelay

Not merging automatically: this PR carries no automerge label, main is protected, and it is still a draft pending your review of the trade-off above.


Generated by Claude Code

groupthinking pushed a commit that referenced this pull request Aug 7, 2026
Raised by CodeRabbit on #1440 (P2), and correct. The previous commit's docblock
asserted that every `fetchTranscript` error value reaching the client is a fixed
app-authored literal. That was false: the guard branch returned
`Rejected audioUrl: ${guardErr.message}` verbatim.

`assertPublicHttpUrl` throws five distinguishable messages — `Invalid URL`,
`Blocked URL scheme: <protocol>`, `Blocked host`, `Blocked private IP literal`,
and `Host does not resolve to a public address`. Forwarding them told the caller
WHICH rule fired. `Blocked host` confirms a hostname-blocklist match; the
resolution message confirms only that DNS returned nothing public. That
difference is a policy oracle, and it sharpens as BLOCKED_HOSTNAMES grows.

All five rejections now return one fixed `Rejected audioUrl`; the real reason is
logged. With this, all seven of the service's error values are fixed literals —
verified by enumeration, not assumed.

The docblock overstated in the same way #1381's did, which is what this PR was
opened to correct, so it has been rewritten to state what is actually true and
to name what it does NOT claim: the choice among literals still tells a caller
whether their host cleared the guard. That residue is inherent to a guard that
refuses some inputs and attempts others, is far coarser than naming the rule,
and the route is session-gated — accepted, and now written down rather than
glossed.

Complementary to #1428, which fixes this at the guard level with SsrfGuardError
(public message + private reason). This is the call-site boundary and does not
touch ssrf-guard.ts, so the two do not conflict.

Verification (head 8d756b6 + this, apps/web):
- New test drives all five real guard messages through fetchTranscript and
  asserts the caller-visible error is identical. Fails without the fix
  (`expected 5 to be 1`), passes with it.
- vitest run: 1 failed | 291 passed (292). The failure,
  billing-chat-gating "blocks free tier after daily quota", is pre-existing and
  unrelated — confirmed earlier on the untouched base. Tracked in #1116.
- npm run type-check: clean. npm run lint: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

Copy link
Copy Markdown
Owner Author

Overlap with #1381 — same defect, two implementations

Flagging before this leaves draft: #1381 (ready, all checks green) already fixes the SSRF DNS oracle in the same file. Both branches change apps/web/src/lib/ssrf-guard.ts and apps/web/src/lib/transcription-service.ts, so whichever merges second will conflict.

Verified by diffing both heads against origin/main:

File #1428 #1381
apps/web/src/lib/ssrf-guard.ts
apps/web/src/lib/transcription-service.ts
apps/web/src/lib/__tests__/ssrf-guard.test.ts
apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts

The two fixes are not identical, and the difference matters:

So #1428 is the strictly broader fix; #1381 is the narrower one that happens to be green and ready now. This is a scope call, not a correctness one — #1381's IP-literal branch keeps its specific message deliberately, on the argument that the caller supplied the address and no name is confirmed or denied.

Suggested resolution (human call, not taken here): let #1381 merge as-is, then rebase this branch and reduce it to the delta — the SsrfGuardError type and the uniform message on the parse/scheme/host branches that #1381 does not cover. That preserves the broader hardening without re-litigating the DNS branches, and keeps this PR from becoming the third duplicate the merge policy was rewritten to prevent.

No action taken on either PR.


Generated by Claude Code

groupthinking added a commit that referenced this pull request Aug 7, 2026
…1381)

* fix(web): stop leaking upstream and Stripe error details to clients

Two information-disclosure issues were returning third-party error text
verbatim to callers.

1. /api/realtime/session (GET + POST) returned the raw OpenAI response
   body as `details` while mirroring `upstream.status`. OpenAI bodies
   echo org/project IDs, quota state, and on 401 a partial key
   (`Incorrect API key provided: sk-proj-****ABCD`). Both handlers now
   log the body server-side and return a static message with a fixed 502.

2. Five billing routes returned raw Stripe SDK `err.message`. All five
   are on the unauthenticated PUBLIC_API_EXACT allowlist, so any internet
   caller could trigger and read them. Each now returns a static string
   plus a machine-readable code; the raw message stays in console.error
   and kaizenObserve.

/api/transcribe had the same raw-upstream pattern in its JSON-parse and
catch-all branches and is fixed the same way.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs(web): correct two inaccurate security comments flagged in review

Applies the two fixes agreed in the Copilot review threads on #1381. Both
are comment-only; no behavior, status code, or response body changes.

transcribe/route.ts — the docblock claimed all error responses are static
and machine-readable. The four `!result.success` branches return
`fetchTranscript`'s message verbatim and carry no `code`, and the billing
branch still returns a static `details` hint. Those messages are
app-authored (every `error` in transcription-service.ts is a literal, a
numeric status, or our own SSRF-guard text), so the CWE-209 objective
holds — but the docblock overstated it. Scoped it to the two sanitized
paths and described the rest accurately.

billing/webhook/route.ts — the rationale was wrong on two counts.
`constructEvent` verifies Stripe-Signature against the webhook signing
secret, not the API key, so an API-key fragment cannot forge a signature;
and StripeSignatureVerificationError describes tolerance/scheme/digests
without echoing key material. Rewrote it to state the real risk: on an
unauthenticated route, verification internals are an oracle for tuning
replay/timestamp attacks.

Verified at this head: type-check clean, eslint clean, the six route test
files pass (17 tests), full web suite 261 passed / 1 failed — the failure
being the pre-existing billing-chat-gating timeout tracked in #1116, which
fails identically on the base commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jsfy3ts3U1ggGsJqFB2KSC

* fix(web): return the static 502 when the Realtime upstream is unreachable

CodeRabbit flagged this on #1381 and it reproduces: neither GET nor POST
wrapped its `fetch`, so a transport-level failure — DNS, TLS, connection
reset, timeout — rejected before `upstreamFailure` could run. The throw
escaped to Next.js's own unstructured 500 instead of the `{ error, code }`
JSON the route promises, `use-realtime-voice.ts` got a payload it cannot
parse, and no `[realtime]` line was logged for the operator.

Both handlers now wrap the fetch and the body read in try/catch and route
transport failures through a new `upstreamUnreachable` helper, which logs
the cause server-side and returns the same fixed 502 as a non-OK upstream
response. The cause is an Error from our own transport rather than upstream
text, so logging it introduces no disclosure. `await upstream.text()` is
inside the try as well — a truncated body rejects there for the same reason.

The gap predates this PR, but this PR is what establishes the contract that
these routes always answer with a static structured error, so it belongs
here.

Three regression tests, each verified non-vacuous — they fail against the
unwrapped route and pass against the fix: GET with a rejected fetch
(ENOTFOUND), POST with a rejected fetch (socket hang up), and GET with a
response whose body read rejects mid-stream.

Verified at this head: tsc clean, eslint clean, full web suite 264 passed /
1 failed — the pre-existing billing-chat-gating timeout tracked in #1116,
identical on the base commit. Up from 261 passed by exactly these 3 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jsfy3ts3U1ggGsJqFB2KSC

* fix(web): complete the error-code contract across the patched routes

CodeRabbit's review of 1adbed6 found the `code` field was added only to the
branches that were sanitized for CWE-209, leaving the rest of each route's
error surface without a machine-readable key. A client cannot branch on a
contract that only half the branches honour.

Every remaining error branch on the six patched routes now returns `code`
alongside its existing `error` value:

- realtime/session — `realtime_not_configured` (GET and POST missing-key),
  `invalid_sdp_offer`
- billing/activate — `invalid_json`, `session_id_required`, `not_eligible`
- billing/checkout — `invalid_json`, `turnstile_rejected`
- billing/renew   — `invalid_json`
- billing/webhook — `webhook_not_configured`, `missing_signature`

`error` values are unchanged, so no client contract breaks. The checkout
Turnstile branch additionally gains a fallback: `turnstile.error` is optional
on `TurnstileVerifyResult`, so the previous `{ error: turnstile.error }`
could serialize to `{}`. Its values are all our own literals, never Cloudflare
response text, so returning it verbatim remains correct.

Also from the same review:

- `upstreamFailure` and `upstreamUnreachable` declare `: Response`.
- `activateRequest`/`webhookRequest` declare `: NextRequest`, `transcribeRequest`
  declares `: Request`.

The review's remaining finding — that a rejected `fetch()` skips `upstreamFailure`
entirely — was already fixed in ae2be83, which landed after the review ran.

+9 tests covering each newly-coded branch. `npm test` 271 passed / 1 failed;
the single failure is the pre-existing `billing-chat-gating` 5000 ms timeout
(#1116), identical on the base commit. `npm run lint` and `npm run type-check`
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): annotate remaining helper return types and cover the Turnstile fallback

Absorbs the three items #1402 carried that this branch did not, so the two
lines of work converge here rather than diverging:

- `getOpenAiHeaders` declares `: Record<string, string> | null`
- `sdpRequest` declares `: Request`, `upstreamResponse` declares `: Response`
- a test for `verifyTurnstileToken` returning `{ ok: false }` with no `error`,
  which the optional-field fallback added in 5e69b7e handles but nothing
  exercised

#1402's `fetchUpstream` rewrite is deliberately not taken. It guards only the
`fetch` call, leaving `await upstream.text()` outside the try — so a connection
reset mid-body still escapes the route. `upstreamUnreachable` (ae2be83) wraps
both, which is the stricter guarantee.

`npm test` 272 passed / 1 failed — the pre-existing `billing-chat-gating`
timeout (#1116), identical on the base commit. `npm run lint` and
`npm run type-check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): stop verifyTurnstileToken rejecting into an unstructured 500

CodeRabbit's review of the previous head found this and it reproduces.
verifyTurnstileToken returns a TurnstileVerifyResult on every other exit, so
its sole caller — the unauthenticated /api/billing/checkout route — awaits it
outside any try/catch. Two awaits inside it break that contract: the
siteverify fetch rejects on a transport failure, and res.json() rejects when
Cloudflare answers 2xx with a truncated or non-JSON body. Either escaped POST
as an unstructured framework 500 with no kaizenObserve trace and no `code` —
the same disclosure-shaped hole ae2be83 closed on the realtime route.

Fixed at the source rather than at the call site: the function now honours its
own Promise<TurnstileVerifyResult> signature, so every present and future
caller is covered. The reject reason is logged server-side — undici puts the
resolved host and port in it (`connect ECONNREFUSED 10.0.3.14:443`) — and
collapsed into the app-authored literal `turnstile_verification_unavailable`.

The route's existing 403 branch handles it unchanged, so no status code or
response shape moves. Note the 403 is now also reachable when Cloudflare is
merely unreachable rather than rejecting; keeping the existing status was the
narrower choice, and the distinction is visible to operators in the log and to
clients in the `error` literal.

Five tests, verified non-vacuous — reverting the guard fails three of them:
rejected fetch, rejected res.json(), and a non-rejection contract check, plus
the accept and non-2xx paths. They assert the resolved host/port never rides
out on the result while the reason still reaches console.error.

Verified at this head: tsc clean, eslint clean, full web suite 277 passed /
1 failed — the pre-existing billing-chat-gating 5000 ms timeout (#1116),
which is also intermittently joined by a second timeout under load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jsfy3ts3U1ggGsJqFB2KSC

* fix(web): complete the error-code contract on the transcribe route

CodeRabbit flagged the `!result.success` branches of /api/transcribe as a
CWE-209 leak. Traced every value `fetchTranscript` can put in `error` before
changing anything: each one is an app-authored literal (transcription-service
75, 296, 303, 314), a numeric HTTP status (287), our own SSRF-guard message
(278, and every throw in ssrf-guard interpolates only the caller's own URL),
or a conditional literal (363). Upstream throws are swallowed at console.warn
and never reach `error`. So the leak half of that finding does not hold.

The contract half does. This PR established `error` + a stable machine-readable
`code` on every error branch of the five billing routes and realtime/session;
transcribe was the last surface still missing it. The docblock documented the
gap instead of closing it.

All five error branches now carry a code — input_required, rate_limited,
billing_not_configured, transcription_unavailable, alongside the existing
invalid_json and transcription_failed. The `error` strings are untouched, so
the human-facing contract is unchanged, matching the precedent already accepted
for the Turnstile branch: dynamic message, pinned code.

The `details` hint on the billing branch is kept. It is an app-authored
operator string, not upstream text; the docblock no longer claims otherwise.

Tests: +5. One asserting the missing-input branch short-circuits before
fetchTranscript, and four table-driven cases pinning each resolved-failure
branch's status and code while asserting no provider-shaped token reaches the
client. Also added vi.clearAllMocks() to the afterEach — restoreAllMocks only
restores spies, so the module-factory vi.fn() was leaking call history between
tests in this file.

apps/web: type-check clean, eslint clean, 282 passed / 1 failed — the
pre-existing billing-chat-gating timeout tracked in #1116.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1xcdTagvCU4K7ZwTgGQF6

* fix(web): stop the SSRF guard leaking DNS resolver text to the caller

CodeRabbit found the last CWE-209 path on this PR's surface, and it is a
real one. `assertPublicHttpUrl` throws app-authored literals at every exit
except `dns.lookup`, which was awaited bare:

  ssrf-guard.ts:121      await dns.lookup(host, { all: true })
    -> rejects `getaddrinfo ENOTFOUND <submitted-hostname>`
  transcription-service.ts:273-280
    -> `Rejected audioUrl: ${guardErr.message}`
  transcribe/route.ts:104-113
    -> returns that verbatim as `error` in a 503

So a caller could name any host and read back the server's resolver verdict
on it. That is system error text reaching a client, which is this PR's whole
objective — but the sharper problem is that it is a DNS oracle. ENOTFOUND vs
EAI_AGAIN vs the existing 'Host resolves to a private address' distinguishes
"no such name" from "exists but private" from "exists and public", letting a
caller map internal names from the server's network position. That is exactly
the reconnaissance this guard exists to block, leaking out of the guard.

Fixed at the source rather than at either call site, so present and future
callers of `assertPublicHttpUrl` are covered: the lookup is wrapped, the
reject reason is logged server-side, and every resolver failure collapses
into 'Host does not resolve' — the literal a zero-result lookup already
threw, so the oracle closes in both directions.

This also corrects a claim made earlier on this PR. The transcribe docblock
thread asserted every `result.error` value was app-authored and listed
line 278 as "our own SSRF-guard message". That held for the five explicit
`throw new Error(...)` sites but not for the `dns.lookup` rejection, which is
Node's. The docblock scoping stands; the reasoning behind it was incomplete.

Severity is bounded: `/api/transcribe` is not on `PUBLIC_API_EXACT`, so this
needs an authenticated caller, unlike the billing routes.

Four tests, verified non-vacuous — reverting the try/catch fails exactly the
two leakage tests (resolver text suppressed but still logged; ENOTFOUND and
EAI_AGAIN indistinguishable from an empty result) while the two behaviour
tests still pass, since private-address blocking and the public-host allow
path do not depend on the catch.

Verified at this head: tsc clean, eslint clean, web suite 286 passed /
1 failed — the pre-existing billing-chat-gating 5000 ms timeout (#1116).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): close the last DNS oracle in the SSRF guard

Vercel's review bot found this on 8628b5e and it is correct — 36645ad closed
only half the hole it described. That commit collapsed the resolver-failure
cases (ENOTFOUND, EAI_AGAIN, zero results) into one literal, but left the
private-address branch throwing a distinct one:

    Host does not resolve            <- the name does not exist
    Host resolves to a private address  <- the name EXISTS, and is internal

That difference is the sharpest oracle of the set. `fetchTranscript`
interpolates the message into `Rejected audioUrl: ${guardErr.message}`
(transcription-service.ts:278) and the transcribe route returns it verbatim on
the `!result.success` path, so an unauthenticated caller could guess internal
hostnames and read back whether each one exists and points at internal
infrastructure — the reconnaissance this guard exists to prevent. 36645ad's own
comment names "Host resolves to a private address" as part of the oracle and
then does not unify it.

All four non-public outcomes now throw one literal, `Host does not resolve to a
public address`, which is true of every one of them. The resolved address is
logged for operators instead.

The IP-literal branch keeps its own distinct message deliberately: the caller
supplied that address, so naming it private reveals nothing they did not
already know, and no hostname is confirmed or denied.

Two tests, verified non-vacuous — restoring the old literal fails both:
one asserts the private address never rides out on the rejection while still
reaching console.error, the other feeds the same hostname down the
does-not-exist and exists-but-internal paths and asserts the caller cannot
tell them apart.

npm test 287 passed / 1 failed — the pre-existing billing-chat-gating timeout
(#1116). lint and type-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): stop /api/transcribe leaking probe status and key config

#1381 sanitized the thrown and JSON-parse paths of /api/transcribe but left
the `!result.success` branches returning `fetchTranscript`'s message verbatim,
on the stated grounds that every such value is an app-authored literal. Two
were not, and both are reachable by a caller who controls the request body:

- `Failed to fetch audio: ${audioResponse.status}` echoed the HTTP status of
  the caller-supplied `audioUrl`. The SSRF guard admits public hosts, so this
  returned a cross-origin read (401 vs 403 vs 404 vs 500) that the browser's
  same-origin policy would otherwise deny — a probe oracle, not upstream text,
  which is why it survived the original pass.
- The all-strategies-failed message branched on whether provider keys were
  set, disclosing server configuration and naming both the variables and the
  hosting platform.

Both are fixed at the source in transcription-service.ts rather than masked at
the route, so any future caller of fetchTranscript inherits the fix. The real
status and the real key state are logged for operators, where they were the
only thing actionable. Also drops the residual `details` field from the
billing branch, which named this deployment's cloud and model vendors — #1381
stated `details` was gone from this route, and this is the one that was left.

The route docblock carried the claim these values disproved; it now states the
invariant and what enforces it.

Verification (head e9bf62d, apps/web):
- 3 new tests in transcription-error-disclosure.test.ts; all 3 fail against
  the unfixed service and pass with it — the status test asserts four distinct
  upstream statuses collapse to one message, so a partial fix cannot pass it.
- vitest run: 1 failed | 290 passed (291). The single failure,
  billing-chat-gating "blocks free tier after daily quota", is pre-existing and
  unrelated — confirmed failing identically on the untouched e9bf62d with this
  change stashed. Tracked in #1116; #1230 makes the suite hermetic against it.
- npm run type-check: clean. npm run lint: clean.
- package-lock.json drift from npm install reverted, per #1381's convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

* test: use the repo's standard fixture video ID

The new disclosure tests used dQw4w9WgXcQ. `.github/copilot-instructions.md`
requires auJzb1D-fag for all test data, and 60 files follow it against 6
stragglers — this file was one I had just added to that tail. Flagged by
Vercel VADE review on #1440.

The ID is inert here: fetch is stubbed, so the URL is never dereferenced and
only reaches the strategy dispatch. 3/3 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

* fix(web): stop forwarding the SSRF guard's rejection reason

Raised by CodeRabbit on #1440 (P2), and correct. The previous commit's docblock
asserted that every `fetchTranscript` error value reaching the client is a fixed
app-authored literal. That was false: the guard branch returned
`Rejected audioUrl: ${guardErr.message}` verbatim.

`assertPublicHttpUrl` throws five distinguishable messages — `Invalid URL`,
`Blocked URL scheme: <protocol>`, `Blocked host`, `Blocked private IP literal`,
and `Host does not resolve to a public address`. Forwarding them told the caller
WHICH rule fired. `Blocked host` confirms a hostname-blocklist match; the
resolution message confirms only that DNS returned nothing public. That
difference is a policy oracle, and it sharpens as BLOCKED_HOSTNAMES grows.

All five rejections now return one fixed `Rejected audioUrl`; the real reason is
logged. With this, all seven of the service's error values are fixed literals —
verified by enumeration, not assumed.

The docblock overstated in the same way #1381's did, which is what this PR was
opened to correct, so it has been rewritten to state what is actually true and
to name what it does NOT claim: the choice among literals still tells a caller
whether their host cleared the guard. That residue is inherent to a guard that
refuses some inputs and attempts others, is far coarser than naming the rule,
and the route is session-gated — accepted, and now written down rather than
glossed.

Complementary to #1428, which fixes this at the guard level with SsrfGuardError
(public message + private reason). This is the call-site boundary and does not
touch ssrf-guard.ts, so the two do not conflict.

Verification (head 8d756b6 + this, apps/web):
- New test drives all five real guard messages through fetchTranscript and
  asserts the caller-visible error is identical. Fails without the fix
  (`expected 5 to be 1`), passes with it.
- vitest run: 1 failed | 291 passed (292). The failure,
  billing-chat-gating "blocks free tier after daily quota", is pre-existing and
  unrelated — confirmed earlier on the untouched base. Tracked in #1116.
- npm run type-check: clean. npm run lint: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Owner Author

Overlap: this PR's "no file overlap" premise is now stale — #1381 merged into the same two files

Flagging under MERGE_POLICY.md v2 gate 6, which asks for reconciliation rather than mutual blocking, on a 72-hour clock.

This description states:

#1381 owns the route handlers in apps/web/src/app/api/**; this PR owns apps/web/src/lib/ssrf-guard.ts and the one call site in apps/web/src/lib/transcription-service.ts. No file overlap#1381 changes neither file.

That was accurate when this was opened at 18:17. It is not now. #1381 later picked up 020fad8 and follow-ups, grew from 16 to 18 changed files, and merged at 20:47 — touching both files this PR claims exclusively.

The oracle this PR is named for is already closed on main, by a different mechanism:

main (via #1381) this PR
Where uniformity is enforced call site — transcription-service.ts collapses every guard message into one fixed string inside the guard — SsrfGuardError gives all six throw sites one message
DNS causes NOT_PUBLIC constant across resolver-rejection, empty-result, and private-result same, via SSRF_REJECTION_MESSAGE
Diagnostic retention console.error inside the guard structured reason field, logged by the caller
Guard test coverage ssrf-guard-dns-leakage.test.ts ssrf-guard.test.ts, 7 cases

main's call-site comment already reasons through the same policy-oracle argument this PR makes, including that Blocked host sharpens as BLOCKED_HOSTNAMES grows.

So this PR is not obsolete, but it is misframed. What it still adds that main does not have:

  1. Uniformity at the guard, not the caller. main protects the one known call site. Any future caller of assertPublicHttpUrl that interpolates err.message reintroduces the leak. SsrfGuardError makes that structurally impossible — the stronger design, and the reason I would not just close this.
  2. Coverage main lacks — the IPv4-mapped IPv6 case (0:0:0:0:0:ffff:7f00:1) and the mixed public/private multi-address case, neither of which ssrf-guard-dns-leakage.test.ts exercises.

It will not merge as-is. Base is 5934cbf, predating #1381. The diff removes the bare const resolved = await dns.lookup(host, { all: true }) and the two old literals — lines that no longer exist on main. Expect a conflict, and note that a careless resolution could revert #1381's call-site collapse.

Suggested reconciliation, keeping this PR as the winner on the merits:

  1. Merge main in and resolve toward SsrfGuardError as the single mechanism.
  2. Simplify transcription-service.ts — with a uniform message from the guard, the call site no longer needs its own collapsing logic, only the reason logging.
  3. Fold ssrf-guard-dns-leakage.test.ts and ssrf-guard.test.ts together rather than leaving two overlapping files.
  4. Re-scope the title and description: the oracle is closed; this hardens the guard so it cannot reopen, and adds the missing coverage. Worth updating Closes #1427 too if fix(security): SSRF guard leaks a DNS oracle through /api/transcribe (CWE-209) #1427's acceptance criteria are already met by main.

Not touching the branch myself — it is not mine and the reconciliation call is yours.


Generated by Claude Code

@groupthinking
groupthinking marked this pull request as ready for review August 7, 2026 20:51
@groupthinking
groupthinking enabled auto-merge (squash) August 7, 2026 20:51

Copy link
Copy Markdown
Owner Author

Reconciliation needed — this PR now conflicts with, and appears superseded by, #1381.

This PR's scope note says #1381 "changes neither file." That was true when written; it is no longer. #1381 merged as feae3d3 and did modify both apps/web/src/lib/ssrf-guard.ts and apps/web/src/lib/transcription-service.ts, which is why the PR is now dirty. I reproduced the conflict locally — content conflicts in exactly those two files, in the DNS-rejection block both changes rewrite.

main already closes the oracle. ssrf-guard.ts on main collapses all three DNS outcomes into one constant and logs the cause for operators:

141:  const NOT_PUBLIC = 'Host does not resolve to a public address';
146:    console.error('[ssrf-guard] DNS lookup failed:', err);
147:    throw new Error(NOT_PUBLIC);
149:  if (resolved.length === 0) throw new Error(NOT_PUBLIC);
152:      console.error(…host resolved to a non-public address…);
155:      throw new Error(NOT_PUBLIC);

That includes the wider leak this PR identified — the uncaught dns.lookup rejection propagating getaddrinfo ENOTFOUND <host> — which is caught at line 146.

The coverage gap is also closed. apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts exists on main and asserts the same load-bearing properties this PR argues for, including indistinguishability rather than mere staticness:

  • expect(transient).toBe(empty) — resolver failure vs. zero results
  • expect(present).toBe(absent) — "does not reveal whether a probed internal name exists"
  • expect(message).not.toContain('10.0.3.14') plus consoleError … toContain('10.0.3.14') — operator signal preserved
  • a public-host control

Remaining delta, as far as I can tell, is design rather than security: this PR adds a typed SsrfGuardError carrying a structured reason, exports SSRF_REJECTION_MESSAGE, and additionally uniforms the four pre-DNS messages (Invalid URL, Blocked URL scheme: …, Blocked host, Blocked private IP literal). main keeps those four distinct, and its in-file comment argues that is deliberate — they describe caller-supplied syntax and confirm no name, so they are not an oracle. That reasoning looks right to me.

Per MERGE_POLICY.md gate 6, flagging for reconciliation rather than closing: the security substance has landed, so the open question is only whether the typed-error ergonomics are worth a rebase. If not, this closes with a pointer to #1381. Not closing unilaterally — that call is yours on a security PR.

If you do want it rebased, the conflict is confined to the two hunks above.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Reconciliation: this PR's security outcome already shipped in #1381

Automated PR sweep. This PR is mergeable_state: dirtyapps/web/src/lib/ssrf-guard.ts and apps/web/src/lib/transcription-service.ts both conflict with main. I did not resolve the conflict, because resolving it means choosing between two designs for a fix that has already landed, and that is a gate 6 reconciliation call rather than a merge conflict.

What changed underneath this PR

The body states "No file overlap#1381 changes neither file." That was true when written, but #1381 grew before merging. It is the only commit touching either file since this branch's base (5934cbf):

$ git log --oneline 5934cbf..origin/main -- apps/web/src/lib/ssrf-guard.ts apps/web/src/lib/transcription-service.ts
feae3d3 fix(web): stop leaking upstream and Stripe error details to clients (#1381)

The stated vulnerability is closed on main today

Read from origin/main, not inferred:

This PR's claim State on main after #1381
dns.lookup rejection propagates getaddrinfo ENOTFOUND <host> Caught. try/catch around dns.lookup, logs err, throws the constant
"does not resolve" vs "resolves to a private address" is an oracle Collapsed. Resolver failure, zero results, and private result all throw one NOT_PUBLIC literal
The cause must survive server-side Preserved. console.error on both the resolver and private-address branches
The route returns the guard message verbatim No longer. transcription-service.ts:287 returns a fixed 'Rejected audioUrl'

So the caller-facing oracle this PR exists to close is already closed.

What this PR would still add, and its reachability

The residual delta is a refactor: the SsrfGuardError class carrying a diagnostic reason, and a uniform message across all six guard paths. On main, four paths still throw distinguishable text — Invalid URL, Blocked URL scheme: <protocol>, Blocked host, Blocked private IP literal.

That is not currently reachable by a client. assertPublicHttpUrl has exactly two importers:

  • transcription-service.ts — the live caller, and it returns a constant regardless of which message the guard threw.
  • transcription-service-improved.ts — imports the symbol but has 0 call sites, and the module itself is imported nowhere. Dead code.

Suggested disposition — a human call, not one I should make

  1. Close as superseded by fix(web): stop leaking upstream and Stripe error details to clients #1381, if the structured-error refactor is not wanted on its own; or
  2. Re-cut against current main as a small, honestly-scoped refactor PR — "give the SSRF guard a structured error type" — keeping fix(web): stop leaking upstream and Stripe error details to clients #1381's behaviour and adding the reason field plus uniform messages for the four remaining paths. There is a real defense-in-depth argument for it: today's safety depends on the single caller collapsing messages, and a future caller that forwards err.message would reopen the oracle.

What would be wrong is resolving the conflict as-is, which would re-apply a competing implementation of a shipped fix and risk regressing #1381's behaviour.

No CI was re-run and nothing was pushed to this branch.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

main landed an equivalent fix while this sat in draft — needs a reconciliation decision, not a merge

This PR is now conflicted (mergeable_state: dirty), and the conflict is not incidental: main and this branch fix the same CWE-209 oracle with two different designs. I resolved the merge locally to see what was actually in dispute, then abandoned it, because completing it means choosing a security posture — a call for a human, not an unattended run.

The reported leak is already closed on main

main now has all of:

  • ssrf-guard.ts — all four DNS outcomes (resolver rejection, transient failure, zero results, private result) collapsed to one NOT_PUBLIC constant, real cause to console.error;
  • transcription-service.ts — the catch returns a flat 'Rejected audioUrl' and never forwards guardErr.message;
  • apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts — regression coverage.

I checked every live caller rather than assuming one:

$ git grep -n "assertPublicHttpUrl" origin/main -- apps/ | grep -v __tests__
apps/web/src/lib/transcription-service-improved.ts:7   (imports, never calls)
apps/web/src/lib/transcription-service.ts:8            (import)
apps/web/src/lib/transcription-service.ts:274          (the only live call site)

One live call site, and it discards the guard's message. So on observable behaviour the two implementations are equivalent: every rejection reaches the client as one constant string either way. This PR's stated outcome is already true on main.

What is genuinely still open

The designs differ in where uniformity is enforced, and that difference is not cosmetic:

  • main enforces it at the call site. The guard itself still throws descriptive messages on the three non-DNS branches — `Not a parseable URL: ${input}`, `Blocked host: ${host}`, `Blocked private IP literal: ${host}` — and its comment argues deliberately that the IP-literal case discloses nothing the caller did not already supply.
  • This PR enforces it in the error type. SsrfGuardError(reason) passes a constant to super() and keeps the detail on .reason, so all six paths are uniform by construction.

The live difference is what happens when the next caller is added. transcription-service-improved.ts already imports the guard; the day it starts calling it and forwards err.message, main leaks again on the non-DNS branches, and this branch cannot. That is a real defence-in-depth argument for the design here — it is just not the vulnerability the PR was opened to fix, and it is no longer urgent.

Recommendation

A human picks one, per gate 6 of MERGE_POLICY.md:

  1. Close as superseded — the reported leak is fixed and covered on main. Cheapest, loses the safe-by-construction property.
  2. Re-scope to the hardening only — drop everything that duplicates main, keep SsrfGuardError + SSRF_REJECTION_MESSAGE, rebase onto current main, and retarget the description at "the guard cannot leak regardless of call site". Retains the durable value; the diff becomes small.

I did not resolve the conflict or push, since either route changes what this PR claims to be.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Blocked on reconciliation, not on CI. main shipped a fix for the same oracle while this PR was in draft, so mergeable_state is dirty and the conflict cannot be resolved mechanically — resolving it means choosing which implementation survives.

I stopped rather than pick, because the choice rewrites security tests that merged minutes ago.

The overlap. Both close #1427; they differ in blast radius.

main (shipped) This PR
DNS paths (reject / transient / empty / private) uniform Host does not resolve to a public address uniform SSRF_REJECTION_MESSAGE
Scheme, blocked-host, IP-literal paths keep their specific messages, deliberately — the caller supplied the value, so echoing it confirms no name also uniform
Diagnostics console.error at the throw site structured SsrfGuardError.reason
Tests ssrf-guard-dns-leakage.test.ts, transcription-error-disclosure.test.ts __tests__/ssrf-guard.test.ts (7 cases)

This PR is a strict superset in uniformity and carries better-structured diagnostics. But the vulnerability itself is already closed on main — the DNS oracle was the sharp edge, and it is gone. What remains here is a quality delta, not an open exposure.

Why I did not just resolve it. Adopting this implementation changes the thrown message, and main hard-asserts the old literal in two places:

  • ssrf-guard-dns-leakage.test.ts:35,66,80
  • transcription-error-disclosure.test.ts:41

So the merge is not "fix the conflict markers" — it is "replace a just-shipped security implementation and rewrite its tests." That is gate 6 reconciliation, and picking a winner unattended is the wrong call.

The two options, either of which I can execute on request:

  1. This PR wins. Merge main, keep SsrfGuardError, update the two test files above to assert SSRF_REJECTION_MESSAGE, and fold main's comment on why the DNS oracle is the sharpest case into the new code — adapting the IP-literal paragraph, which stops being true here.
  2. main wins. Close this PR, and salvage the 7 tests as a follow-up — they are the first real coverage ssrf-guard.ts has had and are worth keeping either way.

I'd lean 2 + salvage: the exposure is closed, and option 1 spends a rewrite of live security tests to buy uniformity on three paths that main argues, credibly, do not leak.

Note also that agent-completion/truth-gate is red on this head at 32632fd. That is stale — the gate was retired in #1431 as unsatisfiable, and MERGE_POLICY.md records it as such. Every other check was green.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

HALTED(merge_conflict) — but the conflict is not mechanical, and the PR's premise no longer holds

This PR is now mergeable_state: dirty. Both conflicted files are apps/web/src/lib/ssrf-guard.ts and apps/web/src/lib/transcription-service.ts.

The body states:

Sibling to #1381No file overlap#1381 changes neither file.

That was true when this was opened. It is not true now: #1381 merged as feae3d3 and changed both of them. Merge base is 5934cbf; git log 5934cbf..origin/main -- ssrf-guard.ts transcription-service.ts returns exactly one commit, feae3d3.

#1381 already landed this PR's stated outcome

Including the finding this PR leads with — that the leak was wider than the two literals because dns.lookup rejects rather than resolving empty. feae3d3 wraps the lookup, collapses resolver-rejection / transient / empty / private into one Host does not resolve to a public address, and logs the cause for operators. It also shipped 126 lines of ssrf-guard-dns-leakage.test.ts asserting precisely the indistinguishability property this PR calls its load-bearing assertion, plus 143 lines of transcription-error-disclosure.test.ts.

So the Scope note that __tests__/ssrf-guard.test.ts is "the first coverage this module has had" is also stale.

Resolving the conflict in this PR's favour would regress main

Not a merge-text problem — a behavioural one. This branch's guard carries the cause out in SsrfGuardError.reason and logs at the call site; feae3d3's guard logs inside the guard. ssrf-guard-dns-leakage.test.ts calls assertPublicHttpUrl directly with only dns mocked, and asserts:

expect(JSON.stringify(consoleError.mock.calls)).toContain('ENOTFOUND');

Under this branch's guard nothing logs there, so that fails. Two more break on the literal, which this branch changes to SSRF_REJECTION_MESSAGE:

File Breaks on
ssrf-guard-dns-leakage.test.ts in-guard console.error; toThrow('Host does not resolve to a public address')
transcription-error-disclosure.test.ts its enumerated list of "the real messages assertPublicHttpUrl throws"

What is actually left that is worth landing

One thing, and it is real: uniformity across all six rejection paths. main still throws distinguishable Invalid URL, Blocked URL scheme: <protocol>, Blocked host, Blocked private IP literalfeae3d3 only uniformed the DNS branch. That is latent rather than live today (the only live caller returns a bare constant, and transcription-service-improved.ts imports the guard but never calls it), so it is defense-in-depth against a future call site that forwards err.message — which is exactly how this bug class arrived the first time.

The SsrfGuardError + reason shape is a good way to get that, and strictly better than message-matching.

Recommended rescope

Rebase onto feae3d3 and keep only the typed-error layer:

  1. Keep SsrfGuardError / SSRF_REJECTION_MESSAGE; set SSRF_REJECTION_MESSAGE = 'Host does not resolve to a public address' so main's existing assertions keep passing, or update those two files deliberately in the same commit.
  2. Keep the in-guard console.error from feae3d3 and the reason field — they are complementary, and dropping the former is what breaks the direct-call test.
  3. Convert the four remaining literals to SsrfGuardError — the genuinely new coverage.
  4. Drop the DNS-branch rewrite and __tests__/ssrf-guard.test.ts cases already covered by ssrf-guard-dns-leakage.test.ts; keep only the ones exercising the four non-DNS paths.
  5. Refresh the Verification section — npm test counts and the "first coverage" claim predate feae3d3.

Not pushing the resolution: this session is scoped to its own branch, and more to the point the merge needs the judgement call in step 1 rather than a mechanical resolution. Closing in favour of a follow-up is equally reasonable, given how much of the diff is now redundant — that is a maintainer call.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Conflict gate: this is a gate 6 overlap, not a rebase

mergeable_state is dirty. I reproduced the merge locally — it conflicts in apps/web/src/lib/ssrf-guard.ts and apps/web/src/lib/transcription-service.ts. I have not pushed a resolution, because merging it is the wrong move.

The premise in this description is false. Both this PR and #1381 state "No file overlap — #1381 changes neither file." #1381 changed both:

$ git log --oneline origin/main -- apps/web/src/lib/ssrf-guard.ts
feae3d3 fix(web): stop leaking upstream and Stripe error details to clients (#1381)

#1381 merged as feae3d3, so the vulnerability this PR fixes is already fixed on main — independently, in the same file. The conflict is two implementations of one fix, which is MERGE_POLICY.md gate 6, not a freshness problem.

What main already has

assertPublicHttpUrl on main collapses all four DNS outcomes into one constant and logs the real reason for operators:

Path main (feae3d3)
resolver rejection (ENOTFOUND/EAI_AGAIN) console.error, throw Host does not resolve to a public address
zero results same constant
resolves to private address console.error with host -> address, same constant
blocked hostname Blocked host — hostname no longer interpolated
private IP literal Blocked private IP literal — IP no longer interpolated

It also ships tests: apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts, 5 cases, including the load-bearing one this PR calls out — expect(transient).toBe(empty) — plus assertions that getaddrinfo/ENOTFOUND/the hostname are absent from the thrown message while still reaching console.error.

So the security outcome, the operator-diagnostics outcome, and the indistinguishability test are all already on main.

What is actually still unique here

Not the fix — the shape:

  1. SsrfGuardError carrying a structured reason field, versus main's console.error-at-throw-site.
  2. A second test file (ssrf-guard.test.ts, 7 cases) that would overlap ssrf-guard-dns-leakage.test.ts.

That is a refactor of a security guard, not a vulnerability fix, and it should be judged as one.

Recommendation

Under gate 6 the fresher PR wins by default, but #1381 already merged — so the tie is broken by facts on the ground, not by the deadline. Recommend closing this as superseded by #1381. If the typed-reason design is wanted, it is worth reopening as a small refactor PR against current main, where the diff is honest about being a refactor and does not duplicate existing coverage.

I am not closing it — that is your call, and the reason design is a legitimate thing to want.

The generalisable point: both PRs asserted disjoint scope against each other and both were wrong, which is a gate 5 (provenance) miss on #1381's declared scope of apps/web/src/app/api/**. A git log --oneline main -- <file> per claimed-disjoint file would have caught it before either merged.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

HALTED(superseded)#1381 landed this fix; don't hand-resolve the conflict yet

mergeable_state is dirty, with real content conflicts in both source files. Before resolving it, the conflict is worth reading rather than fixing: #1381 merged as feae3d3 and already implemented this change on main.

The "no file overlap" premise no longer holds

This PR's description states:

Sibling to #1381No file overlap#1381 changes neither file.

That was true when written. It is not true of what merged:

$ git log origin/main --oneline -2 -- apps/web/src/lib/ssrf-guard.ts apps/web/src/lib/transcription-service.ts
feae3d3 fix(web): stop leaking upstream and Stripe error details to clients (#1381)
6847a1f perf: offload transcript download cleanup to a worker thread (#1245)

feae3d3 touches both files this PR owns. That is the whole conflict — two independent fixes for #1427's vulnerability, not a textual accident.

What main already has

ssrf-guard.ts on main collapses all four DNS outcomes into one constant and catches the resolver rejection — the exact defect this PR describes:

const NOT_PUBLIC = 'Host does not resolve to a public address';
let resolved: LookupAddress[];
try {
  resolved = await dns.lookup(host, { all: true });
} catch (err) {
  console.error('[ssrf-guard] DNS lookup failed:', err);
  throw new Error(NOT_PUBLIC);          // ENOTFOUND no longer propagates
}
if (resolved.length === 0) throw new Error(NOT_PUBLIC);
for (const r of resolved) {
  if (ipIsPrivate(r.address)) {
    console.error(`[ssrf-guard] host resolved to a non-public address: ${host} -> ${r.address}`);
    throw new Error(NOT_PUBLIC);
  }
}

The call site is hardened too — transcription-service.ts:284 logs the full guardErr and returns a bare 'Rejected audioUrl', with no interpolation. And main carries apps/web/src/lib/__tests__/ssrf-guard-dns-leakage.test.ts (5 cases) covering resolver-text suppression, transient-vs-permanent indistinguishability, private-host blocking, existence non-disclosure, and a public-host control.

So the CWE-209 objective of #1427 is met on main today.

One deliberate design difference — merging this as-is would revert it

This PR converts six rejection paths to the uniform message, including the IP-literal branch. main keeps that one distinct, on purpose, with the rationale written down:

The IP-literal branch above keeps its own message deliberately: the caller supplied the address, so telling them it is private reveals nothing they did not already know, and no name is confirmed or denied.

That reasoning is sound — there is no oracle when the caller already holds the address. Resolving the conflict in this PR's favour would silently undo it.

The residual delta is two tests, and both already pass

Comparing case-by-case, this PR's suite has two cases main does not cover. Both are detection tests, not oracle tests:

Case On main?
rejects a private address behind an IPv4-mapped IPv6 spelling not tested
rejects when any resolved address is private, even if another is public not tested

I checked whether either is a live bug by extracting ipIsPrivate / ipv6ToHextets from main and running them directly (they depend only on node:net):

PASS  ipIsPrivate(::ffff:10.0.0.1                 )=true   IPv4-mapped IPv6 private (compressed)
PASS  ipIsPrivate(0:0:0:0:0:ffff:0a00:0001        )=true   IPv4-mapped IPv6 private (expanded)
PASS  ipIsPrivate(::ffff:127.0.0.1                )=true   IPv4-mapped loopback (dotted)
PASS  ipIsPrivate(10.0.0.1                        )=true   plain private v4
PASS  ipIsPrivate(93.184.216.34                   )=false  public v4
PASS  ipIsPrivate(2606:2800:220:1:248:1893:25c8:1946)=false public v6

main handles every spelling (the mapped/compatible decode at ssrf-guard.ts:49–56), and the for (const r of resolved) loop covers the mixed public/private case. So these are regression guards for behaviour that is already correct, not bug-finders. Worth having; not worth a conflicted merge of a superseded diff.

Staged options — a disposition call, not more investigation

  1. Close as superseded, and port just the two detection cases into the existing ssrf-guard-dns-leakage.test.ts in a small follow-up. My recommendation: it keeps main's deliberate IP-literal decision and avoids re-litigating a merged fix.
  2. Reduce this PR to the two tests — drop both source-file changes, keep __tests__ only. The conflict disappears, since main has no competing test file of that name.

I have not resolved the conflict, closed the PR, or pushed to claude/clever-heisenberg-eh4att — the branch is not mine, and option 1 versus 2 is a judgment call about #1427's scope rather than a mechanical fix.

Verification note: the supersession and test-gap claims above are read from origin/main at 8c93de8 and from this head 32632fd. I did not run the web suite — node_modules is absent in this sandbox — so the ipIsPrivate results come from the extracted helpers, not from vitest.


Generated by Claude Code

A parallel session fixed this same DNS oracle and landed it on main while
this branch was in review. The conflict is resolved entirely in favour of
main — `ssrf-guard.ts` and `transcription-service.ts` are now byte-identical
to origin/main, and nothing from this branch's implementation survives.

Main's design is the better of the two and this branch's would have been a
regression to force through:

  - It keeps informative messages inside the guard and collapses them to one
    fixed string at the call site, next to the HTTP response. This branch
    uniformed every message inside the guard, which loses detail operators
    can use and buys nothing the call-site collapse doesn't already provide.
  - It deliberately leaves the IP-literal and scheme branches distinguishable,
    on the reasoning that the caller supplied that input, so naming the rule
    confirms nothing and denies nothing. This branch suppressed those too,
    which is over-suppression rather than defence.

What survives is the part main does not cover. Its tests assert what a
rejection *says*; none exercise which resolved answers are rejected at all.
`ssrf-guard-private-address-detection.test.ts` adds three cases:

  - loopback behind a fully-expanded IPv4-mapped IPv6 answer
    (`0:0:0:0:0:ffff:7f00:1`), which `net.isIPv4` does not match and only
    the hextet expansion catches
  - a genuine public IPv6 answer still allowed, so the above cannot be
    satisfied by a guard that just rejects all IPv6
  - a private address in a multi-answer response where a public one sorts
    first, i.e. every answer is checked rather than `resolved[0]`

This gap matters more after the leakage fix than before it: every non-public
outcome now reports one identical message, so a detection regression is no
longer distinguishable from a working rejection.

Both new detection cases verified non-vacuous by mutation rather than by
inspection:

  - restricting the loop to `resolved.slice(0, 1)` fails only the
    multi-answer case
  - replacing the IPv4-mapped decode with `return false` fails only the
    mapped-loopback case
  - main's five existing tests pass against BOTH mutants, which is the
    direct evidence that this coverage is additive and not duplicated

  npx tsc --noEmit   clean
  npm run lint       clean
  npm test           326 passed (56 files), zero failures

The `billing-chat-gating` timeout previously reported on this branch is gone;
#1230 fixed it on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ng3VUMYcPDELaNpKPgd83e
@groupthinking
groupthinking merged commit 75ef3ca into main Aug 7, 2026
4 of 5 checks passed
@groupthinking
groupthinking deleted the claude/clever-heisenberg-eh4att branch August 7, 2026 20:57

Copy link
Copy Markdown
Owner Author

Conflict gate → HALTED(superseded) — recommend closing rather than merging

Ran the runbook's conflict gate on this PR (mergeable_state: dirty). Resolving the conflict is possible, but it should not be resolved: main already closes #1427, and merging this PR as-is regresses seven tests that are green on main today.

Measured, not inferred. Merged origin/main (8c93de8) into 32632fd, resolved both conflicts in this PR's favour, and ran the three affected suites:

Suite Result
ssrf-guard.test.ts (this PR's 7 new tests) 7 passed
ssrf-guard-dns-leakage.test.ts (on main) 3 failed, 2 passed
transcription-error-disclosure.test.ts (on main) 4 failed, 0 passed

The two failure classes are not equivalent

ssrf-guard-dns-leakage.test.ts — 3 failures, cosmetic. All three assert the literal 'Host does not resolve to a public address'; this PR renames it to SSRF_REJECTION_MESSAGE. The two tests that assert indistinguishability rather than a literal (does not reveal whether a probed internal name exists, still allows a genuinely public host) both pass. So the security property survives the rename — only the text assertions break.

transcription-error-disclosure.test.ts — 4 failures, a real behavioural regression. This PR branched before main's CWE-209 hardening of transcription-service.ts, so its copy of that file is strictly behind. Three separate disclosure protections are lost:

× does not echo the status of a caller-supplied audioUrl
× reports the same status-free message whatever the upstream status is
      expected 4 distinct messages to be 1
× does not reveal which SSRF guard rule rejected the audioUrl
      expected 'Could not transcribe video — all strategies failed'
            to be 'Rejected audioUrl'
× does not disclose whether provider API keys are configured
      received 'No AI API key configured. Set OPENAI_API_KEY or
                GEMINI_API_KEY in Vercel environment variables.'

That last one is the sharpest: merging this PR would restore an unauthenticated oracle over whether provider keys are configured — a leak unrelated to, and wider than, the one this PR set out to fix.

#1427 is already satisfied on main

main closes the DNS oracle with the same end-to-end property, layered differently: the guard collapses the four DNS outcomes into one NOT_PUBLIC message, and fetchTranscript collapses all five guard rejections into the constant 'Rejected audioUrl', logging the real cause via console.error for operators. transcription-error-disclosure.test.ts pins exactly that — expect(new Set(errors).size).toBe(1) across the full GUARD_REJECTIONS list.

Against #1427's acceptance criteria: 2, 3, 4 and 5 are met on main. Criterion 1 ("every rejection from assertPublicHttpUrl presents a byte-identical message") is met end-to-end but not at the guard boundarymain keeps Invalid URL / Blocked host / Blocked private IP literal distinct, deliberately, on the documented reasoning that those name a caller-supplied input rather than confirming an internal name exists. The threat #1427 describes — enumerating internal DNS through unauthenticated /api/transcribe — is fully closed.

What this PR still has that main does not

Only a refactor: SsrfGuardError carries a structured reason field instead of main's string-plus-console.error. That is a genuine readability improvement, but it delivers no additional security, and landing it means rewriting three passing assertions on main to accommodate it. Rewriting green tests to fit a no-security-delta refactor is the wrong trade here.

Recommendation

  1. Close this PR as superseded by the ssrf-guard.ts / transcription-service.ts changes already on main. Its 7 tests are good, but 4 of the 7 duplicate coverage ssrf-guard-dns-leakage.test.ts already provides; the 3 genuinely new ones (IPv4-mapped IPv6 spelling, mixed public/private resolution sets, all-causes uniformity) are worth cherry-picking into that file as a small follow-up if you want them.
  2. Close fix(security): SSRF guard leaks a DNS oracle through /api/transcribe (CWE-209) #1427, or narrow it to the guard-boundary uniformity in criterion 1 as a low-priority hardening item, since the exploitable path it documents is closed.

Not closing either myself — that is a human call, and the PR is yours. No commits pushed to this branch; the merge was performed in a scratch branch and discarded.

Terminal state: HALTED(superseded). Staged next command, if you agree:

gh pr close 1428 --repo groupthinking/EventRelay \
  --comment "Superseded by the CWE-209 hardening already on main; see the conflict-gate analysis above."

Generated by Claude Code

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GRV-381

@groupthinking groupthinking changed the title fix(security): close the SSRF guard's DNS oracle (CWE-209) test(security): cover SSRF private-address detection main's tests miss Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Closing: superseded by #1381, with the one part that wasn't superseded salvaged into #1483.

The security outcome already landed. #1381 merged as feae3d3 and rewrote both files this PR owns. main now collapses every DNS-path rejection into a single literal and logs the real cause:

const NOT_PUBLIC = 'Host does not resolve to a public address';
...
console.error('[ssrf-guard] DNS lookup failed:', err);
throw new Error(NOT_PUBLIC);
if (resolved.length === 0) throw new Error(NOT_PUBLIC);

The canonical issue #1427 was closed as completed on that basis.

One correction to this PR's description, since it's load-bearing for the "no competing PR" checkbox: it states "#1381 owns the route handlers in apps/web/src/app/api/**; this PR owns apps/web/src/lib/ssrf-guard.tsNo file overlap#1381 changes neither file." That was accurate when written, but #1381 as merged changes both files. That overlap is exactly why this branch is now dirtygit merge origin/main conflicts in ssrf-guard.ts and transcription-service.ts, both times between two implementations of the same fix.

main is also strictly broader at the call site. This PR unified the guard's own messages; main's transcription-service.ts additionally flattens all five rejection paths to a constant 'Rejected audioUrl' at the boundary, so Invalid URL, Blocked URL scheme, Blocked host and Blocked private IP literal are covered too — including the IP-literal branch this PR deliberately left distinguishable.

What was worth keeping. Two of the seven tests here cover detection cases that main has no coverage for — ssrf-guard-dns-leakage.test.ts only tests what a rejection discloses, never whether the guard rejects at all:

  • a loopback address in expanded IPv4-mapped IPv6 form (0:0:0:0:0:ffff:7f00:1)
  • a private address alongside a public one in a multi-record answer

I ported both to the merged API in #1483 and confirmed main already handles them correctly — so these are regression locks, not bug reports, and there is no live exposure. I verified they aren't vacuous by mutation: replacing the mapped-IPv4 decode with return false fails only the first (1 failed, 3 passed); inspecting only resolved[0] fails only the multi-answer cases (2 failed, 2 passed).

Not carried over: the SsrfGuardError / SSRF_REJECTION_MESSAGE refactor. It's a better call-site contract than console.error + plain Error, but it now refactors already-secure code, so it belongs in its own PR if you want it.

Nothing here needs re-doing — resolving the conflict would mean re-implementing what feae3d3 already ships.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Merged main, reconciled with #1381 — and one defect the merge exposed

Automated PR-remediation pass. This PR was dirty against main; resolved in 5fc4fe0. Two things need your eyes, because they change the PR's stated premise.

1. The "no file overlap" claim is stale

The body says #1381 "changes neither file". It does — #1381 merged as feae3d3 and touched both ssrf-guard.ts and transcription-service.ts, which is the entire conflict.

main already collapses the four DNS outcomes onto one literal. What it does not do is cover the other branches: #1381 deliberately left Invalid URL, Blocked URL scheme, Blocked host, and Blocked private IP literal distinguishable at the guard, on the reasoning that each call site sanitizes. Its own comment says so — "the guard throws five distinguishable messages".

So this PR is not redundant the way #1434 was against #1431. The residual value is real: SsrfGuardError moves uniformity into the guard, so a call site cannot reintroduce the oracle by forwarding err.message, and it brings the first test coverage this module has had. I resolved toward that design and recorded the supersession in a comment where #1381's carve-out used to be.

2. guardErr instanceof SsrfGuardError broke the error path

Worth flagging on its own — this is not a merge artifact, and it is not just a testability nit.

The call site's guardErr instanceof SsrfGuardError degrades wherever the module identity differs. transcription-error-disclosure.test.ts (from #1381) mocks @/lib/ssrf-guard with only assertPublicHttpUrl, so SsrfGuardError is undefined and x instanceof undefined throws — the clean guard rejection fell through to the catch-all, and the caller got Could not transcribe video — all strategies failed instead of Rejected audioUrl. The same failure mode applies to dual-package/ESM-CJS interop, not only to mocks.

Logging the error object instead is equivalent for operators, since reason is an own enumerable property, and carries no identity coupling.

What I preserved from each side

Kept
This PR SsrfGuardError, SSRF_REJECTION_MESSAGE, all six branches uniform, ssrf-guard.test.ts
#1381 Guard-level console.error on the DNS branches (its tests pin this), the constant 'Rejected audioUrl', and its two unrelated CWE-209 fixes (upstream status, provider-key branch)

One test change you should sanity-check: ssrf-guard-dns-leakage.test.ts asserted the literal 'Host does not resolve to a public address' in three places. That literal cannot survive this PR's premise, so I repointed those to SSRF_REJECTION_MESSAGE. Every assertion's intent is unchanged — indistinguishability across outcomes, no address or resolver text in the message, operators still get the detail. Only the literal moved. Flagging it because rewriting a merged security test is your call, not mine.

Verification at 5fc4fe0

The agent-completion/truth-gate failure on the old head should moot itself: #1431 deleted that workflow from main, and this branch now contains it.

Terminal state: HALTED(awaiting_merge_approval) — CI re-running on the new head, and I do not auto-merge to a protected branch. Staged next command:

gh pr merge 1428 --squash

Generated by Claude Code

groupthinking pushed a commit that referenced this pull request Aug 7, 2026
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
groupthinking pushed a commit that referenced this pull request Aug 7, 2026
This branch was auto-deleted when #1428 merged. An automated remediation
pass recreated it by pushing 5fc4fe0, a conflict resolution computed against
the pre-rescope head (32632fd) and therefore unaware that #1428 had already
been rescoped to test-only and merged as 07a0ab2.

5fc4fe0 would have reverted merged work: it reintroduced SsrfGuardError,
reverted ssrf-guard.ts and transcription-service.ts away from main, and
deleted ssrf-guard-private-address-detection.test.ts — the 76 lines #1428
actually contributed. Main's design was chosen deliberately (the IP-literal
and scheme branches stay distinguishable because the caller supplied that
input); that decision stands.

This commit restores the tree to exactly 07a0ab2, the state that merged.
Force-push and branch deletion are both 403 for this token, so the fix is a
forward commit rather than a rewind. Nothing here reaches main — #1428 is
already merged.

Copy link
Copy Markdown
Owner Author

Correction — withdraw my previous comment

My comment above was computed against the pre-rescope head 32632fd and posted after this PR had already merged as 07a0ab2. It is wrong on the substance, and I'm retracting it.

What I got wrong. I argued the SsrfGuardError design should supersede main's, on the grounds that uniforming every branch inside the guard prevents a call site reintroducing the oracle. Your rescoped description answers that directly: the IP-literal and scheme branches are distinguishable by design, because the caller supplied that input, so naming the rule confirms nothing and denies nothing. Suppressing those is over-suppression, and it discards detail operators can use while buying nothing the call-site collapse doesn't already provide. That reasoning is right and mine was not.

The one thing in my comment that stands on its own is narrow: the "no file overlap" line in the original description was stale, since #1381 did touch both files. Your rescope already accounts for that.

What I did to the branch, and how it's now resolved. This branch was auto-deleted on merge; my push recreated it with 5fc4fe0, a conflict resolution that would have reverted merged work — reintroducing SsrfGuardError, moving ssrf-guard.ts and transcription-service.ts off main, and deleting ssrf-guard-private-address-detection.test.ts, the 76 lines this PR actually contributed.

main was never affected: this PR was already merged, so nothing could land from it. I've pushed cec9453, whose tree is byte-identical to 07a0ab2, so the branch again matches exactly what merged. Force-push and branch deletion both return 403 for this token, which is why the fix is a forward commit rather than a rewind — the branch would ideally just be deleted, and that needs someone with write scope.

No action needed on the code. Flagging the leftover branch only so it can be deleted.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Correction: my previous comment was wrong on both counts

I posted that comment believing this PR was open, conflicted, and abandoned. It had already merged, and the comment misdescribes what happened. Correcting it rather than editing it away.

What I got wrong:

  1. "Closing:" — I didn't close anything. This PR was merged_at 20:57:02 as 75ef3ca; my state: closed call two minutes later was a no-op against a PR already closed-as-merged. closed_at and merged_at are the same timestamp.
  2. "with the one part that wasn't superseded salvaged into test(web): cover SSRF guard resolution bypasses salvaged from #1428 #1483" — backwards. A parallel session had already reworked this PR into exactly that salvage: conflict resolved wholly in favour of main, all implementation dropped, retitled, reduced to ssrf-guard-private-address-detection.test.ts (+76). It merged carrying the two cases I claimed to be rescuing.

What stands: the diagnosis itself — that the CWE-209 fix landed via #1381, that this branch's implementation was superseded, and that its detection cases were the salvageable part. That was right. What I missed is that this PR had already acted on it, 5 minutes before I read it, while I was working from a stale fetch.

The consequence is mine to clean up. #1483 was created at 20:57:54 — 52 seconds after this merged — with 75ef3ca as its base, so it re-derived two tests that were already in its own base commit:

Case Merged here Duplicated in #1483
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 (control) control variant

I'm closing #1483 and moving its only genuine delta — a dns.lookup call assertion, and a three-address case with the private one in tail position — to #1486, which is still open and touches this area.

The lesson worth recording for the next sweep: I re-read main but not the PR itself before acting. On a repo merging this fast, a five-minute-old fetch is stale enough to invert a conclusion.


Generated by Claude Code

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

fix(security): SSRF guard leaks a DNS oracle through /api/transcribe (CWE-209)

2 participants