From 71a14eb17a5706df0f095d11b46eddf2bbfa546a Mon Sep 17 00:00:00 2001 From: go0p Date: Fri, 4 Sep 2026 14:34:46 +0800 Subject: [PATCH] fix(authorization): keep capture across same-origin login --- .../grants/capability-handlers/network.ts | 11 +++++-- src/features/network-capture/service.test.ts | 31 +++++++++++++++++++ src/features/network-capture/service.ts | 19 ++++++++---- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/features/grants/capability-handlers/network.ts b/src/features/grants/capability-handlers/network.ts index 623dfbc..4623060 100644 --- a/src/features/grants/capability-handlers/network.ts +++ b/src/features/grants/capability-handlers/network.ts @@ -3,7 +3,9 @@ import type { YakPocGenerateResult, } from '@/types/models'; import type { CapabilityDomainHandler } from '../capability-context'; -import { allowedTarget, requireScope } from '../capability-context'; +import { + allowedTarget, PAIRED_BROWSER_INSTANCE_ACCESS_ID, requireScope, +} from '../capability-context'; import { clearNetworkRequests, exportNetworkRequest, @@ -30,7 +32,12 @@ export const networkCapabilityHandler: CapabilityDomainHandler = { captureBody: input.captureBody === true, maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined, maxBodyBytes: typeof input.maxBodyBytes === 'number' ? input.maxBodyBytes : undefined, - }, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt }); + }, { + kind: 'grant', + grantId: grant.id, + expiresAt: grant.expiresAt, + followSameOriginNavigation: grant.id === PAIRED_BROWSER_INSTANCE_ACCESS_ID, + }); } if (method === 'browser.network.status') return networkCaptureStatus(target); if (method === 'browser.network.list') { diff --git a/src/features/network-capture/service.test.ts b/src/features/network-capture/service.test.ts index 387d256..3614e88 100644 --- a/src/features/network-capture/service.test.ts +++ b/src/features/network-capture/service.test.ts @@ -185,6 +185,37 @@ describe('network capture lifecycle, budget and persistence', () => { expect((await networkCaptureStatus({ tabId: 43, frameId: 0, documentId: 'document-cross-origin' })).active).toBe(false); }); + it('continues a paired-browser capture across a same-origin login navigation', async () => { + setTarget(45, 'http://localhost:8080/logic/user/login', 'document-login'); + const before = await start( + 45, + { captureHeaders: true, captureBody: true }, + { + kind: 'grant', + grantId: 'paired-browser-instance', + expiresAt: Number.MAX_SAFE_INTEGER, + followSameOriginNavigation: true, + }, + ); + + await committed(45, 'http://localhost:8080/logic/user/profile', 'document-profile'); + + const after = await networkCaptureStatus({ tabId: 45, frameId: 0, documentId: 'document-profile' }); + expect(after).toMatchObject({ active: true, startedAt: before.startedAt }); + + await committed(45, 'http://other.example/landing', 'document-other'); + expect((await networkCaptureStatus({ tabId: 45, frameId: 0, documentId: 'document-other' })).active).toBe(false); + }); + + it('does not silently extend a regular scoped grant across navigation', async () => { + setTarget(46, 'https://scoped.example.test/start', 'document-start'); + await start(46, {}, { kind: 'grant', grantId: 'scoped-grant', expiresAt: NOW + 60_000 }); + + await committed(46, 'https://scoped.example.test/next', 'document-next'); + + expect((await networkCaptureStatus({ tabId: 46, frameId: 0, documentId: 'document-next' })).active).toBe(false); + }); + it('does not retain a cross-origin navigation request before the commit boundary is processed', async () => { setTarget(44, 'https://source.example.test/start', 'document-source'); await start(44); diff --git a/src/features/network-capture/service.ts b/src/features/network-capture/service.ts index 1118dc4..569f4ff 100644 --- a/src/features/network-capture/service.ts +++ b/src/features/network-capture/service.ts @@ -32,7 +32,9 @@ const CAPTURED_RESOURCE_TYPES = [ ] as const; type CapturePersistence = 'pending' | 'persisted' | 'memory-only' | 'degraded'; -type CaptureOwner = { kind: 'local' } | { kind: 'grant'; grantId: string; expiresAt: number }; +type CaptureOwner = { kind: 'local' } | { + kind: 'grant'; grantId: string; expiresAt: number; followSameOriginNavigation?: boolean; +}; interface CaptureSession { target: BrowserTarget; @@ -270,7 +272,7 @@ function addRestoredSession(value: PersistedCaptureSession): boolean { options: normalizedOptions(value.options), records, owner: value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number' - ? value.owner + ? { ...value.owner, followSameOriginNavigation: value.owner.followSameOriginNavigation === true } : { kind: 'local' }, retainedBytes, recordBytes, @@ -283,7 +285,11 @@ function addRestoredSession(value: PersistedCaptureSession): boolean { totalRecordCount += records.length; totalRetainedBytes += retainedBytes; const ownerWasValid = value.owner?.kind === 'local' - || (value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number'); + || (value.owner?.kind === 'grant' + && typeof value.owner.grantId === 'string' + && typeof value.owner.expiresAt === 'number' + && (value.owner.followSameOriginNavigation === undefined + || typeof value.owner.followSameOriginNavigation === 'boolean')); return records.length === value.records.length && ownerWasValid && !existing; } @@ -605,7 +611,7 @@ browser.webRequest.onErrorOccurred.addListener((details) => { dispatchCaptureEvent(errorRecord, details); }, { urls: [''], types: [...CAPTURED_RESOURCE_TYPES] }); -async function rebindLocalCaptureAfterNavigation(details: { +async function rebindCaptureAfterNavigation(details: { tabId: number; frameId: number; documentId?: string; @@ -613,7 +619,8 @@ async function rebindLocalCaptureAfterNavigation(details: { }): Promise { await restorePromise; const session = captureSessions.get(details.tabId); - if (!session || session.owner.kind !== 'local' || session.target.frameId !== details.frameId) return; + if (!session || session.target.frameId !== details.frameId + || (session.owner.kind === 'grant' && !session.owner.followSameOriginNavigation)) return; if (details.documentId && session.target.documentId === details.documentId) return; let isolationBoundary: string | undefined; try { @@ -639,7 +646,7 @@ async function rebindLocalCaptureAfterNavigation(details: { } browser.webNavigation.onCommitted.addListener((details) => { - void rebindLocalCaptureAfterNavigation(details).catch(() => undefined); + void rebindCaptureAfterNavigation(details).catch(() => undefined); }); browser.tabs.onRemoved.addListener((tabId) => { if (!deleteSession(tabId)) return;