From 93ca276921cb6bd1ebfa0431369633c0a428bde5 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:31:31 -0700 Subject: [PATCH] fix(browser): accept HAR files that begin with a byte-order mark (#1509) HAR 1.2 lets writers emit a UTF-8 byte-order mark and requires readers to ignore it. The decoder passed ignoreBOM: true to TextDecoder, which keeps the mark in the decoded text, so a BOM-prefixed export failed as "HAR is malformed JSON." Decoding now uses the default, which skips one leading mark. The artifact digest still covers the selected file bytes. Co-authored-by: Claude Opus 5.5 --- src/browser/history/HarCapture.test.ts | 22 +++++++++++++++++++++- src/browser/history/HarCapture.ts | 14 ++++++++++++++ src/browser/history/HarCaptureProcess.ts | 16 +++++----------- 3 files changed, 40 insertions(+), 12 deletions(-) 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 =