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
9 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 −2
manifest.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": {

view file

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",

view file

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",

view file

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

view file

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 correctly

view file

src/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 }

view file

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) {

view file

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

view file

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 {

view file