fix(authorization): keep capture across same-origin login

This commit is contained in:
go0p
2026-09-04 14:34:46 +08:00
parent 1f703dfbdc
commit 71a14eb17a
3 changed files with 53 additions and 8 deletions
@@ -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') {
@@ -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);
+13 -6
View File
@@ -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: ['<all_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<void> {
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;