fix(recapture): refuse a redirect to a loopback or private address#1887

merged2
main
fix/1726-refuse-private-redirects
opened12d agobyMMatt Donovan
View on GitHubTwo-way sync enabledPR last imported 9d ago

Closes #1726

Summary

A background capture no longer renders or stores a page when its link, or a redirect on the way, lands on the name localhost or on an address of the Operator's own machine or private network written as numbers. The hidden browser window stops each such redirect before any request goes to the address, and the capture queue refuses a link that points at one and checks where the render ended before anything is stored. A site name that resolves to a private address, and a frame embedded in the page, are not checked yet. The Operator sees the capture as refused with the kind of address named, by the address rules the stored-page viewer already applies to its Capture link, now shared by both. A switch that only an unpackaged build reads lets the automated tests keep rendering pages served from the test machine itself.

Changes

Files changed, grouped by purpose
  • src/shared/localAddress.ts (new): the loopback, private, link-local, unique-local, shared, broadcast, multicast, IPv4-compatible and NAT64 classification moved out of guestLink.ts unchanged, now returning a kind (localAddressKind) and its wording (localAddressLabel). Only literal addresses are classified; a public name that resolves to a private address is not caught, as before in the viewer.
  • src/renderer/components/captures/guestLink.ts: captureLinkBlockReason reads the shared classification; its messages are unchanged.
  • src/main/services/localAddressPolicy.ts (new): refusedAddressClass(url) applies the classification for background capture, with the test-only switch BIRDBRAIN_E2E_ALLOW_LOOPBACK=1, honoured only when app.isPackaged is false and only for localhost and loopback addresses; RefusedAddressError carries the refusal.
  • src/main/services/backgroundRenderer.ts: will-redirect and will-navigate handlers check each main-frame hop, call preventDefault() on a refused one, and end the render through a third promise in the existing deadline race, so the window is destroyed and the temp file removed on the same path as a timeout.
  • src/main/services/recapture.ts: validateUrl rejects a link that points at a local address (Points at a loopback address); runJob refuses a render whose final URL is a local address before ingest, and reports a RefusedAddressError as a skipped event with the reason (Redirects to a private address). Both events name the job's own URL, so they replace its in-flight entry. The excluded-host check on the final URL still runs after it.
  • e2e/recapture-local-redirect.spec.ts (new): a 302 to 10.255.255.1 and a page that sets location.href to 192.168.255.1 both end skipped with the reason and store nothing; a redirect between loopback paths is followed and stored; without the switch, 127.0.0.1 is rejected at enqueue.
  • e2e/recapture.spec.ts, e2e/recapture-permissions.spec.ts, e2e/recapture-consent.spec.ts: set the switch before launch, since their fixtures are served on 127.0.0.1.
  • Tests: tests/shared/localAddress.test.ts (each kind, public hosts, non-URLs); tests/main/services/localAddressPolicy.test.ts (a packaged build refuses 127.0.0.1 with the switch set; an unpackaged build with the switch admits loopback only); tests/main/services/backgroundRenderer.test.ts (refused will-redirect and will-navigate hops are prevented and destroy the window; a public hop is followed); tests/main/services/recapture.test.ts (enqueue rejections name the class; a refused hop and a private final URL write no row, no file and no manifest).
  • docs/specs/2026-07-31-evidence-affecting-paths-assessment.md: blocking rows for src/shared/localAddress.ts and src/main/services/localAddressPolicy.ts.
  • src/renderer/components/captures/useLinkMenuTarget.ts: the comment on the menu's capture action no longer says the queue checks only the scheme.
  • Not changed, left for the maintainer: a host name that resolves to a local address is followed, because the issue binds the check to the viewer's literal classification; and a sub-frame or sub-resource on a public page can still load from a local address and land in the stored page, because only main-frame hops are checked.
  • Not changed, follow-up candidate: the existing excluded-host refusal after a redirect reports the final URL in its skipped event, so the job's in-flight entry in the activity feed is not replaced.

Evidence-affecting

Yes

What could change for evidence, and how that is checked

Evidence impact

  • Evidentiary result or interpretation that could change: a background capture whose link, redirect hop or final URL is a literal loopback, private, link-local, unique-local or other local address is now refused, and nothing is stored for it: no Capture row, no file, no Manifest Entry. Before this change it was rendered and stored as evidence. A Recapture of an existing Capture whose URL is a local address is now refused the same way. Captures of public addresses are stored exactly as before; no stored field or manifest format changed.
  • What verification proves and does not prove after this change: verification is unchanged and says nothing new. A refused hop stops before any request goes to the address; a refused final URL stops after the render and before ingest. Either way there is no entry to verify. The check reads literal addresses only, so a public host name that resolves to a private address is still rendered and stored, and it checks the main frame only, so an embedded frame or image from a local address can still land in a stored page.
  • Backward verification preserved (existing evidence packages still verify): yes. No verifier, manifest or package code changed, and existing Captures are untouched.
  • Known-answer test added or extended, or why none applies: the classification has a known-answer table in tests/shared/localAddress.test.ts; the e2e spec drives a real 302 and a real page navigation through Electron and asserts the refusal and that nothing was stored.

Verification

All pre-merge checks passed

pnpm preflight at 32b1033b2da096466db0b1a880515a7fb25af006 on Node v20.20.2; diff scored against origin/main (merge base 58997988d). Result: pass.

  • pnpm lint - pass (exit 0)
  • pnpm format:check - pass (exit 0)
  • pnpm lint:boundaries - pass (exit 0)
  • pnpm lint:agents-md - pass (exit 0) - CLAUDE.md and AGENTS.md match
  • pnpm lint:adr - pass (exit 0)
  • vale-prose --base - pass (exit 0) - 1 changed Markdown file checked
  • pnpm typecheck - pass (exit 0)
  • pnpm build - pass (exit 0)
  • pnpm build:extension - skipped - no extension/ changes
  • pnpm test:coverage - pass (exit 0) - 6489 passed, 17 skipped (6506), thresholds met
  • pnpm coverage:diff - pass (exit 0) - 100.00% of 67 changed lines covered (floor 90%)

Pull request description generated by Claude Code

Checks

1 failing · 1 pending · 17 successful · 10 skipped
Some checks failed

Failing1

In progress1

  • agent/pre-passGitHub
    In progress
26d7107
Synced from GitHub

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Repository: thebristolsound/birdbrain/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d7555e56-fb19-4b52-a3df-8552c07e106e

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.

Synced from GitHub

Review verdict: request changes

  1. The description's summary says a redirect can no longer bring a page from the operator's own machine or network into a case, but only addresses written as numbers in the main page are caught, so that sentence, which becomes the permanent merge record, overstates the fix.
  2. A hostile page can still redirect to a host name it controls that points at a private address, and the capture follows it and stores the result, so the maintainer needs to rule whether numbers-only checking meets the refusal ruling or belongs in a follow-up.
  3. Embedded frames are not checked at all, and a new test asserts that a frame redirecting to a private address is followed, which sits against the ruling that nothing from such an address is stored and is missing from the description's evidence section.
  4. A code comment beside the stored-page viewer's capture-link action still says the capture queue checks only the address scheme and that nothing looks at redirects, which this change made untrue.

All automated checks pass on this commit except the merge check, which waits for the maintainer's sign-off, and the local test and browser runs reproduce the description's results.

Full report

Reviewed commit: 028361afb46722f2cbe79bae7b7a91328dc78755

PR #1887 (draft, opened 2026-10-17T03:28:50Z) already exists at this head. Its live body matches /tmp/ce-1726-pr-body.md except for one trailing blank line, so I checked the controls against the live PR. Head was still 028361af when I finished. origin/main moved to 3d9ef9a9 (#1874, a signals refactor), which touches none of this branch's files.

#severityfile:linefinding
1blockingPR body, Summary sentences 1 and 2"no longer renders or stores a page on the Operator's own machine or private network when a public link redirects there ... so nothing from the address reaches the Case" is unqualified. The code classifies literal hosts only (src/shared/localAddress.ts:84-95) and main-frame hops only (src/main/services/backgroundRenderer.ts:415). The body's own Changes bullet (line 12) and Evidence impact (line 35) state the literal-only limit, so the top layer and the detail contradict each other. ADR-0018: the Summary is copied into the squash commit. Fix: say "a literal loopback or private address", and add that a host name resolving to one, or a hop inside an embedded frame, is not caught yet.
2advisory (maintainer ruling)src/shared/localAddress.ts:84-95, src/main/services/localAddressPolicy.ts:22-27The classification reads only the hostname string and does no resolution. A redirect to any name that resolves to loopback or RFC 1918 space is followed and stored. The issue's adversary is the page author, who picks the redirect target. AC1 says "resolves to" while AC2 binds the check to the viewer's literal-only classification, and the implementer followed AC2. That conflict is in the issue, so this is not an implementer defect. It does mean the ruling's "nothing from such an address is rendered or stored" holds for literal addresses only.
3advisorysrc/main/services/backgroundRenderer.ts:415, tests/main/services/backgroundRenderer.test.ts:422Sub-frame hops return early, and the new test asserts that a sub-frame will-redirect to http://192.168.1.1/ is followed. savePage(..., 'MHTML') at backgroundRenderer.ts:538 serialises every frame. The repo already guards every frame for the evidence viewers with will-frame-navigate (src/main/index.ts:380-429), so the pattern exists. The body lists this only as a "follow-up candidate" under Changes, and the Evidence impact section leaves it out. It is evidence-affecting, so under ADR-0028 it qualifies for filing rather than being left as a candidate.
4advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:16-21The comment says validateUrl "refuses only a non-http(s) scheme, so the menu's address refusals ... have no backstop in main; and neither looks at where a public URL redirects once rendered". Since this change, validateUrl refuses local addresses (src/main/services/recapture.ts:104-105) and the renderer checks each main-frame hop (backgroundRenderer.ts:476-477). Both clauses are now false.
5advisoryPR body, Evidence impact line 35"The refusal happens before acquisition" fits the per-hop refusal. It does not fit the final-URL backstop (recapture.ts:149-160), which refuses after the page was rendered and saved to a temp file, before ingest. "before ingest" would be accurate.

Failure scenario for 1 and 2. A public page answers 302 Location: http://127.0.0.1.nip.io:8080/, or redirects to any name the author points at 192.168.1.1. checkHop calls refusedAddressClass, then localAddressKind. ipv4Kind splits the hostname into 7 dot-separated parts and returns null (localAddress.ts:37-40), so the hop is followed. The final-URL backstop returns null for the same reason (recapture.ts:149), and captureLifecycle.ingest stores the page (recapture.ts:186).

Failure scenario for 3. A public page embeds <iframe src="https://public.example/f">, which answers 302 to http://192.168.1.1/. will-redirect fires with isMainFrame: false, checkHop returns at backgroundRenderer.ts:415, and the frame's document is serialised into the stored MHTML and the screenshot. Not verified: whether Chromium's own local-network-access checks block a public-to-private frame in Electron 44.7.0. No fixture here has a public origin.

Body claims checked against the source (all hold except 1 and 5 above):

  • Classification moved unchanged. ipv4Kind and ipv6Kind match the removed ipv4Class and ipv6Class line for line. The localhost and trailing-dot rule moved into localAddressKind (localAddress.ts:91-94). The viewer's messages are unchanged (guestLink.ts:59-61), and e2e/mhtml-link-interactivity.spec.ts:489 still expects "Points at a loopback address" and passes.
  • Switch: app?.isPackaged === false && env === '1' (localAddressPolicy.ts:13-15). It admits only loopback and this-computer (:25), and a mocked packaged build refuses 127.0.0.1 with the switch set (tests/main/services/localAddressPolicy.test.ts:33-37). This covers the brief's "production refuses 127.0.0.1" test. The fixture is test-scoped and passes process.env through (e2e/fixtures/electronApp.ts:62-66).
  • The renderer's third race promise reuses the timeout cleanup path (backgroundRenderer.ts:564-577). Listeners are registered before the first await (:476-477 precede :498). refused is subscribed by Promise.race, so a late reject is not unhandled.
  • A Recapture of an existing Capture goes through the same check: RECAPTURE_ENQUEUE_CAPTURES, then recaptureService.enqueue (src/main/ipcHandlers.ts:974-980), then validateUrl (recapture.ts:275).
  • The in-flight entry is replaced by url plus source (src/renderer/stores/appStore.ts:265-268). Both new skipped events use job.url (recapture.ts:155, :236). The body's note that the excluded-host event uses finalUrl is accurate (recapture.ts:178).
  • AC4: the excluded-host check still runs after the new check (recapture.ts:167-183).
  • renderPageInHiddenWindow has one wiring (src/main/index.ts:610-611). The global web-contents-created guards cover webviews only (index.ts:356-357), so the new handlers are the only ones on the hidden window.
  • The four renderer call sites surface rejected[0].reason, so an enqueue-time refusal is shown to the operator (AddUrlsBox.tsx:84, CaptureMenu.tsx:78, CaptureSelectionBar.tsx:78, useLinkMenuTarget.ts:54). The activity feed shows skipReason (CaptureHealth.tsx:94).

Evidence gate. Blocking-tier hits: src/main/services/recapture.ts and src/main/services/backgroundRenderer.ts (existing rows), plus the two new rows. PR labels are evidence-affecting and agent-authored. The issue carries evidence-affecting. The Evidence impact section is substantive apart from findings 3 and 5. The known-answer table in tests/shared/localAddress.test.ts and the refusal tests in tests/main/services/recapture.test.ts:288-360 exercise the changed decision. Backward verification: the diff touches no verifier, manifest, or package code, and the full suite, including the verify tests, passes. The brief asked for a blocking row for the shared module, which is present (assessment :209). The policy row is also needed, because no src/main/services/* glob exists.

Controls.

controlresultcommand
attribution linepass, last authored linegh api repos/thebristolsound/birdbrain/pulls/1887 --jq .body
auto-mergepass, nullsame endpoint, .auto_merge
Claude co-author trailerpass, 0 matchesgit log origin/main..028361af --format=%B, case-insensitive grep for the co-author trailer
evidence-affecting labelpass, on the PR and the issuegh api .../pulls/1887 --jq '[.labels[].name]', gh api .../issues/1726/labels
diff coveragepass, 67/67 (100.00%)pnpm coverage:diff

Verification (local, Node v20.20.2, bash tool shell; no shell scripts changed, so no bash-semantics check was needed).

  • pnpm lint: exit 0. 46 warnings, none in changed files.
  • pnpm typecheck: exit 0.
  • pnpm build: exit 0.
  • BIRDBRAIN_TEST_SYSTEM_SHARP=1 BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 pnpm test:coverage: exit 0. 400 files passed and 1 skipped; 6490 tests passed and 17 skipped (6507). Matches the body.
  • pnpm coverage:diff: exit 0, 100.00% of 67 lines. Matches the body.
  • E2E under xvfb (X11): recapture-local-redirect, recapture, recapture-permissions and recapture-consent gave 5 passed. mhtml-link-interactivity gave 5 passed.
  • CI at 028361af: lint, typecheck, test, build, e2e and hunks pass. merge-gate fails with "evidence-affecting with no sign-off at head", which is the expected maintainer sign-off. agent/pre-pass is pending (this review).

Could not check:

  • A real packaged build refusing 127.0.0.1. Only a mocked app.isPackaged was tested.
  • Whether Chromium opens any speculative connection to a redirect target before the will-redirect throttle. The e2e targets are unroutable, so the e2e runs cannot show the address was never contacted.
  • Whether Chromium's local-network-access checks block the finding 3 frame in this Electron version.
Synced from GitHub

Replies to the review verdict above, one per finding.

  1. Applied. The summary now says the fix refuses addresses written as numbers and the name localhost, and that site names resolving to a private address and embedded frames are not checked yet.
  2. Not applied: the issue ties the check to the viewer's numbers-only rules, so whether to look up site names is the maintainer's call; it is asked, with a follow-up recommended.
  3. Not applied: the issue's criteria cover redirects and the final address only; blocking frames and images from local addresses is asked of the maintainer as a follow-up, and the test that pinned sub-frame hops as followed is removed.
  4. Applied. The comment on the viewer's capture action now says the queue refuses the same addresses and the hidden render refuses a redirect to one.
  5. Applied. The evidence section now says a refused hop stops before any request and a refused final address stops after the render and before anything is stored.
Commit and where each change is

Fix round at 32b1033b, on top of 028361af.

  • 1 and 5: PR body, Summary and Evidence impact.
  • 3: tests/main/services/backgroundRenderer.test.ts, the sub-frame test removed; the PR body's Changes block lists frames and resolved names under "left for the maintainer".
  • 4: src/renderer/components/captures/useLinkMenuTarget.ts.
Synced from GitHub

Review verdict: approve for human review

  1. Leaving out host names that point at a private address, and embedded frames and images, fits the issue as written, and the description now states both limits; no follow-up issue exists yet, so whether they become one is still the maintainer's decision.
  2. The description's summary, which becomes the merge record, names embedded frames as unchecked but not images, while its evidence section names both.
  3. One new code comment says the hidden window checks every redirect, when it checks only the main page's redirects.
  4. The test this round removed was the only one covering the deliberate pass-through of frame redirects, so that behaviour is now untested.

A direct check in the app's browser engine confirmed the description's new claim that a refused redirect or page navigation never opens a connection to the address; every required check passes on this commit, and the merge check waits for this verdict and the maintainer's sign-off.

Full report

Reviewed commit: 32b1033b2da096466db0b1a880515a7fb25af006

Round: second pre-pass, fix-round delta 028361af..32b1033b plus the rewritten PR body. The delta rewrites one comment (src/renderer/components/captures/useLinkMenuTarget.ts:17-19) and deletes one test from tests/main/services/backgroundRenderer.test.ts. Deleting a test is executable, so this is a normal round, not an ADR-0025 prose-only delta. Under the dispatcher's scope I re-ran the touched and related suites rather than full preflight, and took CI at head as authoritative for the full suite. Head was still 32b1033b when I finished; origin/main is 3d9ef9a9.

#severityfile:linefinding
1advisory (maintainer ruling)PR body, Changes, "Not changed, left for the maintainer"; src/shared/localAddress.ts:78-95; src/main/services/backgroundRenderer.ts:415The dispositions of first-round findings 2 and 3 are acceptable under issue #1726 as written. AC2 binds the check to the viewer's classification, which is literal-only (localAddress.ts:81-82, guestLink.ts:49-50). AC1 covers the capture's own redirect hops and final URL, not frames or sub-resources. Summary sentence 3 and Evidence impact bullet 2 now state both limits. No follow-up issue exists: the 15 most recent issues, through #1895, include none. The issue thread has no ruling after 2026-10-04. The "it is asked" in the author's reply is the reply itself plus a collapsed bullet that states the limit without posing a question.
2advisoryPR body, Summary sentence 3"A site name that resolves to a private address, and a frame embedded in the page, are not checked yet" leaves out images and other sub-resources. Evidence impact bullet 2 ("an embedded frame or image") and the Changes bullet ("a sub-frame or sub-resource") name them. ADR-0018 copies the Summary into the squash commit. Fix: "a frame or image embedded in the page".
3advisorysrc/main/services/localAddressPolicy.ts:5-6"the hidden renderer checks each redirect hop before Chromium follows it" is broader than the code. checkHop returns on !details.isMainFrame (backgroundRenderer.ts:415), so a sub-frame will-redirect is followed. The renderer's own comment (backgroundRenderer.ts:407) and the comment this round rewrote (useLinkMenuTarget.ts:19) both say main-frame. Fix: "each main-frame hop". The line predates this round but belongs to this PR.
4advisorytests/main/services/backgroundRenderer.test.ts (test removed from 028361af:422-430)The removed case was the only one that drove the !details.isMainFrame branch. At head every isMainFrame in the test file is true (:400, :413). Line coverage is unchanged because the guard and its return share one line, so coverage:diff still reports 67/67. Deleting the guard, which would refuse frame hops and end the whole render, now passes every test. The code's behaviour did not change: removing the test only stops pinning a limit the body already documents.

The comment this round rewrote, checked against the code. useLinkMenuTarget.ts:17-19 says the queue "refuses the same literal local addresses the menu does, from the same classification, and the hidden render refuses a main-frame redirect to one". The queue calls refusedAddressClass (recapture.ts:104-105), which calls localAddressKind (localAddressPolicy.ts:22-26). The menu calls localAddressKind too (guestLink.ts:60-61). The render checks main-frame hops at backgroundRenderer.ts:414-420, with the listeners attached at :476-477. The comment holds. The one difference between queue and menu is the test switch, which only an unpackaged build reads (localAddressPolicy.ts:13-15, :25).

PR body claims re-derived:

  • Summary 1: "lands on the name localhost or on an address ... written as numbers". localAddress.ts:93 catches localhost and any *.localhost, and :94 catches literal IPv4 and IPv6. This holds, and it understates *.localhost.
  • Summary 2, "stops each such redirect before any request goes to the address": the probe below confirms it for the main frame. "The capture queue refuses a link that points at one" is recapture.ts:104-105, reached from enqueue at :275. "Checks where the render ended before anything is stored" is :149-160, which runs before ingest at :186.
  • Summary 3 (the limits): holds, apart from finding 2.
  • Summary 4, refusals "with the kind of address named": Points at ${label} (recapture.ts:105), Redirects to ${class} (recapture.ts:157, localAddressPolicy.ts:35).
  • Evidence impact 2, "a refused final URL stops after the render and before ingest": the render is at recapture.ts:141, the refusal at :149-160 and ingest at :186, and the temp file is removed in finally at :252.
  • The Changes bullet for the renderer tests matches the tests at head (:392-421). The bullet on the menu comment holds. The excluded-host follow-up candidate holds: recapture.ts:178 emits rendered.finalUrl.
  • Verification block: the marker sha is head. Its 6489 passed and 17 skipped (6506) is one less than round one's 6490 (6507), which is the one deleted test. CI's test job (117304593690) ran on merge ref a7cf685a and reports 6485 passed, 17 skipped (6502), with 399 files passed and 1 skipped. The 4-test, 1-file gap is main's #1874: git diff 58997988 origin/main -- tests e2e shows 4 removed it( cases, 3 of them in the deleted tests/components/CreateSelectorCard.test.tsx. CI diff coverage, 67/67 (100.00%), matches the body.

Probe: does a refused hop open a connection? Round one could not check this. I wrote a standalone script in /tmp/ce1726-probe/main.js and ran it with the repository's Electron 44.7.0 binary under xvfb-run. The target was a raw TCP server on 127.0.0.2 that counts accepted sockets. The handler mirrors checkHop's shape (details.url, details.isMainFrame, details.preventDefault()), and each mechanism ran once without and once with preventDefault:

mechanismevent fired (isMainFrame)sockets, not preventedsockets, prevented
server 302will-redirect (true)10
location.hrefwill-navigate (true)10
meta refreshwill-navigate (true)10
Refresh response headerwill-navigate (true)10
top.location set from an iframewill-navigate (true)10

So Electron fires the event with isMainFrame set, and a prevented hop opens no TCP connection, preconnects included. The probe tests the Electron contract the code relies on, not renderPageInHiddenWindow itself. Not probed: form POST navigations, and sub-frame redirects, which this PR leaves out of scope.

Observation, not a finding: both refusal paths emit the same text ("Redirects to a private address", localAddressPolicy.ts:35 and recapture.ts:157). The e2e spec (e2e/recapture-local-redirect.spec.ts:119-126) therefore cannot show which path fired. The probe shows the hop path fires.

Evidence gate. The blocking-tier hits are src/main/services/recapture.ts, src/main/services/backgroundRenderer.ts, src/shared/localAddress.ts and src/main/services/localAddressPolicy.ts (assessment rows :213, :214, :209, :215). The delta touches none of them: useLinkMenuTarget.ts and the test files are not listed. The PR's labels are evidence-affecting and agent-authored, and the issue's are ready-for-agent, evidence-affecting and queued. The Evidence impact section is substantive and now carries the before-ingest wording and the frame-and-image limit. The known-answer test, tests/shared/localAddress.test.ts, is unchanged this round and passes. Backward verification: the full diff touches no verifier, manifest or package code.

Controls.

controlresultcommand
attribution linepass, last authored linegh api repos/thebristolsound/birdbrain/pulls/1887 --jq .body | awk 'NF' | tail -1
auto-mergepass, nullsame endpoint, --jq .auto_merge
Claude co-author trailerpass, 0 matches over both commitsgit log origin/main..HEAD --format=%B, case-insensitive grep for the co-author trailer
evidence-affecting labelpass, present on the PR and the issuegh api repos/thebristolsound/birdbrain/issues/1887/labels, .../issues/1726/labels
diff coveragepass, 67/67 (100.00%), not re-run locallyCI test job 117304593690 log

Verification (bash tool shell, Node v20.20.2).

  • bash .github/scripts/dispatch/checks.sh --wait 540 1887: exit 0, with no failing and no pending checks. It passed lint, typecheck, test, build, e2e, hunks, changes, seed, adr-numbers, Dependency audit, Font licence check, Registry publish guard and Secret scan. It ignored merge-gate, which failed as expected because it reads this verdict and the evidence sign-off.
  • BIRDBRAIN_TEST_SYSTEM_SHARP=1 pnpm test tests/main/services/backgroundRenderer.test.ts: exit 0, 17 passed.
  • BIRDBRAIN_TEST_SYSTEM_SHARP=1 pnpm test on tests/shared/localAddress.test.ts, tests/main/services/localAddressPolicy.test.ts and tests/main/services/recapture.test.ts: exit 0, 3 files, 71 passed.
  • pnpm test tests/renderer/guestLink.test.ts: exit 0.
  • eslint and prettier --check on the two files the delta touches: exit 0 each.
  • Not re-run locally: full preflight, the full coverage suite, build and e2e. That was the dispatcher's scope, and CI is green on all of them at this head.

Could not check:

  • A real packaged build refusing 127.0.0.1, carried from round one. Only a mocked app.isPackaged is tested.
  • Whether Chromium's local-network-access checks already block a public page's frame or image from a private address. That is out of scope by the dispositions above, and no fixture has a public origin.
  • Whether the maintainer has seen the open question. The issue thread has no ruling after 2026-10-04.
Synced from GitHub

Review verdict: approve for human review

  1. The change does what the issue asks: a background capture whose link, server redirect or self-navigation lands on the Operator's own machine or private network is refused, and nothing is stored.
  2. Two new comments say every redirect hop is checked, but only redirects of the page itself are; redirects inside embedded frames are still followed, as the description already says.
  3. The one check that lets embedded frames through has no test since the second commit removed it.
  4. Merging still needs the maintainer's own sign-off at this commit, because the change affects evidence.
Full report

Reviewed commit: 32b1033b2da096466db0b1a880515a7fb25af006

The commit was head at the start and at the end of the review.

#severityfile:linefinding
1advisorysrc/main/services/localAddressPolicy.ts:5-6; docs/specs/2026-07-31-evidence-affecting-paths-assessment.md:215Both say the renderer checks "each redirect hop". checkHop returns early for !details.isMainFrame (backgroundRenderer.ts:415), so a sub-frame redirect to a private address is followed. The PR body and the comment at backgroundRenderer.ts:406-409 say main frame only. Fix: "each main-frame redirect hop".
2advisorysrc/shared/localAddress.ts:3-4"so the two cannot disagree". In an unpackaged build with BIRDBRAIN_E2E_ALLOW_LOOPBACK=1, the queue admits loopback (localAddressPolicy.ts:25) and the viewer's captureLinkBlockReason still refuses it (guestLink.ts:60-61). The classification is shared; the policy is not. This affects test builds only.
3advisorysrc/main/services/backgroundRenderer.ts:415The sub-frame early return is never taken: every hop test sets isMainFrame: true, and 32b1033b removed the sub-frame case. Line coverage counts the line as covered. If the guard were removed, sub-frame hops would be refused (stricter, not unsafe) and no test would notice.
4info(CI) merge-gatemerge-gate fails with "evidence-affecting with no sign-off at head". That is ADR-0041 waiting for maintainer approval at head, not a code failure. Every other check passed or was skipped.

Correctness traces

  • Hop refusal (backgroundRenderer.ts:406-420, 476-477, 566). The handlers are attached inside render() before its first await. Promise.race is built in the same tick. refuseHop rejects synchronously inside the Electron event handler, so refused settles before the aborted loadURL rejection gets through render()'s finally. The job therefore ends skipped, not failed; e2e asserts this against a real 302. If the hop comes after the race has settled (window alive until RenderedPage.cleanup), preventDefault still stops it. The late rejection is already handled by the race, so there is no unhandled rejection.
  • Refusal routing (recapture.ts:146-160, 233-242). The final-URL refusal destroys the stream, emits skipped with job.url and returns. The finally at :252 calls rendered.cleanup(). RefusedAddressError thrown from the renderer or through the fallback race becomes skipped. Nothing reaches captureLifecycle.ingest.
  • Enqueue (recapture.ts:100-109). The scheme check runs first, then the classification. The only producer is enqueue (ipcHandlers.ts:961, :980), and the only renderer is wired at index.ts:611. No path reaches the renderer without validation.
  • Switch (localAddressPolicy.ts:13-15). It needs app.isPackaged === false and the value exactly '1'. It admits only loopback and this-computer. Unit tests see app undefined (recapture.test.ts) or without isPackaged (backgroundRenderer.test.ts:153-156), so it is off there.
  • Classification move. ipv4Kind and ipv6Kind match the removed ipv4Class and ipv6Class exactly, and the labels match the old strings. captureLinkBlockReason messages are unchanged.
  • Real-Electron probe (my own script under xvfb, Electron 44.7.0, not committed). For a meta refresh, location.replace, a form POST and an a.click() to 10.255.255.1, will-navigate fired with isMainFrame=true. preventDefault kept the page on its origin. Chromium blocked a cross-origin iframe's top.location without user activation before any main-frame event.
  • Bounded work: not applicable, since no stored or uploaded bytes are processed.

Evidence gate

  • Touched blocking-tier paths: recapture.ts (:213), backgroundRenderer.ts (:214), and new rows localAddress.ts (:209) and localAddressPolicy.ts (:215). No advisory-tier entry was touched.
  • Labels: the PR and #1726 both carry evidence-affecting.
  • The Evidence impact section is substantive. It names the refusal, the literal-only and main-frame-only limits, and what verification does not cover.
  • KAT: the classification table is in tests/shared/localAddress.test.ts. Refusal-writes-nothing tests are in recapture.test.ts:286-360. No hashing, manifest or verifier code is touched.
  • Backward verification: no verifier, manifest or package code changed. The full suite, verify tests included, passes.

Verification (local, Node v20.20.2, detached worktree at head)

  • test:coverage: exit 0, 400 files passed and 1 skipped, 6489 tests passed and 17 skipped, thresholds met.
  • coverage:diff: PASS, 67/67 changed lines (100%).
  • lint 0, typecheck 0, build 0, lint:boundaries 0. build:extension not needed.
  • vale-prose: could not check (vale not installed here).
  • Live CI: all passed except merge-gate (see 4).

Completion controls

  • Commit trailers: no co-author trailer on either commit.
  • auto_merge: null.
  • Attribution line: present and last.
  • Head sha unchanged at the end.

Scope

The guestLink refactor is the shared-classification move, the e2e switch is needed for the loopback fixtures, and the path-list rows follow from the new files. Nothing goes beyond #1726.

Synced from GitHub

Merge request refused: GitHub would not enable auto-merge; read the pull request's merge box

Remove the cause, then apply the merge label again.

Synced from GitHub

Merge request refused: GitHub would not enable auto-merge; read the pull request's merge box

Remove the cause, then apply the merge label again.

Added 2 commits

  • fix(recapture): correct the link menu comment and drop a scope test26d7107
  • fix(recapture): refuse a redirect to a loopback or private address4ac5abc
Synced from GitHub

Merge request refused: GitHub would not enable auto-merge; read the pull request's merge box

Remove the cause, then apply the merge label again.

Synced from GitHub

Merge request refused: GitHub would not enable auto-merge; read the pull request's merge box

Remove the cause, then apply the merge label again.