diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 4c29504a8a..2eb1fe21a0 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { expect, it } from "vitest"; import { historicalHar } from "../../../tests/fixtures/historicalHar.js"; -import { decodeHarCapture } from "./HarCapture.js"; +import { decodeHarCapture, decodeHarText } from "./HarCapture.js"; it.each([ { values: [] }, @@ -558,3 +558,23 @@ it.each([ }); }, ); + +it("ignores one leading byte-order mark, as HAR 1.2 requires of readers", () => { + const text = JSON.stringify(historicalHar()); + const body = Buffer.from(text, "utf8"); + const marked = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]); + expect(decodeHarText(marked)).toBe(text); + expect(decodeHarCapture(decodeHarText(marked), [])).toEqual( + decodeHarCapture(decodeHarText(body), []), + ); + // A second mark is content, not an encoding signature. + expect(() => + decodeHarCapture( + decodeHarText(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), marked])), + [], + ), + ).toThrow("HAR is malformed JSON."); + expect(() => decodeHarText(Buffer.from([0x7b, 0xff, 0x7d]))).toThrow( + "HAR is not valid UTF-8 JSON.", + ); +}); diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index ddc31567c0..f3a83af15d 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { TextDecoder } from "node:util"; import { isSafeNumber, LosslessNumber } from "lossless-json"; import { parseHarJson } from "./HarJson.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -25,6 +26,19 @@ type Projection = Pick< "reported" | "numeric_literals" | "binary_fields" | "redactions" >; +/** + * Decode retained HAR bytes as UTF-8. HAR 1.2 lets writers emit a byte-order + * mark and requires readers to ignore it, so one leading BOM is skipped. + */ +export const decodeHarText = (bytes: Uint8Array): string => { + try { + // ignoreBOM: false (the default) is what skips the mark; true keeps it. + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new CaptureFormatError("format", "HAR is not valid UTF-8 JSON."); + } +}; + /** Decode a retained HAR with exact numeric lexemes and unchanged upstream schema validation. */ export const decodeHarCapture = ( text: string, diff --git a/src/browser/history/HarCaptureProcess.ts b/src/browser/history/HarCaptureProcess.ts index 0751d0b80f..1e4c66c761 100644 --- a/src/browser/history/HarCaptureProcess.ts +++ b/src/browser/history/HarCaptureProcess.ts @@ -1,10 +1,9 @@ import { readFile, writeFile } from "node:fs/promises"; -import { TextDecoder } from "node:util"; import { z } from "zod"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js"; import { CaptureFormatError } from "./CaptureFormatError.js"; -import { decodeHarCapture } from "./HarCapture.js"; +import { decodeHarCapture, decodeHarText } from "./HarCapture.js"; const requestSchema = z.strictObject({ snapshot_path: z.string(), @@ -23,17 +22,12 @@ const main = async (): Promise => { request.snapshot_path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes, ); - let text: string; - try { - text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - snapshot.bytes, - ); - } catch { - throw new CaptureFormatError("format", "HAR is not valid UTF-8 JSON."); - } reply = { ok: true, - value: decodeHarCapture(text, request.sensitive_values), + value: decodeHarCapture( + decodeHarText(snapshot.bytes), + request.sensitive_values, + ), }; } catch (cause: unknown) { reply =