From 22acb0d5d730379fbe49e9e491f446e11425190e Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:10:11 -0400 Subject: [PATCH] fix(cli): reject invalid UTF-8 JSON file bytes (#613) * fix(cli): reject invalid UTF-8 JSON file bytes Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> * test(browser): preserve unknown with-scope loader bindings Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --------- Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- src/application/JsonFiles.ts | 6 ++- src/cliJsonInput.ts | 12 ++++-- .../boundary/filesystem/jsonUtf8Input.test.ts | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/filesystem/jsonUtf8Input.test.ts diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index 1b6af75eb..ca8ff6771 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -17,7 +17,11 @@ export const readJsonFile = async ( const encoded = await readFile(canonicalPath); let decoded: unknown; try { - decoded = JSON.parse(encoded.toString("utf8")); + decoded = JSON.parse( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + encoded, + ), + ); } catch (cause: unknown) { return err(new EvidenceFileError("read", "invalid-json", { cause })); } diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index 2eaa2e24c..ca3f2eacf 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -16,7 +16,7 @@ export const parseCliJsonInput = async ( if (["{", "["].includes(value.trimStart()[0] ?? "")) return { ok: false, error: inputError(operation) }; try { - const parsed = parseJson(await readFile(value, "utf8")); + const parsed = parseJson(await readFile(value)); return parsed === undefined ? jsonFileError(value, operation, "invalid-json") : { ok: true, value: parsed }; @@ -25,9 +25,15 @@ export const parseCliJsonInput = async ( } }; -const parseJson = (value: string): unknown => { +const parseJson = (value: string | Uint8Array): unknown => { try { - return JSON.parse(value); + return JSON.parse( + typeof value === "string" + ? value + : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + value, + ), + ); } catch { return undefined; } diff --git a/tests/boundary/filesystem/jsonUtf8Input.test.ts b/tests/boundary/filesystem/jsonUtf8Input.test.ts new file mode 100644 index 000000000..c5274bd15 --- /dev/null +++ b/tests/boundary/filesystem/jsonUtf8Input.test.ts @@ -0,0 +1,43 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readJsonFile } from "../../../src/application/JsonFiles.js"; +import { parseCliJsonInput } from "../../../src/cliJsonInput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +describe("JSON file UTF-8 byte integrity", () => { + it.each([0x80, 0xe9, 0xff])( + "rejects invalid UTF-8 byte %s through both file input boundaries", + async (invalidByte) => { + const root = await createTestTempDirectory("rea-json-utf8-"); + const path = join(root, "input.json"); + await writeFile( + path, + Buffer.concat([ + Buffer.from('{"message":"'), + Buffer.from([invalidByte]), + Buffer.from('"}'), + ]), + ); + const fileInput = await readJsonFile(path); + const cliInput = await parseCliJsonInput(path, "compare_web_captures"); + expect(fileInput).toMatchObject({ + ok: false, + error: { reason: "invalid-json" }, + }); + expect(cliInput).toMatchObject({ + ok: false, + error: { input_reason: "invalid-json" }, + }); + }, + ); + it("preserves a legitimate UTF-8 replacement character", async () => { + const root = await createTestTempDirectory("rea-json-utf8-control-"); + const path = join(root, "input.json"); + const value = { message: "valid � and é and 😀" }; + await writeFile(path, JSON.stringify(value), "utf8"); + const fileInput = await readJsonFile(path); + const cliInput = await parseCliJsonInput(path, "compare_web_captures"); + expect(fileInput).toMatchObject({ ok: true, value }); + expect(cliInput).toMatchObject({ ok: true, value }); + }); +});