ext: capture/index.ts — blocking onHeadersReceived hook, fail-open

For a response shouldCapture() likes: gather cookies, offer to the daemon
under a hard 750 ms budget, and return {cancel:true} ONLY on an explicit
{action:"take"}. Every other outcome — shouldCapture says no, daemon down /
slow / erroring, cookies fail, anything throws — resolves to {} and Firefox
downloads normally.

Fail-open tests written first (tests/capture/hook.test.ts): offer() rejects,
offer() never settles (resolves within budget), timeout-shaped rejection,
getRules() throws, malformed details. Plus the happy paths and a check that
the capture.offer payload is well-formed from details + stash + headers.

background/index.ts: wire the stash + hook onto browser.webRequest, mirror
capture rules via capture.getRules on connect and on a capture.* settings
change; DEFAULT_CAPTURE_RULES (enabled:false) until the first mirror lands.

84 tests green; web-ext lint clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
2026-09-10 15:35:29 +04:00
co-authored by Claude Sonnet 5
parent 05b650b8ec
commit 23407974d2
4 changed files with 480 additions and 2 deletions
+228
View File
@@ -0,0 +1,228 @@
// The blocking onHeadersReceived hook.
//
// For a response that shouldCapture() likes: gather cookies, offer it to the daemon with
// a hard 750 ms budget, and cancel the browser's own download ONLY on an explicit
// {action:"take"}. Everything else — shouldCapture says no, the daemon is down / slow /
// erroring, cookies fail, anything throws — resolves to {} and Firefox downloads
// normally. Fail-open is not a fallback path here; it is the default every branch returns
// to. tests/capture/hook.test.ts pins that.
import type {
CaptureOfferParams,
CaptureOfferResult,
CaptureRules,
Cookie,
} from '../../shared/protocol/index.js';
import { normalizeHeaders } from './headers.js';
import type { HeaderStash } from './headers.js';
import { filenameExtension, shouldCapture, type CaptureCandidate, type ResourceType } from './rules.js';
const BUDGET_MS = 750; // docs/05 §2 and METHODS['capture.offer'].deadlineMs
const PROCEED: BlockingResponse = {};
const CANCEL: BlockingResponse = { cancel: true };
export interface BlockingResponse {
cancel?: boolean;
}
export interface OnHeadersReceivedDetails {
requestId: string;
url: string;
method: string;
type: ResourceType;
statusCode: number;
tabId: number;
responseHeaders?: { name: string; value?: string }[];
documentUrl?: string | null;
originUrl?: string | null;
}
interface WebRequestEvent<L extends (...a: never[]) => void> {
addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void;
removeListener(listener: L): void;
}
export interface HeadersReceivedWebRequest {
onHeadersReceived: WebRequestEvent<
(d: OnHeadersReceivedDetails) => BlockingResponse | Promise<BlockingResponse>
>;
}
/** A browser cookie, as `browser.cookies.getAll` returns it (superset of the wire type). */
export interface CookieRecord {
name: string;
value: string;
domain?: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
}
export interface CaptureHookDeps {
/** Usually `(p, o) => transport.call('capture.offer', p, o)`. */
offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise<CaptureOfferResult>;
stash: Pick<HeaderStash, 'take' | 'peek'>;
getCookies: (url: string) => Promise<CookieRecord[]>;
/** A synchronous snapshot of the daemon's capture policy (mirrored via capture.getRules). */
getRules: () => CaptureRules;
/** Whether the user held the bypass modifier for the click behind this request. */
bypassHeld?: (details: OnHeadersReceivedDetails) => boolean;
/** moz-extension://<uuid>, for CaptureOfferParams.origin. */
origin?: string;
budgetMs?: number;
}
const ALL_URLS = '<all_urls>';
export class CaptureHook {
private readonly budgetMs: number;
private bound?: (d: OnHeadersReceivedDetails) => Promise<BlockingResponse>;
private attachedTo?: HeadersReceivedWebRequest;
constructor(private readonly deps: CaptureHookDeps) {
this.budgetMs = deps.budgetMs ?? BUDGET_MS;
}
/** The listener. Always resolves within the budget to {} or {cancel:true}; never rejects. */
handle = (details: OnHeadersReceivedDetails): Promise<BlockingResponse> => {
return this.withinBudget(this.decide(details));
};
attach(webRequest: HeadersReceivedWebRequest): void {
if (this.attachedTo) throw new Error('CaptureHook is already attached');
this.attachedTo = webRequest;
this.bound = this.handle;
webRequest.onHeadersReceived.addListener(this.bound, { urls: [ALL_URLS] }, [
'blocking',
'responseHeaders',
]);
}
detach(): void {
if (this.attachedTo && this.bound) {
this.attachedTo.onHeadersReceived.removeListener(this.bound);
}
this.attachedTo = undefined;
this.bound = undefined;
}
private withinBudget(work: Promise<BlockingResponse>): Promise<BlockingResponse> {
return new Promise<BlockingResponse>((resolve) => {
let settled = false;
const done = (value: BlockingResponse): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};
const timer = setTimeout(() => done(PROCEED), this.budgetMs);
(timer as { unref?: () => void }).unref?.();
work.then(done, () => done(PROCEED));
});
}
private async decide(details: OnHeadersReceivedDetails): Promise<BlockingResponse> {
try {
const rules = this.deps.getRules();
const responseHeaders = normalizeHeaders(details.responseHeaders);
const stashed = this.stashPeek(details.requestId);
const candidate: CaptureCandidate = {
url: details.url,
method: details.method,
statusCode: details.statusCode,
type: details.type,
responseHeaders,
requestHeaders: stashed?.headers ?? {},
documentUrl: details.documentUrl ?? details.originUrl ?? null,
bypassHeld: this.deps.bypassHeld?.(details) ?? false,
};
if (!shouldCapture(candidate, rules).capture) return PROCEED;
// We are acting on this request now — take the stash entry.
const taken = this.deps.stash.take(details.requestId);
const requestHeaders = taken?.headers ?? candidate.requestHeaders;
let cookies: Cookie[] = [];
try {
cookies = toWireCookies(await this.deps.getCookies(details.url));
} catch {
cookies = []; // best-effort; still worth offering
}
const params: CaptureOfferParams = {
url: details.url,
method: candidate.method.toUpperCase() === 'POST' ? 'POST' : 'GET',
tabUrl: candidate.documentUrl ?? '',
headers: requestHeaders,
cookies,
contentType: responseHeaders['content-type'] ?? null,
contentLength: intOrNull(responseHeaders['content-length']),
contentDisposition: responseHeaders['content-disposition'] ?? null,
filename: guessFilename(details.url, responseHeaders['content-disposition']),
referrer: requestHeaders['referer'] ?? null,
origin: this.deps.origin ?? null,
requestId: details.requestId,
};
const result = await this.deps.offer(params, { timeoutMs: this.budgetMs });
return result.action === 'take' ? CANCEL : PROCEED;
} catch {
return PROCEED; // fail open on anything at all
}
}
private stashPeek(requestId: string) {
try {
return this.deps.stash.peek(requestId);
} catch {
return undefined;
}
}
}
// --- helpers ----------------------------------------------------------------------
function toWireCookies(records: CookieRecord[]): Cookie[] {
return records.map((c) => {
const out: Cookie = { name: c.name, value: c.value };
if (c.domain !== undefined) out.domain = c.domain;
if (c.path !== undefined) out.path = c.path;
if (c.secure !== undefined) out.secure = c.secure;
if (c.httpOnly !== undefined) out.httpOnly = c.httpOnly;
return out;
});
}
function intOrNull(value: string | undefined): number | null {
if (value === undefined) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/** RFC 6266 filename, falling back to the URL's last path segment. */
export function guessFilename(url: string, disposition: string | undefined): string | null {
if (disposition) {
const star = /filename\*\s*=\s*(?:[^']*'[^']*')?([^;]+)/i.exec(disposition);
if (star?.[1]) {
try {
return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')) || null;
} catch {
/* fall through */
}
}
const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition);
if (plain?.[1]) return plain[1].trim() || null;
}
try {
const path = new URL(url).pathname;
const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
if (base) return base;
} catch {
/* not a parseable URL */
}
const ext = filenameExtension(url);
return ext ? `download.${ext}` : null;
}
+13
View File
@@ -63,6 +63,19 @@ export interface CaptureDecision {
reason: CaptureReason;
}
/**
* What shouldCapture() runs against before the first capture.getRules has been mirrored
* from the daemon. `enabled: false` — we intercept nothing until we know the real policy.
*/
export const DEFAULT_CAPTURE_RULES: CaptureRules = {
enabled: false,
monitoredExtensions: [],
monitoredMimeTypes: [],
minSizeBytes: 1024 * 1024,
excludedHosts: [],
rulesVersion: 0,
};
const RENDERABLE_TYPES = new Set([
'text/html',
'application/xhtml+xml',
+40 -2
View File
@@ -1,20 +1,58 @@
// Background event-page entry.
//
// Brings the transport up and holds the single shared reference to it. Capture, the
// popup relay, and context menus attach here as they land (docs/05 §6 build order).
// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture
// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety
// net. The popup relay and context menus attach here in later steps of the build order.
import { CaptureHook } from './capture/index.js';
import { HeaderStash } from './capture/headers.js';
import type { WebRequestLike } from './capture/headers.js';
import type { HeadersReceivedWebRequest } from './capture/index.js';
import { DEFAULT_CAPTURE_RULES } from './capture/rules.js';
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
import type { CaptureRules } from '../shared/protocol/index.js';
let transport: VeloxTransport | undefined;
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
const stash = new HeaderStash();
const hook = new CaptureHook({
offer: (params, opts) => mustTransport().call('capture.offer', params, opts),
stash,
getCookies: (url) => browser.cookies.getAll({ url }),
getRules: () => rules,
origin: new URL(browser.runtime.getURL('/')).origin,
});
function mustTransport(): VeloxTransport {
if (!transport) throw new Error('transport not ready');
return transport;
}
async function refreshRules(): Promise<void> {
try {
rules = await mustTransport().call('capture.getRules', {});
} catch {
// Keep the last known rules; capture stays fail-open regardless (docs/05 §2).
}
}
function onTransportState(status: TransportStatus): void {
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
console.debug(`[velox] transport ${status.state}${detail ? `${detail}` : ''}`);
if (status.state === 'connected') void refreshRules();
}
async function start(): Promise<void> {
stash.attach(browser.webRequest as unknown as WebRequestLike);
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
transport = await createTransport();
transport.onStateChange(onTransportState);
transport.on('event.settings.changed', (payload) => {
const keys = (payload as { keys?: string[] }).keys ?? [];
if (keys.some((k) => k.startsWith('capture.'))) void refreshRules();
});
onTransportState(transport.status);
}