fix(browser): bound aggregate source-map decoding before expansion

This commit is contained in:
morluto
2026-10-09 05:15:23 +08:00
parent 3d450a29fc
commit f983c718c3
5 changed files with 394 additions and 79 deletions
+9
View File
@@ -91,6 +91,15 @@ preceding browser mappings when a nested section starts with a gap or has no
child leaves. Raw map text, original source identities and the dedicated decoder
profile are preserved.
Browser collection accepts up to 64 MiB of map responses per inspection and
preflights indexed sections, sources, names, generated rows and mapping segments
against a 262144-record decode budget before upstream flattening. Indexed maps
are limited to 64 nested levels. These structural limits bound decoded object
growth while preserving large embedded `sourcesContent` strings within the
response-byte budget; exceeding a decoder limit returns a `fetch_failed` item.
Malformed UTF-8 map responses are reported as invalid rather than repaired with
replacement characters.
Focused tests cover duplicate mappings, indexed offsets/overlap/local indexes,
32-bit VLQ overflow, nullable content, UTF-16/CRLF positions, immutable artifact
identity, cancellation, late output closure, cleanup failures, SDK schemas and
+100 -42
View File
@@ -12,7 +12,6 @@ import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js";
import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import { hasValidSourceMapContents } from "../domain/sourceMapContents.js";
import type {
AnalyzeWebBundleInput,
WebSourceMapItem,
@@ -22,9 +21,9 @@ import { webSourceMapsSchema } from "../domain/webBundleAnalysis.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
flattenSourceMapLeaves,
isVersion3Map,
} from "../domain/sourceMapEnvelope.js";
SourceMapFormatFailure,
validateSourceMapStructure,
} from "../javascript/sourceMaps/SourceMapFormat.js";
export interface WebSourceMapRequest {
readonly scriptKey: string;
@@ -53,6 +52,11 @@ type ParsedSourceMapItem = Extract<
SourceMapItem,
{ status: "included" | "partial" }
>;
interface SourceMapDecodeContext {
readonly signal: AbortSignal | undefined;
readonly deadlineAt: number;
readonly budget: { records: number };
}
/** Fetch and validate approved source maps without browser credentials. */
export const fetchWebSourceMaps = async (
@@ -70,11 +74,17 @@ export const fetchWebSourceMaps = async (
const abortFromCaller = (): void => operationController.abort(signal?.reason);
signal?.addEventListener("abort", abortFromCaller, { once: true });
const operationSignal = operationController.signal;
const budget = { retainedBytes: 0 };
const deadlineAt =
Date.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
const budget = {
retainedBytes: 0,
deadlineAt,
decodedRecords: { records: 0 },
};
try {
for (const request of requests) {
if (signal?.aborted === true) throw signal.reason;
if (operationSignal.aborted) {
if (operationSignal.aborted || Date.now() >= deadlineAt) {
items.push(
emptySourceMapItem(
request,
@@ -90,25 +100,28 @@ export const fetchWebSourceMaps = async (
clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromCaller);
}
const retained = items.filter(
({ status }) => status === "included" || status === "partial",
).length;
if (signal?.aborted === true) throw signal.reason;
return webSourceMapsSchema.parse({
status:
items.length === 0
? "unavailable"
: retained === items.length &&
!items.some(({ status }) => status === "partial")
? "included"
: retained > 0
? "partial"
: "unavailable",
status: sourceMapsStatus(items),
requested: requests.length,
processed: items.length,
items,
});
};
const sourceMapsStatus = (
items: readonly SourceMapItem[],
): SourceMaps["status"] => {
const retained = items.filter(
({ status }) => status === "included" || status === "partial",
).length;
if (items.length === 0 || retained === 0) return "unavailable";
return retained === items.length &&
!items.some(({ status }) => status === "partial")
? "included"
: "partial";
};
class SourceMapDeadlineError extends Error {
constructor() {
super("Source-map fetching exceeded its operation deadline.");
@@ -123,12 +136,23 @@ class SourceMapSizeLimitError extends Error {
}
}
class SourceMapEncodingError extends Error {
constructor() {
super("Source-map response is not valid UTF-8.");
this.name = "SourceMapEncodingError";
}
}
const fetchOne = async (
request: WebSourceMapRequest,
input: AnalyzeWebBundleInput,
signal: AbortSignal | undefined,
host: SourceMapFetchHost,
budget: { retainedBytes: number },
budget: {
retainedBytes: number;
deadlineAt: number;
decodedRecords: { records: number };
},
): Promise<SourceMapItem> => {
if (!approvedUrl(request.fetchUrl, input.allowed_origins))
return emptySourceMapItem(
@@ -158,6 +182,7 @@ const fetchOne = async (
`Source-map server returned HTTP ${String(response.status)}.`,
);
}
checkOperation(undefined, signal, budget.deadlineAt);
return normalizeSourceMap(
request,
await readBoundedText(
@@ -167,6 +192,11 @@ const fetchOne = async (
signal,
),
fetchedUrl,
{
signal,
deadlineAt: budget.deadlineAt,
budget: budget.decodedRecords,
},
);
} catch (cause: unknown) {
if (
@@ -174,6 +204,8 @@ const fetchOne = async (
!(signal.reason instanceof SourceMapDeadlineError)
)
throw cause;
if (cause instanceof SourceMapEncodingError)
return emptySourceMapItem(request, "invalid", cause.message);
return emptySourceMapItem(
request,
"fetch_failed",
@@ -260,7 +292,11 @@ const readBoundedText = async (
joined.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(joined);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(joined);
} catch {
throw new SourceMapEncodingError();
}
};
const readWithAbort = <T>(
@@ -346,13 +382,15 @@ type EncodedBrowserSourceMap = EncodedSourceMap | SectionedSourceMap;
/** Compose indexed offsets before upstream flattening, retaining parent clipping. */
const sourceMapWithAbsoluteSections = (
text: string,
value: unknown,
check: () => void,
): EncodedBrowserSourceMap => {
const root = JSON.parse(text) as EncodedBrowserSourceMap;
const root = value as EncodedBrowserSourceMap;
if (!("sections" in root)) return root;
const sections: Section[] = [];
const pending: Section[] = [{ map: root, offset: { line: 0, column: 0 } }];
while (pending.length > 0) {
check();
const node = pending.pop();
if (node === undefined) break;
if (!("sections" in node.map)) {
@@ -387,17 +425,31 @@ const normalizeSourceMap = (
request: WebSourceMapRequest,
text: string,
fetchedUrl: string,
context: SourceMapDecodeContext,
): SourceMapItem => {
if (!validSourceMapEnvelope(text))
const parsedJson = safeParseJson(text);
if (!parsedJson.ok)
return emptySourceMapItem(
request,
"invalid",
"Source-map JSON is not a version 3 map.",
);
try {
const map = new AnyMap(sourceMapWithAbsoluteSections(text), fetchedUrl);
const check = (): void =>
checkOperation(undefined, context.signal, context.deadlineAt);
validateSourceMapStructure(parsedJson.value, {
profile: "browser-collection",
budget: context.budget,
check,
});
check();
const map = new AnyMap(
sourceMapWithAbsoluteSections(parsedJson.value, check),
fetchedUrl,
);
const resolvedBySource = new Map<string, string>();
const originalSources = map.sources.map((source, index) => {
if ((index & 255) === 0) check();
const content = map.sourcesContent?.[index];
const resolved =
map.resolvedSources[index] ?? source ?? "[unknown-source]";
@@ -412,6 +464,7 @@ const normalizeSourceMap = (
});
const mappings: ParsedSourceMapItem["mappings"] = [];
eachMapping(map, (mapping) => {
if ((mappings.length & 0x3fff) === 0) check();
if (
mapping.source === null ||
mapping.originalLine === null ||
@@ -429,7 +482,8 @@ const normalizeSourceMap = (
name: mapping.name ?? null,
});
});
const modules = originalModuleEdges(originalSources);
const modules = originalModuleEdges(originalSources, check);
check();
const parsed = {
...sourceMapContext(request),
artifact: createWebTextArtifact(text, "application/source-map+json"),
@@ -437,6 +491,7 @@ const normalizeSourceMap = (
original_module_edges: modules.edges,
mappings,
};
check();
return modules.incomplete.length === 0
? { ...parsed, status: "included", limitation: null }
: {
@@ -445,11 +500,17 @@ const normalizeSourceMap = (
limitation: `Module edges are incomplete: ${modules.incomplete.length} of ${originalSources.filter(({ artifact }) => artifact !== null).length} original sources could not be parsed in full (${modules.incomplete.join(", ")}).`,
};
} catch (cause: unknown) {
void cause;
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit")
return emptySourceMapItem(request, "fetch_failed", cause.message);
if (
context.signal?.aborted === true ||
cause instanceof SourceMapDeadlineError
)
throw cause;
return emptySourceMapItem(
request,
"invalid",
"Source-map mappings could not be decoded safely.",
"Source-map JSON mappings could not be decoded safely.",
);
}
};
@@ -462,13 +523,16 @@ interface OriginalModuleEdges {
const originalModuleEdges = (
sources: ParsedSourceMapItem["original_sources"],
check: () => void,
): OriginalModuleEdges => {
const edges: ParsedSourceMapItem["original_module_edges"] = [];
const seen = new Set<string>();
const incomplete: string[] = [];
for (const source of sources) {
check();
if (source.artifact === null) continue;
const parsed = parseJavaScriptSource(source.artifact.text);
check();
if (parsed === null) {
incomplete.push(source.source);
continue;
@@ -479,6 +543,7 @@ const originalModuleEdges = (
let unboundRequires: ReadonlySet<string> | undefined;
traverseJavaScriptAst(parsed, {
enter: (node) => {
check();
const dependency = originalDependency(node);
if (dependency === null) return;
const { kind, specifier } = dependency;
@@ -518,23 +583,16 @@ const originalModuleEdges = (
return { edges, incomplete };
};
const validSourceMapEnvelope = (text: string): boolean => {
const parsedResult = safeParseJson(text);
if (!parsedResult.ok) return false;
const parsed: unknown = parsedResult.value;
if (isVersion3Map(parsed) && typeof parsed.mappings === "string")
return validSourceMapLeaf(parsed);
const leaves = flattenSourceMapLeaves(parsed, { validateOffsets: true });
if (leaves === undefined) return false;
return leaves.every(validSourceMapLeaf);
const checkOperation = (
callerSignal: AbortSignal | undefined,
operationSignal: AbortSignal | undefined,
deadlineAt: number,
): void => {
if (callerSignal?.aborted === true) throw callerSignal.reason;
if (operationSignal?.aborted === true) throw operationSignal.reason;
if (Date.now() >= deadlineAt) throw new SourceMapDeadlineError();
};
const validSourceMapLeaf = (map: Readonly<Record<string, unknown>>): boolean =>
typeof map.mappings === "string" &&
Array.isArray(map.sources) &&
Array.isArray(map.names) &&
hasValidSourceMapContents(map.sources.length, map.sourcesContent);
const approvedUrl = (
value: string,
allowedOrigins: readonly string[],
+1
View File
@@ -104,6 +104,7 @@ export const WEB_SOURCE_MAP_LIMITS = {
outputBytes: 32 * 1024 * 1024,
decodedRows: 262144,
decodedSegments: 262144,
decodedRecords: 262144,
sectionDepth: 64,
decodeTimeoutMs: 20000,
} as const;
+143 -37
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js";
const leafSchema = z.object({
version: z.literal(3),
@@ -38,6 +39,17 @@ interface PendingMap {
readonly stop: Offset | null;
}
/** Shared structural constraints configurable only for stricter consumers. */
export interface SourceMapStructureBudget {
records: number;
}
export interface SourceMapStructureOptions {
readonly profile?: "browser-collection";
readonly budget?: SourceMapStructureBudget;
readonly check?: () => void;
}
/** A decoder-profile failure with an actionable producer constraint. */
export class SourceMapFormatFailure extends Error {
constructor(
@@ -84,9 +96,32 @@ const addOffset = (base: Offset, offset: Offset): Offset => {
return result;
};
const addSections = (node: PendingMap, pending: PendingMap[]): void => {
const consumeRecords = (budget: { records: number }, count: number): void => {
if (count > WEB_SOURCE_MAP_LIMITS.decodedRecords - budget.records)
throw new SourceMapFormatFailure(
"limit",
`Source-map structure exceeds the ${WEB_SOURCE_MAP_LIMITS.decodedRecords}-record decoder budget.`,
);
budget.records += count;
};
const addSections = (
node: PendingMap,
pending: PendingMap[],
budget: { records: number },
options: SourceMapStructureOptions,
): void => {
options.check?.();
const header = mapHeaderSchema.parse(node.value);
if (!Array.isArray(header.sections))
throw new SourceMapFormatFailure(
"format",
"Indexed source-map sections must be an array.",
);
consumeRecords(budget, header.sections.length);
const sections = indexedSchema.parse(node.value).sections;
for (const [index, section] of sections.entries()) {
if ((index & 255) === 0) options.check?.();
const previous = sections[index - 1];
if (previous !== undefined && compare(section.offset, previous.offset) <= 0)
throw new SourceMapFormatFailure(
@@ -133,6 +168,7 @@ const addSections = (node: PendingMap, pending: PendingMap[]): void => {
const inspectMappingLayout = (
mappings: string,
check?: () => void,
): { lines: number; segments: number } => {
let lines = 1;
let segments = 0;
@@ -152,7 +188,9 @@ const inspectMappingLayout = (
"Source-map segment requires one, four or five VLQ components.",
);
};
let index = 0;
for (const character of mappings) {
if ((index++ & 0x3fff) === 0) check?.();
if (character === "," || character === ";") {
finish();
inSegment = false;
@@ -189,6 +227,58 @@ const inspectMappingLayout = (
return { lines, segments };
};
const inspectLeaf = (
node: PendingMap,
budget: { records: number },
options: SourceMapStructureOptions,
): z.output<typeof leafSchema> => {
options.check?.();
consumeRecords(budget, 1);
if (
typeof node.value !== "object" ||
node.value === null ||
Array.isArray(node.value)
)
return leafSchema.parse(node.value);
const rawMap = node.value as Readonly<Record<string, unknown>>;
const rawSources = rawMap.sources;
const rawNames = rawMap.names;
const rawContents = rawMap.sourcesContent;
consumeRecords(budget, Array.isArray(rawSources) ? rawSources.length : 0);
consumeRecords(budget, Array.isArray(rawNames) ? rawNames.length : 0);
consumeRecords(budget, Array.isArray(rawContents) ? rawContents.length : 0);
options.check?.();
if (options.profile === "browser-collection" && !Array.isArray(rawNames))
throw new SourceMapFormatFailure(
"format",
"Source-map leaf requires a names array.",
);
if (
options.profile === "browser-collection" &&
!hasValidSourceMapContents(
Array.isArray(rawSources) ? rawSources.length : 0,
rawContents,
)
)
throw new SourceMapFormatFailure(
"format",
"Source-map sourcesContent must align with its source declarations.",
);
options.check?.();
const leaf = leafSchema.parse(node.value);
const layout = inspectMappingLayout(leaf.mappings, options.check);
consumeRecords(budget, layout.segments + layout.lines);
if (
layout.segments > WEB_SOURCE_MAP_LIMITS.decodedSegments ||
node.offset.line + layout.lines > WEB_SOURCE_MAP_LIMITS.decodedRows
)
throw new SourceMapFormatFailure(
"limit",
"Source-map layout exceeds the 262144 segment/generated-row decoder budget.",
);
return leaf;
};
/** Validate structure before allocation, retaining raw declarations and section boundaries. */
export const inspectSourceMap = (
text: string,
@@ -204,14 +294,51 @@ export const inspectSourceMap = (
? withoutBom.slice(withoutBom.indexOf("\n") + 1)
: withoutBom;
const value: unknown = JSON.parse(jsonText);
const inspected = inspectSourceMapValue(value);
const normalized =
inspected.format === "regular"
? inspected.leaves[0]?.map
: {
version: 3,
sections: inspected.leaves.map((leaf) => ({
offset: leaf.offset,
map: leaf.map,
})),
};
return {
jsonText: JSON.stringify(normalized),
...inspected,
};
};
/** Validate shared source-map structure before a consumer decodes mappings. */
export const validateSourceMapStructure = (
value: unknown,
options: SourceMapStructureOptions = {},
): void => {
inspectSourceMapValue(value, options, false);
};
const inspectSourceMapValue = (
value: unknown,
options: SourceMapStructureOptions = {},
collect = true,
): {
readonly format: "regular" | "indexed";
readonly reportedFile: string | null;
readonly declarations: readonly SourceMapSourceDeclaration[];
readonly leaves: readonly SourceMapLeaf[];
} => {
options.check?.();
const root = mapHeaderSchema.parse(value);
const declarations: SourceMapSourceDeclaration[] = [];
const leaves: SourceMapLeaf[] = [];
const pending: PendingMap[] = [
{ value, path: [], offset: { line: 0, column: 0 }, stop: null },
];
let segmentCount = 0;
const budget = options.budget ?? { records: 0 };
while (pending.length > 0) {
options.check?.();
const node = pending.pop();
if (node === undefined) break;
if (node.path.length > WEB_SOURCE_MAP_LIMITS.sectionDepth)
@@ -220,47 +347,26 @@ export const inspectSourceMap = (
"Indexed source-map nesting exceeds the 64-level decoder stack budget.",
);
if (mapHeaderSchema.parse(node.value).sections !== undefined) {
addSections(node, pending);
addSections(node, pending, budget, options);
continue;
}
const leaf = leafSchema.parse(node.value);
const layout = inspectMappingLayout(leaf.mappings);
segmentCount += layout.segments;
if (
segmentCount > WEB_SOURCE_MAP_LIMITS.decodedSegments ||
node.offset.line + layout.lines > WEB_SOURCE_MAP_LIMITS.decodedRows
)
throw new SourceMapFormatFailure(
"limit",
"Source-map layout exceeds the 262144 segment/generated-row decoder budget.",
);
leaves.push({ map: leaf, offset: node.offset, stop: node.stop });
const leaf = inspectLeaf(node, budget, options);
if (collect)
leaves.push({ map: leaf, offset: node.offset, stop: node.stop });
for (const [sourceIndex, source] of leaf.sources.entries())
declarations.push({
sectionPath: node.path,
sourceIndex,
source,
root: leaf.sourceRoot ?? null,
content: leaf.sourcesContent?.[sourceIndex] ?? null,
ignored: (leaf.ignoreList ?? leaf.x_google_ignoreList ?? []).includes(
if (collect)
declarations.push({
sectionPath: node.path,
sourceIndex,
),
});
source,
root: leaf.sourceRoot ?? null,
content: leaf.sourcesContent?.[sourceIndex] ?? null,
ignored: (leaf.ignoreList ?? leaf.x_google_ignoreList ?? []).includes(
sourceIndex,
),
});
}
// Absolute leaves adapt nested first-line column offsets before upstream AnyMap.
// Raw map bytes and the original section/source identities are retained separately.
const normalized =
root.sections === undefined
? leaves[0]?.map
: {
version: 3,
sections: leaves.map((leaf) => ({
offset: leaf.offset,
map: leaf.map,
})),
};
return {
jsonText: JSON.stringify(normalized),
format: root.sections === undefined ? "regular" : "indexed",
reportedFile: root.file ?? null,
declarations,
@@ -374,6 +374,52 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
});
});
describe("source-map response encoding", () => {
it("rejects malformed UTF-8 source-map response bytes", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(Uint8Array.of(0x7b, 0x22, 0xc3, 0x28, 0x22, 0x7d), {
status: 200,
}),
),
});
expect(result.status).toBe("unavailable");
expect(result.items[0]).toMatchObject({
status: "invalid",
artifact: null,
limitation: "Source-map response is not valid UTF-8.",
});
});
});
describe("source-map operation deadline observations", () => {
it("keeps completed maps when a later request times out", async () => {
let calls = 0;
const requests = [
request,
{ ...request, scriptKey: `scr_${"2".repeat(64)}` },
];
const result = await fetchWebSourceMaps(requests, input(), undefined, {
timeoutMs: 100,
fetch: () => {
calls += 1;
return calls === 1
? Promise.resolve(validMapResponse())
: new Promise<Response>(() => undefined);
},
});
expect(result.status).toBe("partial");
expect(result.items.map(({ status }) => status)).toEqual([
"included",
"fetch_failed",
]);
expect(result.items[0]?.original_sources).toHaveLength(1);
});
});
describe("indexed source-map offsets", () => {
it.each([
{ line: 0, column: -1 },
@@ -621,6 +667,76 @@ describe("source-map dependency coverage of hard-to-parse sources", () => {
});
describe("web source-map collection", () => {
it("rejects mapping expansion before the trace-mapping decoder allocates it", async () => {
const response = new Response(
JSON.stringify({
version: 3,
names: [],
sources: [],
mappings: Array.from({ length: 262_145 }, () => "A").join(","),
}),
{ status: 200 },
);
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () => Promise.resolve(response),
});
expect(result.status).toBe("unavailable");
expect(result.items[0]).toMatchObject({
status: "fetch_failed",
artifact: null,
});
expect(result.items[0]?.limitation).toContain("262144");
});
it("accounts for decoded records across the complete fetch operation", async () => {
const mapText = JSON.stringify({
version: 3,
names: [],
sources: [],
mappings: Array.from({ length: 150_000 }, () => "A").join(","),
});
const requests = [
request,
{ ...request, scriptKey: `scr_${"2".repeat(64)}` },
];
const result = await fetchWebSourceMaps(requests, input(), undefined, {
fetch: () => Promise.resolve(new Response(mapText, { status: 200 })),
});
expect(result.status).toBe("partial");
expect(result.items.map(({ status }) => status)).toEqual([
"included",
"fetch_failed",
]);
expect(result.items[1]?.limitation).toContain("262144-record");
});
it("rejects deeply nested indexed maps before recursive flattening", async () => {
let map: unknown = {
version: 3,
names: [],
sources: [],
mappings: "",
};
for (let index = 0; index < 65; index += 1)
map = {
version: 3,
sections: [{ offset: { line: 0, column: 0 }, map }],
};
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () =>
Promise.resolve(new Response(JSON.stringify(map), { status: 200 })),
});
expect(result.status).toBe("unavailable");
expect(result.items[0]).toMatchObject({
status: "fetch_failed",
artifact: null,
});
expect(result.items[0]?.limitation).toContain("64-level");
});
it("retains every mapping from a sectioned source map", async () => {
const segmentCount = 10_001;
const regular = {
@@ -693,6 +809,31 @@ describe("web source-map collection", () => {
});
});
describe("raw source-map structure preflight", () => {
it("budgets source inventory before schema parsing its entries", async () => {
const response = new Response(
JSON.stringify({
version: 3,
names: [],
sources: Array.from({ length: 262_145 }, (_, index) =>
index === 0 ? 42 : "source.js",
),
mappings: "",
}),
{ status: 200 },
);
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () => Promise.resolve(response),
});
expect(result.items[0]).toMatchObject({
status: "fetch_failed",
artifact: null,
});
expect(result.items[0]?.limitation).toContain("262144-record");
});
});
const validMapResponse = () =>
new Response(
JSON.stringify({