fix(browser): bound source-map fetches and retain predecessor requests

This commit is contained in:
morluto committed 2026-10-09 00:49:30 +08:00
1 parent 135d102f9d
commit eaf5734083
7 files changed
+406 -21

No files matched your search

+3 -1
View File
@@ -159,7 +159,9 @@ Disallowed-origin entries are filtered before retention.
CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited protocol events poison the connection so later commands fail closed.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
## Non-goals and threat model
+7 -1
View File
@@ -125,6 +125,13 @@ export const handleRequestWillBeSent = (
return;
}
if (state.malformedRedirectRequestIds.has(requestId)) return;
const previous = state.network.get(requestId);
if (
!Object.hasOwn(params, "redirectResponse") &&
previous !== undefined &&
preserveMalformedRedirectEvidence(state, requestId, previous)
)
return;
if (request === undefined) {
state.completeness.exclude("network_requests", "invalid_protocol_value");
state.network.delete(requestId);
@@ -141,7 +148,6 @@ export const handleRequestWillBeSent = (
state.networkRequestTimestamps.delete(requestId);
return;
}
const previous = state.network.get(requestId);
const redirectResponse = recordValue(params.redirectResponse);
if (
Object.hasOwn(params, "redirectResponse") &&
+208 -16
View File
@@ -28,8 +28,19 @@ export interface WebSourceMapRequest {
interface SourceMapFetchHost {
readonly fetch: typeof fetch;
/** Fetch-operation deadline; injectable so boundary regressions stay fast. */
readonly timeoutMs?: number;
/** Maximum response bytes retained across this operation. */
readonly maxResponseBytes?: number;
}
// Source maps are normally fetched as a small part of a larger inspection.
// Bound a stalled server to 30 seconds and bound raw map input retained for
// parsing to 64 MiB per inspection. Both limits apply at the network boundary;
// hitting either produces an explicit fetch_failed item, never truncated data.
const SOURCE_MAP_FETCH_TIMEOUT_MS = 30_000;
const SOURCE_MAP_RESPONSE_BYTES = 64 * 1024 * 1024;
type SourceMaps = WebSourceMaps;
type SourceMapItem = WebSourceMapItem;
type ParsedSourceMapItem = Extract<
@@ -45,9 +56,33 @@ export const fetchWebSourceMaps = async (
host: SourceMapFetchHost = { fetch: globalThis.fetch },
): Promise<SourceMaps> => {
const items: SourceMapItem[] = [];
for (const request of requests) {
if (signal?.aborted === true) throw signal.reason;
items.push(await fetchOne(request, input, signal, host));
const operationController = new AbortController();
const timeout = setTimeout(
() => operationController.abort(new SourceMapDeadlineError()),
host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS,
);
const abortFromCaller = (): void => operationController.abort(signal?.reason);
signal?.addEventListener("abort", abortFromCaller, { once: true });
const operationSignal = operationController.signal;
const budget = { retainedBytes: 0 };
try {
for (const request of requests) {
if (signal?.aborted === true) throw signal.reason;
if (operationSignal.aborted) {
items.push(
emptySourceMapItem(
request,
"fetch_failed",
"Source-map fetching exceeded its operation deadline.",
),
);
continue;
}
items.push(await fetchOne(request, input, operationSignal, host, budget));
}
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromCaller);
}
const retained = items.filter(
({ status }) => status === "included" || status === "partial",
@@ -68,11 +103,26 @@ export const fetchWebSourceMaps = async (
});
};
class SourceMapDeadlineError extends Error {
constructor() {
super("Source-map fetching exceeded its operation deadline.");
this.name = "SourceMapDeadlineError";
}
}
class SourceMapSizeLimitError extends Error {
constructor() {
super("Source-map response exceeded the retained-byte budget.");
this.name = "SourceMapSizeLimitError";
}
}
const fetchOne = async (
request: WebSourceMapRequest,
input: AnalyzeWebBundleInput,
signal: AbortSignal | undefined,
host: SourceMapFetchHost,
budget: { retainedBytes: number },
): Promise<SourceMapItem> => {
if (!approvedUrl(request.fetchUrl, input.allowed_origins))
return emptySourceMapItem(
@@ -102,13 +152,30 @@ const fetchOne = async (
`Source-map server returned HTTP ${String(response.status)}.`,
);
}
return normalizeSourceMap(request, await response.text(), fetchedUrl);
return normalizeSourceMap(
request,
await readBoundedText(
response,
budget,
host.maxResponseBytes ?? SOURCE_MAP_RESPONSE_BYTES,
signal,
),
fetchedUrl,
);
} catch (cause: unknown) {
if (signal?.aborted === true) throw cause;
if (
signal?.aborted === true &&
!(signal.reason instanceof SourceMapDeadlineError)
)
throw cause;
return emptySourceMapItem(
request,
"fetch_failed",
"Source-map fetch or validation failed.",
cause instanceof SourceMapSizeLimitError
? cause.message
: cause instanceof SourceMapDeadlineError
? cause.message
: "Source-map fetch or validation failed.",
);
}
};
@@ -122,19 +189,27 @@ const fetchFollowingApprovedRedirects = async (
let current = initialUrl;
const visited = new Set<string>();
for (;;) {
if (signal?.aborted === true) throw signal.reason;
if (!approvedUrl(current, allowedOrigins)) return undefined;
if (visited.has(current)) throw new Error("source_map_redirect_loop");
visited.add(current);
const response = await host.fetch(current, {
method: "GET",
headers: {
Accept: "application/json, application/source-map+json;q=0.9",
},
redirect: "manual",
credentials: "omit",
referrerPolicy: "no-referrer",
...(signal === undefined ? {} : { signal }),
});
const response = await promiseWithAbort(
host.fetch(current, {
method: "GET",
headers: {
Accept: "application/json, application/source-map+json;q=0.9",
},
redirect: "manual",
credentials: "omit",
referrerPolicy: "no-referrer",
...(signal === undefined ? {} : { signal }),
}),
signal,
);
if (signal !== undefined && signalIsAborted(signal)) {
await response.body?.cancel(signal.reason).catch(() => undefined);
throw signal.reason;
}
if (response.status < 300 || response.status >= 400)
return { response, fetchedUrl: current };
const location = response.headers.get("location");
@@ -144,6 +219,123 @@ const fetchFollowingApprovedRedirects = async (
}
};
const readBoundedText = async (
response: Response,
budget: { retainedBytes: number },
maxBytes: number,
signal: AbortSignal | undefined,
): Promise<string> => {
if (response.body === null) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
if (signal?.aborted === true) throw signal.reason;
const { done, value } = await readWithAbort(reader, signal);
if (done) break;
const nextBytes = value?.byteLength ?? 0;
if (budget.retainedBytes + bytes + nextBytes > maxBytes) {
await reader.cancel(new SourceMapSizeLimitError());
throw new SourceMapSizeLimitError();
}
if (value !== undefined) chunks.push(value);
bytes += nextBytes;
}
} finally {
// Aborting cancels the reader in readWithAbort. It owns the pending read
// until that cancellation settles, so releasing the lock here can throw.
if (signal?.aborted !== true) reader.releaseLock();
}
budget.retainedBytes += bytes;
const joined = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
joined.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(joined);
};
const readWithAbort = <T>(
reader: ReadableStreamDefaultReader<T>,
signal: AbortSignal | undefined,
): Promise<Awaited<ReturnType<ReadableStreamDefaultReader<T>["read"]>>> => {
if (signal === undefined) return reader.read();
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = (): void => signal.removeEventListener("abort", abort);
const abort = (): void => {
if (settled) return;
settled = true;
cleanup();
void reader.cancel(signal.reason).catch(() => undefined);
reject(signal.reason);
};
signal.addEventListener("abort", abort, { once: true });
void reader.read().then(
(value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
},
(cause: unknown) => {
if (settled) return;
settled = true;
cleanup();
void reader.cancel(cause).catch(() => undefined);
reject(cause);
},
);
});
};
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
const promiseWithAbort = (
promise: Promise<Response>,
signal: AbortSignal | undefined,
): Promise<Response> => {
if (signal === undefined) return promise;
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = (): void => signal.removeEventListener("abort", abort);
const abort = (): void => {
if (settled) return;
settled = true;
cleanup();
reject(signal.reason);
};
signal.addEventListener("abort", abort, { once: true });
void promise.then(
(response) => {
if (settled || signal.aborted) {
void response.body?.cancel(signal.reason).catch(() => undefined);
if (!settled) {
settled = true;
cleanup();
reject(signal.reason);
}
return;
}
settled = true;
cleanup();
resolve(response);
},
(cause: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(cause);
},
);
});
};
const normalizeSourceMap = (
request: WebSourceMapRequest,
text: string,
@@ -22,7 +22,7 @@ const inspectNetwork = async (options: FakeOptions) => {
return { browser, inspection: result.value };
};
describe("CdpBrowserProvider: redirect errors", () => {
describe("CdpBrowserProvider: redirect errors: redirect response validation", () => {
it.each([
{ label: "missing", url: undefined, reason: "invalid_protocol_value" },
{ label: "malformed", url: "http://%", reason: "invalid_protocol_value" },
@@ -113,6 +113,41 @@ describe("CdpBrowserProvider: redirect errors", () => {
);
},
);
});
describe("CdpBrowserProvider: redirect errors: preserving authorized predecessor evidence", () => {
it("preserves predecessor evidence when a continuation omits redirectResponse", async () => {
const { browser, inspection } = await inspectNetwork({
malformedRedirectResponse: true,
omitRedirectResponse: true,
});
expect(inspection.network.requests).toHaveLength(1);
expect(inspection.network.requests[0]).toMatchObject({
request_id: "request-1",
url: `${browser.allowedOrigin}/malformed-redirect-prior`,
status: 201,
redirects: [
{
url: `${browser.allowedOrigin}/api?token=network-secret`,
response_url: `${browser.allowedOrigin}/api?token=network-secret`,
},
],
body_shapes: { status: "partial", response: null },
});
expect(inspection.metadata.responses).toHaveLength(1);
expect(inspection.completeness.unavailable_sections).toContain(
"network_requests",
);
expect(inspection.completeness.excluded).toContainEqual({
section: "network_requests",
reason: "invalid_protocol_value",
count: expect.any(Number),
});
expect(JSON.stringify(inspection)).not.toContain(
"malformed-redirect-final",
);
});
it("keeps authorized prior evidence when the later response URL is disallowed", async () => {
const { browser, inspection } = await inspectNetwork({
@@ -100,7 +100,7 @@ describe("source-map redirect URL resolution", () => {
);
});
describe("web source-map fetching and validation", () => {
describe("web source-map fetching and validation: fetching maps and following redirects", () => {
it("fetches without credentials and derives mappings and original modules", async () => {
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
const map = JSON.stringify({
@@ -216,6 +216,155 @@ describe("web source-map fetching and validation", () => {
expect(calls).toHaveLength(8);
expect(result.items[0]?.status).toBe("included");
});
});
describe("web source-map fetching and validation: enforcing fetch limits and validating responses", () => {
it("settles an endless unique-URL redirect chain at the operation deadline", async () => {
let calls = 0;
const server = createServer((incoming, response) => {
calls += 1;
const next =
Number(
new URL(incoming.url ?? "/", "http://local").searchParams.get("n") ??
"0",
) + 1;
response.writeHead(302, { location: `/map?n=${String(next)}` }).end();
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string")
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/map?n=0`;
const result = await fetchWebSourceMaps(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({ allowed_origins: [localOrigin] }),
undefined,
{ fetch, timeoutMs: 100 },
);
expect(calls).toBeGreaterThan(1);
expect(result).toMatchObject({
status: "unavailable",
processed: 1,
items: [
{
status: "fetch_failed",
artifact: null,
limitation: "Source-map fetching exceeded its operation deadline.",
},
],
});
} finally {
server.closeAllConnections();
if (server.listening)
await new Promise<void>((resolve, reject) => {
server.close((error) =>
error === undefined ? resolve() : reject(error),
);
});
}
});
it("cancels a streamed response when its retained-byte budget is exceeded", async () => {
let responseClosed = false;
let markResponseClosed: (() => void) | undefined;
const responseClosedPromise = new Promise<void>((resolve) => {
markResponseClosed = resolve;
});
const server = createServer((_incoming, response) => {
response.on("close", () => {
responseClosed = true;
markResponseClosed?.();
});
response.writeHead(200, { "content-type": "application/json" });
response.write("x".repeat(1_024));
const interval = setInterval(() => response.write("x".repeat(1_024)), 2);
response.on("close", () => clearInterval(interval));
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string")
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/map`;
const result = await fetchWebSourceMaps(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({ allowed_origins: [localOrigin] }),
undefined,
{ fetch, maxResponseBytes: 512 },
);
let timeout: ReturnType<typeof setTimeout> | undefined;
const closeConfirmed = await Promise.race([
responseClosedPromise.then(() => true),
new Promise<boolean>((resolve) => {
timeout = setTimeout(() => resolve(false), 500);
}),
]);
if (timeout !== undefined) clearTimeout(timeout);
expect(closeConfirmed).toBe(true);
expect(responseClosed).toBe(true);
expect(result).toMatchObject({
status: "unavailable",
items: [
{
status: "fetch_failed",
artifact: null,
limitation:
"Source-map response exceeded the retained-byte budget.",
},
],
});
} finally {
server.closeAllConnections();
if (server.listening)
await new Promise<void>((resolve, reject) => {
server.close((error) =>
error === undefined ? resolve() : reject(error),
);
});
}
});
it("cancels a pending response reader on caller abort", async () => {
let markPullStarted: (() => void) | undefined;
const pullStarted = new Promise<void>((resolve) => {
markPullStarted = resolve;
});
let markCancelled: (() => void) | undefined;
const cancelled = new Promise<void>((resolve) => {
markCancelled = resolve;
});
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("{"));
},
pull() {
markPullStarted?.();
},
cancel() {
markCancelled?.();
},
});
const controller = new AbortController();
const abortReason = new Error("caller stopped source-map fetch");
const pending = fetchWebSourceMaps([request], input(), controller.signal, {
fetch: async () => new Response(body),
});
await pullStarted;
controller.abort(abortReason);
await expect(pending).rejects.toBe(abortReason);
await cancelled;
});
it("reports malformed source-map JSON as invalid", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
+1 -1
View File
@@ -403,7 +403,7 @@ const emitMalformedRedirect = (
requestId: "request-1",
type: "Fetch",
request: { url: finalUrl, method: "GET" },
redirectResponse,
...(options.omitRedirectResponse === true ? {} : { redirectResponse }),
timestamp: 9,
});
return finalUrl;
+1
View File
@@ -78,6 +78,7 @@ export interface FakeOptions {
readonly redirectFromDisallowedOrigin?: boolean;
readonly redirectWithinOrigin?: boolean;
readonly malformedRedirectResponse?: boolean;
readonly omitRedirectResponse?: boolean;
readonly redirectResponseUrl?: string;
readonly redirectResponseEnvelope?: unknown;
readonly responseAfterMalformedUrl?: string;