Files
sudoku-tools/tests/formats/importSecurity.test.ts
T

161 lines
5.0 KiB
TypeScript

import fc from "fast-check";
import { compressToBase64, compressToEncodedURIComponent } from "lz-string";
import { describe, expect, it } from "vitest";
import {
LzStringOutputLimitError,
decompressFromBase64Bounded,
decompressFromBase64OrUriComponentBounded,
decompressFromEncodedURIComponentBounded,
} from "../../src/formats/boundedLz";
import {
MAX_DOCUMENT_BYTES,
PUZZLE_HASH_PREFIX,
SudokuFormatError,
decodePuzzleHash,
importFpuzzles,
importPuzzle,
importSudokuPad,
parseFpuzzles,
} from "../../src/formats";
function emptyGrid(size = 4) {
return Array.from({ length: size }, () =>
Array.from({ length: size }, () => ({})),
);
}
function expectLimitExceeded(action: () => unknown): void {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(SudokuFormatError);
expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" });
return;
}
throw new Error("Expected the import to reject an oversized payload.");
}
describe("bounded puzzle imports", () => {
it("keeps both LZ-String wire formats compatible for arbitrary text", () => {
fc.assert(
fc.property(fc.string({ maxLength: 512 }), (source) => {
const byteLength = new TextEncoder().encode(source).byteLength;
const limit = Math.max(1, byteLength);
expect(
decompressFromBase64Bounded(compressToBase64(source), limit),
).toBe(source);
expect(
decompressFromEncodedURIComponentBounded(
compressToEncodedURIComponent(source),
limit,
),
).toBe(source);
}),
{ numRuns: 100 },
);
});
it("counts UTF-8 bytes while expanding and stops before oversized output", () => {
const source = "🧩".repeat(1_024);
const compressed = compressToEncodedURIComponent(source);
const exactBytes = new TextEncoder().encode(source).byteLength;
expect(
decompressFromEncodedURIComponentBounded(compressed, exactBytes),
).toBe(source);
expect(() =>
decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1),
).toThrow(LzStringOutputLimitError);
});
it("selects URI-safe payloads before an invalid Base64 interpretation", () => {
const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR";
const grid = emptyGrid();
const fpuzzlesJson = JSON.stringify({ size: 4, grid, title });
const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson);
const sudokuPadJson = JSON.stringify({
id: "local-scl",
cells: grid,
metadata: {
title,
author: "Tester",
rules: "Normal rules apply.",
antiknight: true,
},
cages: [
{
cells: [
[0, 0],
[0, 1],
],
value: "3",
unique: true,
},
],
});
const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson);
expect(fpuzzlesPayload).toMatch(/[-$]/u);
expect(sudokuPadPayload).toMatch(/[-$]/u);
expect(
decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES),
).not.toBe(fpuzzlesJson);
expect(
decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES),
).not.toBe(sudokuPadJson);
expect(
decompressFromBase64OrUriComponentBounded(
fpuzzlesPayload,
MAX_DOCUMENT_BYTES,
),
).toBe(fpuzzlesJson);
expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title);
expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title);
});
it("rejects compressed bombs in every LZ-backed import format", () => {
const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1);
const base64 = compressToBase64(oversized);
const uri = compressToEncodedURIComponent(oversized);
expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`));
expectLimitExceeded(() => importSudokuPad(`ctc${base64}`));
expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`));
});
it("checks raw JSON character and byte limits before parsing", async () => {
const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`;
await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({
code: "LIMIT_EXCEEDED",
});
const tooManyUtf8Bytes = `{"future":"${"é".repeat(
Math.floor(MAX_DOCUMENT_BYTES / 2),
)}"}`;
expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES);
await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({
code: "LIMIT_EXCEEDED",
});
});
it("rejects unknown fpuzzles inequality markers", () => {
for (const value of [undefined, "≤", "left", 1]) {
expect(() =>
parseFpuzzles({
size: 4,
grid: emptyGrid(),
inequality: [{ cells: ["R1C1", "R1C2"], value }],
}),
).toThrow(/inequality\.value must be either/u);
}
expect(
parseFpuzzles({
size: 4,
grid: emptyGrid(),
inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }],
}).constraints,
).toContainEqual({ type: "inequality", lesser: 0, greater: 1 });
});
});