2d18f733b547
bound peachscript parser before normalize/decode and guard file.size before file.text() (PB-26-208, v5.0.64)
- time
- 2026-09-03 18:01 UTC · 1 month ago
- machine
- koh2
- agent
- muse-spark-1.3-contributor-free
- session
- PB-26-208 peachscript limits
- parent
- 26e37ef0c30c
↓ peach-browser-2d18f733.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/peachScript.bounds.test.ts+71 −0src/crypto/peachScript.test.ts+8 −5src/crypto/peachScript.ts+104 −24src/popup/views/Import.tsx+3 −2src/popup/views/Lock.test.tsx+128 −0src/popup/views/Lock.tsx+17 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.63",4+ "version": "5.0.64",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.63",5+ "version": "5.0.64",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.63",3+ "version": "5.0.64",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/crypto/peachScript.bounds.test.ts+71 −0
@@ -0,0 +1,71 @@1+// PB-26-208: PeachScript normalization before limits.2+//3+// normalize-then-decode allocated unbounded intermediates (regex copy,4+// Array.from) before any ceiling, and decode measured UTF-16 length — not5+// decoded bytes — against the export ceiling. One bounded parser replaces6+// the sequence: raw code points are capped at ceiling + a small pasted-7+// whitespace allowance, then a single scan skips whitespace, validates8+// every other code point, and stops once the decoded ceiling is crossed.9+import { describe, expect, test, vi } from "vitest";10+import { decodePeachScript, isPeachScript } from "./peachScript";11+import { IMPORT_LIMITS } from "../shared/import-limits";12+13+// Mirror of the parser ceilings: floor(MAX_JSON_BYTES / 3) decoded code14+// points (each PUA glyph costs 3 UTF-8 bytes, matching the export ceiling)15+// plus a 64 KiB raw-whitespace allowance for pasted text.16+const MAX_CP = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3);17+const ALLOWANCE = 64 * 1024;18+const MAX_RAW = MAX_CP + ALLOWANCE;19+const GLYPH = String.fromCodePoint(0xe041);20+21+describe("PB-26-208 bounded PeachScript parser", () => {22+ test("whitespace-only input over ceiling + allowance fails as too large without Array.from", () => {23+ const fromSpy = vi.spyOn(Array, "from");24+ try {25+ expect(() => decodePeachScript(" ".repeat(MAX_RAW + 1))).toThrow(26+ "PEACHSCRIPT_TOO_LARGE",27+ );28+ expect(29+ fromSpy.mock.calls.some(30+ (args) => typeof args[0] === "string" && args[0].length > MAX_RAW,31+ ),32+ "decode must not Array.from an over-ceiling input",33+ ).toBe(false);34+ } finally {35+ fromSpy.mockRestore();36+ }37+ });38+39+ test("valid script at the encoded ceiling decodes; one more glyph fails", () => {40+ const atCeiling = GLYPH.repeat(MAX_CP);41+ expect(decodePeachScript(atCeiling).length).toBe(MAX_CP);42+ expect(() => decodePeachScript(`${atCeiling}${GLYPH}`)).toThrow(43+ "PEACHSCRIPT_TOO_LARGE",44+ );45+ });46+47+ test("whitespace allowance boundary: allowance passes, allowance + 1 fails", () => {48+ expect(decodePeachScript(" ".repeat(ALLOWANCE) + GLYPH).length).toBe(1);49+ expect(() => decodePeachScript(" ".repeat(ALLOWANCE + 1) + GLYPH)).toThrow(50+ "PEACHSCRIPT_TOO_LARGE",51+ );52+ });53+54+ test("lone surrogates and astral characters throw without partial output", () => {55+ expect(() => decodePeachScript(`${GLYPH}\ud800${GLYPH}`)).toThrow(56+ "INVALID_PEACHSCRIPT_CHARACTER",57+ );58+ expect(() => decodePeachScript("\udc00")).toThrow(59+ "INVALID_PEACHSCRIPT_CHARACTER",60+ );61+ expect(() => decodePeachScript(`${GLYPH}\u{1D2E0}`)).toThrow(62+ "INVALID_PEACHSCRIPT_CHARACTER",63+ );64+ });65+66+ test("isPeachScript shares the bound: oversized input is simply invalid", () => {67+ expect(isPeachScript(" ".repeat(MAX_RAW + 1))).toBe(false);68+ expect(isPeachScript(GLYPH.repeat(MAX_CP + 1))).toBe(false);69+ expect(isPeachScript(` ${GLYPH}\n`)).toBe(true);70+ });71+});src/crypto/peachScript.test.ts+8 −5
@@ -189,17 +189,20 @@189189 );190190 });191191 192- // Whitespace — the raw decoder is strict; normalisation is a separate step193- test("rejects space between valid rune characters", () => {192+ // Whitespace — PB-26-208 merged normalisation into the bounded decoder, so193+ // ASCII whitespace is skipped (not rejected) anywhere in the string. BOM,194+ // ZWNJ, RTL marks and other Unicode space-class code points stay invalid195+ // (see the rejection tests below).196+ test("skips space between valid rune characters", () => {194197 const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));195198 const withSpace = encoded[0] + " " + encoded[1];196- expect(() => decodePeachScript(withSpace)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");199+ expect(decodePeachScript(withSpace)).toEqual(new Uint8Array([0x41, 0x42]));197200 });198201 199- test("rejects newline between valid rune characters", () => {202+ test("skips newline between valid rune characters", () => {200203 const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));201204 const withNewline = encoded[0] + "\n" + encoded[1];202- expect(() => decodePeachScript(withNewline)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");205+ expect(decodePeachScript(withNewline)).toEqual(new Uint8Array([0x41, 0x42]));203206 });204207 205208 // Boundary bytes must round-trip correctlysrc/crypto/peachScript.ts+104 −24
@@ -32,6 +32,33 @@3232 */3333 const UTF8_BYTES_PER_CODE_POINT = 3;3434 35+// PB-26-208: the decoded ceiling in code points. Matches the export ceiling —36+// floor(MAX_JSON_BYTES / 3) glyphs each cost 3 UTF-8 bytes.37+const MAX_PEACHSCRIPT_CODE_POINTS = Math.floor(38+ IMPORT_LIMITS.MAX_JSON_BYTES / UTF8_BYTES_PER_CODE_POINT,39+);40+41+// PB-26-208: a small explicit allowance for whitespace picked up by pasted42+// text (editor line-wrapping, trailing newlines). Peach-generated scripts43+// contain no bulk whitespace, so anything past this is hostile padding.44+const PEACHSCRIPT_WHITESPACE_ALLOWANCE = 64 * 1024;45+46+// Hard cap on the raw input length (glyphs + whitespace) checked before any47+// scan or allocation. A whitespace-only blob over this is rejected as too48+// large without touching the string beyond its length.49+const MAX_RAW_PEACHSCRIPT =50+ MAX_PEACHSCRIPT_CODE_POINTS + PEACHSCRIPT_WHITESPACE_ALLOWANCE;51+52+/**53+ * Whitespace the bounded scanner skips. Deliberately ASCII-only: BOM54+ * (U+FEFF), ZWNJ/ZWJ, RTL/LTR marks and other Unicode space-class code points55+ * stay INVALID so the fuzz-corpus rejection tests keep holding. Real pasted56+ * text only carries spaces, tabs, CR, LF.57+ */58+function isPeachScriptWhitespace(cp: number): boolean {59+ return cp === 0x20 || (cp >= 0x09 && cp <= 0x0d);60+}61+3562 /** Encode a binary payload to a PeachScript string (one byte per code point). */3663 export function encodePeachScript(bytes: Uint8Array): string {3764 if (bytes.length * UTF8_BYTES_PER_CODE_POINT > IMPORT_LIMITS.MAX_JSON_BYTES) {@@ -45,43 +72,96 @@4572 }4673 4774 /**48- * Decode a PeachScript string back to bytes. Throws49- * `INVALID_PEACHSCRIPT_CHARACTER` when any character is outside the50- * allotted PUA range — i.e. the string was edited, truncated mid-pair,51- * or is not PeachScript at all.75+ * PB-26-208: the single bounded scanner shared by decode and validate.76+ *77+ * The raw input is capped at MAX_RAW_PEACHSCRIPT (glyphs + whitespace) by78+ * length alone — a whitespace-padded blob is rejected before any per-code79+ * point work. The scan then skips ASCII whitespace, validates every other80+ * code point against the PUA glyph range, and throws PEACHSCRIPT_TOO_LARGE81+ * as soon as either the whitespace allowance or the decoded glyph ceiling is82+ * crossed. Only after the full pass does the decode mode allocate the83+ * output — sized to the counted glyphs, never above the ceiling.84+ *85+ * `decode` selects throwing + byte output; `!decode` returns false on any86+ * rejection (used by isPeachScript so the import UI's validity gate never87+ * throws on hostile input).5288 */53-export function decodePeachScript(str: string): Uint8Array {54- if (str.length > IMPORT_LIMITS.MAX_JSON_BYTES) {55- throw new Error("PEACHSCRIPT_TOO_LARGE");89+function scanPeachScript(str: string, decode: false): boolean;90+function scanPeachScript(str: string, decode: true): Uint8Array;91+function scanPeachScript(str: string, decode: boolean): Uint8Array | boolean {92+ if (str.length > MAX_RAW_PEACHSCRIPT) {93+ if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE");94+ return false;5695 }57- const codePoints = Array.from(str);58- const bytes = new Uint8Array(codePoints.length);59- for (let i = 0; i < codePoints.length; i++) {60- const cp = codePoints[i].codePointAt(0)!;61- if (cp < BASE || cp > BASE + 0xff) {62- throw new Error("INVALID_PEACHSCRIPT_CHARACTER");96+97+ let glyphCount = 0;98+ let wsCount = 0;99+ // Pass 1: validate + count. No output allocation — the ceiling is enforced100+ // here, so the output buffer is only ever allocated within it.101+ for (let i = 0; i < str.length; ) {102+ const cp = str.codePointAt(i)!;103+ const step = cp > 0xffff ? 2 : 1;104+ if (isPeachScriptWhitespace(cp)) {105+ wsCount += 1;106+ if (wsCount > PEACHSCRIPT_WHITESPACE_ALLOWANCE) {107+ if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE");108+ return false;109+ }110+ } else if (cp >= BASE && cp <= BASE + 0xff) {111+ glyphCount += 1;112+ if (glyphCount > MAX_PEACHSCRIPT_CODE_POINTS) {113+ if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE");114+ return false;115+ }116+ } else {117+ if (decode) throw new Error("INVALID_PEACHSCRIPT_CHARACTER");118+ return false;63119 }64- bytes[i] = cp - BASE;120+ i += step;65121 }122+123+ if (!decode) return glyphCount > 0;124+125+ // Pass 2: fill the now-bounded buffer. Every code point was validated in126+ // pass 1, so this is a pure copy of PUA glyphs to bytes.127+ const bytes = new Uint8Array(glyphCount);128+ let bi = 0;129+ for (let i = 0; i < str.length; ) {130+ const cp = str.codePointAt(i)!;131+ const step = cp > 0xffff ? 2 : 1;132+ if (cp >= BASE && cp <= BASE + 0xff) {133+ bytes[bi++] = cp - BASE;134+ }135+ i += step;136+ }66137 return bytes;67138 }68139 69-/** Strip whitespace from a PeachScript string before decoding. */140+/**141+ * Decode a PeachScript string back to bytes. Whitespace (ASCII space/tab/142+ * CR/LF/VT/FF) is skipped, so pasted text with editor line-wrapping decodes143+ * without a separate normalisation step. Throws `PEACHSCRIPT_TOO_LARGE`144+ * when the raw input exceeds the glyph ceiling plus the whitespace145+ * allowance, and `INVALID_PEACHSCRIPT_CHARACTER` when any non-whitespace146+ * code point is outside U+E000..U+E0FF — with no partial output.147+ */148+export function decodePeachScript(str: string): Uint8Array {149+ return scanPeachScript(str, true);150+}151+152+/** Strip whitespace from a PeachScript string before decoding.153+ * Kept for backward compatibility; decodePeachScript now skips whitespace154+ * internally (PB-26-208), so callers no longer need to pre-normalise. */70155 export function normalisePeachScript(str: string): string {71156 return str.replace(/\s+/g, "");72157 }73158 74159 /**75160 * Cheap pre-flight: every non-whitespace char must be in the PUA range and76- * the string must be non-empty. Used by the import UI to gate the decode77- * button without throwing.161+ * the string must be non-empty. Uses the same bounded scanner as decode so162+ * the import UI's validity gate agrees with the decoder on oversized or163+ * hostile input (returns false instead of throwing).78164 */79165 export function isPeachScript(str: string): boolean {80- const trimmed = normalisePeachScript(str);81- if (trimmed.length === 0) return false;82- for (const ch of trimmed) {83- const cp = ch.codePointAt(0)!;84- if (cp < BASE || cp > BASE + 0xff) return false;85- }86- return true;166+ return scanPeachScript(str, false);87167 }src/popup/views/Import.tsx+3 −2
@@ -11,7 +11,6 @@1111 import {1212 decodePeachScript,1313 isPeachScript,14- normalisePeachScript,1514 } from "../../crypto/peachScript";1615 import { parsePayload } from "../../crypto/peachPayload";1716 import { IMPORT_LIMITS } from "../../shared/import-limits";@@ -254,7 +253,9 @@254253 }255254 setWorking(true);256255 try {257- const bytes = decodePeachScript(normalisePeachScript(scriptInput()));256+ // PB-26-208: decodePeachScript skips whitespace internally — no257+ // separate normalise pass.258+ const bytes = decodePeachScript(scriptInput());258259 const payload = await parsePayload(bytes, scriptPass());259260 260261 if (isOnboarding) {src/popup/views/Lock.test.tsx+128 −0
@@ -0,0 +1,128 @@1+// @vitest-environment jsdom2+//3+// PB-26-208: every .peach entry path checks File.size before file.text() —4+// including drag/drop and the file input on the lock screen restore form.5+// The stub File's text() records the call, so "refused without reading it"6+// is directly observable rather than inferred.7+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";8+9+const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));10+vi.mock("../App", () => ({ sendMessage: mockSendMessage }));11+vi.mock("../components/RuneBackground", () => ({12+ RuneBackground: () => null,13+}));14+vi.mock("../components/RuneCanvas", () => ({ RuneCanvas: () => null }));15+16+import { IMPORT_LIMITS } from "../../shared/import-limits";17+import { LockView } from "./Lock";18+import { flushView, renderView } from "./view-test-utils";19+20+let mounted: Array<{ dispose: () => void }> = [];21+22+/**23+ * A File-shaped stub over the ceiling. `size` is the only field the guards24+ * read; `text()` is what must never be called.25+ */26+function oversizedFile(name: string): File & { readCalls: number } {27+ const file = {28+ name,29+ size: IMPORT_LIMITS.MAX_JSON_BYTES + 1,30+ type: "text/plain",31+ readCalls: 0,32+ async text() {33+ file.readCalls += 1;34+ return "";35+ },36+ async arrayBuffer() {37+ file.readCalls += 1;38+ return new ArrayBuffer(0);39+ },40+ } as unknown as File & { readCalls: number };41+ return file;42+}43+44+function renderSymbolsRestore() {45+ const view = renderView(() => (46+ <LockView47+ hasVault={true}48+ ready={true}49+ onUnlock={() => undefined}50+ initialRestore={true}51+ initialRestoreSource="symbols"52+ />53+ ));54+ mounted.push(view);55+ return view;56+}57+58+beforeEach(() => {59+ mounted = [];60+ mockSendMessage.mockReset();61+ mockSendMessage.mockImplementation(() => Promise.resolve({ ok: false }));62+ (globalThis as unknown as { chrome: unknown }).chrome = {63+ runtime: {64+ onMessage: { addListener: () => undefined, removeListener: () => undefined },65+ getURL: (path: string) => `chrome-extension://test/${path}`,66+ },67+ storage: {68+ local: {69+ get: () => Promise.resolve({}),70+ set: () => Promise.resolve(),71+ remove: () => Promise.resolve(),72+ },73+ session: {74+ get: () => Promise.resolve({}),75+ set: () => Promise.resolve(),76+ remove: () => Promise.resolve(),77+ },78+ },79+ };80+});81+82+afterEach(() => {83+ for (const view of mounted) view.dispose();84+ mounted = [];85+ vi.restoreAllMocks();86+});87+88+describe("PB-26-208: lock-screen .peach paths bound the file before reading it", () => {89+ it("drag/drop refuses an oversized file without reading it", async () => {90+ const view = renderSymbolsRestore();91+ await flushView();92+93+ const form = view.container.querySelector<HTMLElement>(".peach-restore-form");94+ expect(form, "expected the symbols restore form").not.toBeNull();95+96+ const file = oversizedFile("vault.peach");97+ const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {98+ dataTransfer?: { files: File[] };99+ };100+ drop.dataTransfer = { files: [file] };101+ form!.dispatchEvent(drop);102+ await flushView();103+104+ expect(file.readCalls).toBe(0);105+ expect(view.container.textContent).toContain("too large");106+ });107+108+ it("the file input refuses an oversized file without reading it", async () => {109+ const view = renderSymbolsRestore();110+ await flushView();111+112+ const picker = view.container.querySelector<HTMLInputElement>(113+ 'input[type="file"][accept=".peach,text/plain"]',114+ );115+ expect(picker, "expected a .peach file input").not.toBeNull();116+117+ const file = oversizedFile("vault.peach");118+ Object.defineProperty(picker!, "files", {119+ configurable: true,120+ get: () => [file],121+ });122+ picker!.dispatchEvent(new Event("change", { bubbles: true }));123+ await flushView();124+125+ expect(file.readCalls).toBe(0);126+ expect(view.container.textContent).toContain("too large");127+ });128+});src/popup/views/Lock.tsx+17 −2
@@ -6,9 +6,10 @@66 import { RuneBackground } from "../components/RuneBackground";77 import { RuneCanvas } from "../components/RuneCanvas";88 import { storageGet, storageRemove, storageSet } from "../../shared/storage";9-import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9+import { isPeachScript, decodePeachScript } from "../../crypto/peachScript";1010 import { parsePayload } from "../../crypto/peachPayload";1111 import { parseImportSource } from "../../shared/import";12+import { IMPORT_LIMITS } from "../../shared/import-limits";1213 1314 const peachMarkImg = new Image();1415 peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(@@ -922,6 +923,12 @@922923 if (working() || animating() !== null) return;923924 const file = e.dataTransfer?.files?.[0];924925 if (!file) return;926+ // PB-26-208: bound the file before file.text() — a .peach blob over the927+ // import ceiling is rejected without reading a byte.928+ if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {929+ setError("That file is too large.");930+ return;931+ }925932 try {926933 setScriptInput(await file.text());927934 setError(null);@@ -940,7 +947,9 @@940947 if (canvas) startChaos(canvas);941948 942949 try {943- const bytes = decodePeachScript(normalisePeachScript(scriptInput()));950+ // PB-26-208: decodePeachScript skips whitespace internally — no951+ // separate normalise pass.952+ const bytes = decodePeachScript(scriptInput());944953 const payload = await parsePayload(bytes, backupPass());945954 946955 if (!props.hasVault) {@@ -1037,6 +1046,12 @@10371046 onChange={async (e) => {10381047 const file = (e.currentTarget as HTMLInputElement).files?.[0];10391048 if (!file) return;1049+ // PB-26-208: bound the file before file.text().1050+ if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {1051+ setError("That file is too large.");1052+ (e.currentTarget as HTMLInputElement).value = "";1053+ return;1054+ }10401055 try {10411056 setScriptInput(await file.text());10421057 } catch {