Skip to content

Commit e7db6c0

Browse files
committed
Fail closed on hostless readback URLs; ack the Stop gate before the latch may clear
Blocking 1: verifyObservedOnApprovedDomain treated a hostless observed URL (file:///…, data:…, about:blank — registrableHost '') as approved, returning ok with content and updating lastLinks. Now any PRESENT observed.url whose registrable host differs from the grant — including the empty host — drops the bridge to lost and refuses (same per-action status mapping); only an absent url passes, since extraction gave nothing to verify. navigateApprovedLink's pre-check now uses the shared hostMatchesGrant helper (behavior-identical); the frameNavigated listener and the post-read check document why they don't. Blocking 2: the renderer POSTs /browse/control/stop fire-and-forget, so clearing the latch merely because a poll started after stopRequestedAt could execute a command handed out before the server gate actually set. The latch now carries a main-owned ack: while armed and un-acked the poller itself POSTs the idempotent /browse/control/stop and records ackAt on 2xx (no dependence on the renderer's POST); every handed-out command is gated until a poll starts after the ack, and only such a post-ack poll (command or empty) clears the latch. A fresh Stop resets the ack. Stop <1s is unchanged (requestedAt set immediately on IPC); post-resume commands execute; Stop with no new turn stays stopped. Removed the dead old-latch isStopRequested/isStopRequestedSince API.
1 parent 8a12313 commit e7db6c0

4 files changed

Lines changed: 275 additions & 117 deletions

File tree

src/main/browser-bridge.test.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,38 @@ describe('browser-bridge domain-grant immutability', () => {
414414
expect(bridge.currentState()).toBe('lost');
415415
});
416416

417+
// FAIL CLOSED on hostless readback URLs: file:///, data:, about:blank have
418+
// no registrable host — the tab left the approved site, so the observation
419+
// must be refused, never returned as approved content.
420+
for (const hostlessUrl of ['about:blank', 'file:///tmp/x.html', 'data:text/html,hi']) {
421+
it(`inspect readback on ${hostlessUrl} is refused + lost (fail closed)`, async () => {
422+
await connect();
423+
FakeCdpSocket.instances[0].page = { ...FakeCdpSocket.instances[0].page, url: hostlessUrl };
424+
const r = await bridge.inspect();
425+
expect(r.status).toBe('permission_denied');
426+
expect(r.observed).toBeUndefined();
427+
expect(bridge.currentState()).toBe('lost');
428+
});
429+
430+
it(`scroll readback on ${hostlessUrl} is refused + lost (fail closed)`, async () => {
431+
await connect();
432+
FakeCdpSocket.instances[0].page = { ...FakeCdpSocket.instances[0].page, url: hostlessUrl };
433+
const r = await bridge.scroll('down');
434+
expect(r.status).toBe('permission_denied');
435+
expect(r.observed).toBeUndefined();
436+
expect(bridge.currentState()).toBe('lost');
437+
});
438+
439+
it(`wait readback on ${hostlessUrl} is refused + lost (fail closed)`, async () => {
440+
await connect();
441+
FakeCdpSocket.instances[0].page = { ...FakeCdpSocket.instances[0].page, url: hostlessUrl };
442+
const r = await bridge.wait(0);
443+
expect(r.status).toBe('permission_denied');
444+
expect(r.observed).toBeUndefined();
445+
expect(bridge.currentState()).toBe('lost');
446+
});
447+
}
448+
417449
it('a grant under a multi-label suffix does NOT extend to sibling sites (github.io)', async () => {
418450
// PSL isolation end-to-end: approving foo.github.io must not let the
419451
// bridge follow a link to bar.github.io — they are unrelated sites that
@@ -527,31 +559,44 @@ describe('browser-bridge conversation binding lifecycle', () => {
527559
});
528560

529561
describe('browser-bridge local Stop latch', () => {
530-
it('records WHEN Stop was pressed so the poller can gate only raced commands', () => {
562+
it('records WHEN Stop was pressed, un-acked until the server confirms its gate', () => {
531563
const before = Date.now();
532-
expect(bridge.isStopRequested()).toBe(false);
564+
expect(bridge.getStopLatch()).toEqual({ requestedAt: null, ackAt: null });
533565
bridge.requestStop();
534-
expect(bridge.isStopRequested()).toBe(true);
535-
// The latch answers "was Stop pressed at-or-after t?" — true for a poll
536-
// that started before the stop (raced), false for one started after.
537-
expect(bridge.isStopRequestedSince(before)).toBe(true);
538-
expect(bridge.isStopRequestedSince(Date.now() + 1)).toBe(false);
566+
const latch = bridge.getStopLatch();
567+
expect(latch.requestedAt).toBeGreaterThanOrEqual(before);
568+
expect(latch.ackAt).toBeNull();
569+
});
570+
571+
it('ackStopRequest records the server-gate confirmation; a fresh Stop resets it', () => {
572+
bridge.requestStop();
573+
bridge.ackStopRequest();
574+
expect(bridge.getStopLatch().ackAt).not.toBeNull();
575+
// A new Stop needs a new ack — the previous gate may have been resumed.
576+
bridge.requestStop();
577+
expect(bridge.getStopLatch().requestedAt).not.toBeNull();
578+
expect(bridge.getStopLatch().ackAt).toBeNull();
579+
});
580+
581+
it('a stray ack with no Stop latched must not fabricate latch state', () => {
582+
bridge.ackStopRequest();
583+
expect(bridge.getStopLatch()).toEqual({ requestedAt: null, ackAt: null });
539584
});
540585

541-
it('is cleared by clearStopRequest (the poller after a post-stop poll cycle)', () => {
586+
it('is cleared by clearStopRequest (the poller after a post-ack poll cycle)', () => {
542587
bridge.requestStop();
588+
bridge.ackStopRequest();
543589
bridge.clearStopRequest();
544-
expect(bridge.isStopRequested()).toBe(false);
545-
expect(bridge.isStopRequestedSince(0)).toBe(false);
590+
expect(bridge.getStopLatch()).toEqual({ requestedAt: null, ackAt: null });
546591
});
547592

548593
it('is cleared belt-and-braces by a fresh attach and by disposeAllBridges', async () => {
549594
bridge.requestStop();
550595
await bridge.attach('TAB-1');
551-
expect(bridge.isStopRequested()).toBe(false);
596+
expect(bridge.getStopLatch().requestedAt).toBeNull();
552597

553598
bridge.requestStop();
554599
bridge.disposeAllBridges();
555-
expect(bridge.isStopRequested()).toBe(false);
600+
expect(bridge.getStopLatch().requestedAt).toBeNull();
556601
});
557602
});

src/main/browser-bridge.ts

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type BrowserStatusResult,
2828
type ObservedResult,
2929
type ObservedLink,
30+
hostMatchesGrant,
3031
isReadonlyCdpMethod,
3132
registrableHost,
3233
} from '../shared/browser-bridge-types';
@@ -83,43 +84,58 @@ export function getConversationId(): string | null {
8384
return conversationId;
8485
}
8586

86-
// Local user-Stop signal. Stop/resume lifecycle: the SERVER is the single
87-
// source of truth. The renderer's Stop button sets the SERVER control gate
88-
// (POST /browse/control/stop) — while stopped the server hands out NO
89-
// commands — and a fresh user turn resumes it server-side
87+
// Local user-Stop latch. THE canonical Stop-lifecycle description lives here
88+
// (the poller's comments point at this block).
89+
//
90+
// Stop/resume lifecycle: the SERVER is the single source of truth. Stop sets
91+
// the SERVER control gate (POST /browse/control/stop) — while stopped the
92+
// server hands out NO commands — and a fresh user turn resumes it server-side
9093
// (resume_on_new_turn). Re-approval is required only after
9194
// take-over/lost/revoke, never after Stop.
9295
//
9396
// This local latch exists ONLY to close the hand-out→execute race: a command
94-
// the server handed to the wire just before the Stop landed (Stop leaves the
97+
// the server handed to the wire before its stop gate was set (Stop leaves the
9598
// bridge connected, so the poller's currentState() re-check can't catch it).
96-
// It records WHEN Stop was pressed (a timestamp, set via IPC BROWSER_STOP);
97-
// the poller compares it against when its long-poll started and gates only
98-
// commands that could predate the stop, then clears the latch. It must NEVER
99-
// gate post-resume commands — those come from a server whose gate was already
100-
// stopped and then resumed by a new turn. Also cleared on attach()/dispose as
101-
// belt-and-braces.
99+
// `requestedAt` is set immediately on IPC BROWSER_STOP (Stop takes effect
100+
// locally in <1s). The renderer also POSTs /browse/control/stop, but it does
101+
// so fire-and-forget — the server gate is only KNOWN to be set once an ack is
102+
// recorded, so the MAIN process re-POSTs the (idempotent) stop from the
103+
// poller each cycle while un-acked and records `ackAt` when a 2xx lands
104+
// (main-owned ack: no dependence on the renderer's POST succeeding).
105+
//
106+
// The poller gates every handed-out command while the latch is armed
107+
// (requestedAt set, no ack yet). The ack is awaited BEFORE the poll in the
108+
// same single-in-flight loop, so once acked, any command a later poll returns
109+
// was necessarily handed out by a server whose gate had already been set —
110+
// i.e. resumed by a fresh user turn — and the latch is cleared and the
111+
// command executes. The latch must never outlive its ack to gate post-resume
112+
// commands (that was the old bug: a boolean latch persisted until
113+
// re-approval). Also cleared on attach()/dispose as belt-and-braces.
102114
let stopRequestedAt: number | null = null;
115+
let stopAckAt: number | null = null;
103116

104117
export function requestStop(): void {
105118
stopRequestedAt = Date.now();
119+
// A fresh Stop needs a fresh server ack — the previous gate may have been
120+
// resumed since.
121+
stopAckAt = null;
106122
}
107123

108-
// Was Stop pressed at-or-after `sinceMs`? Used by the poller to detect the
109-
// raced case: a stop that landed while its long-poll was outstanding.
110-
export function isStopRequestedSince(sinceMs: number): boolean {
111-
return stopRequestedAt !== null && stopRequestedAt >= sinceMs;
124+
// Record that the server confirmed (2xx) its stop gate is set. No-op when no
125+
// Stop is latched (a stray ack must not fabricate a latch state).
126+
export function ackStopRequest(): void {
127+
if (stopRequestedAt !== null) stopAckAt = Date.now();
112128
}
113129

114-
// Is any un-cleared Stop latched at all (regardless of timing)?
115-
export function isStopRequested(): boolean {
116-
return stopRequestedAt !== null;
130+
export function getStopLatch(): { requestedAt: number | null; ackAt: number | null } {
131+
return { requestedAt: stopRequestedAt, ackAt: stopAckAt };
117132
}
118133

119-
// The latch's raced-case job is done (the poller gated or safely bypassed
120-
// it); post-resume commands must flow freely.
134+
// The latch's raced-case job is done (the server gate is known set and the
135+
// poller finished a post-ack cycle); post-resume commands must flow freely.
121136
export function clearStopRequest(): void {
122137
stopRequestedAt = null;
138+
stopAckAt = null;
123139
}
124140

125141
// Main-side bridge-state subscribers (e.g. the command poller). Distinct from
@@ -218,6 +234,7 @@ export function __resetBridgeForTest(): void {
218234
bridgeStateListeners.clear();
219235
conversationId = null;
220236
stopRequestedAt = null;
237+
stopAckAt = null;
221238
}
222239

223240
async function defaultListTargets(base: string): Promise<CdpTarget[]> {
@@ -409,6 +426,7 @@ export async function attach(
409426
// Belt-and-braces: a fresh approval starts a clean session, so any stale
410427
// Stop latch is meaningless (resume itself is server-side, on a new turn).
411428
stopRequestedAt = null;
429+
stopAckAt = null;
412430
// New approval supersedes any in-flight approve() awaiting connect.
413431
approvalGeneration += 1;
414432
pendingApproval = { targetId, cancel: () => {} };
@@ -490,6 +508,8 @@ export async function approve(): Promise<{ ok: boolean; state: BridgeState; reas
490508
if (!frame.url) return;
491509
const host = registrableHost(frame.url);
492510
// Empty host (about:blank etc.) — ignore, no cross-host expansion.
511+
// (Deliberately NOT hostMatchesGrant: transient hostless frames are
512+
// tolerated here; the post-read verification fails them closed.)
493513
if (host && host !== approvedTarget.domain) {
494514
handleLost('The tab navigated to a different site, so the approval no longer applies.');
495515
}
@@ -651,32 +671,36 @@ async function extractObserved(): Promise<ObservedResult> {
651671
// client-side redirect) between the frameNavigated listener firing and the
652672
// readback — the grant is for the approved domain ONLY, so an off-domain
653673
// observation is discarded, the bridge drops to lost (re-approval required),
654-
// and the caller gets a refusal instead of unapproved-site content. Returns
655-
// the refusal result, or null when the observation is on-grant.
674+
// and the caller gets a refusal instead of unapproved-site content.
675+
//
676+
// FAIL CLOSED on hostless URLs: when observed.url IS present but yields no
677+
// registrable host (file:///…, data:…, about:blank), the tab left the
678+
// approved site — refuse, don't approve. (This is why the check is spelled
679+
// out here instead of using hostMatchesGrant: the helper's empty-host-refuses
680+
// semantics match, but the missing-url pass below does not.) Only an ABSENT
681+
// observed.url passes unverified — extraction gave nothing to check.
682+
// Returns the refusal result, or null when the observation is on-grant.
656683
function verifyObservedOnApprovedDomain(
657684
action: BrowserActionType,
658685
observed: ObservedResult,
659686
): BrowserActionResult | null {
660687
const target = approvedTarget;
661688
if (!target || !observed.url) return null;
662689
const host = registrableHost(observed.url);
663-
if (!host || host === target.domain) return null;
690+
if (host === target.domain) return null;
664691
// Keep the per-action status mapping: a navigate that LANDED off-domain is
665692
// a failed navigation; any other primitive observing off-domain content is
666693
// a permission refusal.
667-
const reason =
668-
action === 'navigate'
669-
? 'That link leaves the approved site.'
670-
: 'The tab is on a different site than the one you approved.';
694+
const offSiteReason = 'The tab is on a different site than the one you approved.';
671695
handleLost(
672696
action === 'navigate'
673697
? 'The page redirected to a different site, so the approval no longer applies.'
674-
: 'The tab is on a different site than the one you approved.',
698+
: offSiteReason,
675699
);
676700
return {
677701
status: action === 'navigate' ? 'navigation_failed' : 'permission_denied',
678702
action,
679-
reason,
703+
reason: action === 'navigate' ? 'That link leaves the approved site.' : offSiteReason,
680704
};
681705
}
682706

@@ -710,7 +734,7 @@ export async function navigateApprovedLink(href: string): Promise<BrowserActionR
710734
reason: 'That link is not one of the links found on the approved page.',
711735
};
712736
}
713-
if (registrableHost(href) !== target.domain) {
737+
if (!hostMatchesGrant(href, target.domain)) {
714738
return {
715739
status: 'navigation_failed',
716740
action: 'navigate',
@@ -799,6 +823,7 @@ export function disposeAllBridges(): void {
799823
bridgeState = 'disconnected';
800824
conversationId = null;
801825
stopRequestedAt = null;
826+
stopAckAt = null;
802827
// Tear down the Chrome WE launched (app quit / full drain). If Chrome was
803828
// already running on the port when we attached, managedChrome is null and we
804829
// leave the user's own Chrome alone.

0 commit comments

Comments
 (0)