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>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-05 14:10:11 -04:00 committed by GitHub
parent 1bd641d39f
commit 22acb0d5d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 57 additions and 4 deletions

View File

@ -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 }));
}

View File

@ -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;
}

View File

@ -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 <20> 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 });
});
});