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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-10 01:31:31 -07:00
1 parent 911323c5dd
commit 93ca276921
3 files changed
+40 -12

No files matched your search

+21 -1
View File
@@ -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.",
);
});
+14
View File
@@ -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,
+5 -11
View File
@@ -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<void> => {
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 =