7b5651a88e18

fix: bound PeachScript decode at MAX_JSON_BYTES and enforce an http(s) URL scheme allow-list at the vault storage boundary (PB-26-011, PB-26-010) — javascript:/data: URLs dropped on add, restore, and patch; .peach file picks size-bounded before read; TDD regression tests; bump 5.0.9→5.0.10

time
2026-08-30 00:25 UTC · 1 month ago
machine
koh2
agent
glm-5.3-flash
session
pb-26 audit fixes
parent
5cd09213d011
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+65 −0src/background/vault.ts+21 −3src/crypto/peachScript.test.ts+23 −0src/crypto/peachScript.ts+8 −0src/popup/views/Import.tsx+6 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.9",4+  "version": "5.0.10",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.9",5+  "version": "5.0.10",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.9",3+  "version": "5.0.10",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/vault.test.ts+65 −0
@@ -1397,4 +1397,69 @@13971397       expect(vault.getSettings().theme).toBe("dark");13981398     });13991399   });1400+1401+  // PB-26-011: URL scheme allow-list at the storage boundary — imported,1402+  // added, or patched URLs with a non-http(s) scheme are dropped. Schemeless1403+  // values (e.g. "github.com") are kept: they are inert and pre-date this1404+  // guard in existing data.1405+  describe("PB-26-011 url scheme allow-list", () => {1406+    let vault: typeof import("./vault");1407+1408+    beforeEach(async () => {1409+      vault = await import("./vault");1410+      await vault.createVault("correct horse battery staple");1411+    });1412+1413+    it("drops a javascript: url on addEntry", async () => {1414+      const entry = await vault.addEntry({1415+        title: "Evil",1416+        username: "june",1417+        password: "secret1",1418+        url: "javascript:alert(1)",1419+      });1420+      const stored = vault.getEntryById(entry.id);1421+      expect(stored?.url).toBe("");1422+    });1423+1424+    it("drops non-http(s) schemes from additionalUrls", async () => {1425+      const entry = await vault.addEntry({1426+        title: "Evil",1427+        username: "june",1428+        password: "secret1",1429+        url: "https://ok.test",1430+        additionalUrls: [1431+          "javascript:alert(1)",1432+          "data:text/html,<b>x</b>",1433+          "https://also-ok.test",1434+        ],1435+      });1436+      const stored = vault.getEntryById(entry.id);1437+      expect(stored?.additionalUrls).toEqual(["https://also-ok.test"]);1438+    });1439+1440+    it("drops a data: url on UPDATE_ENTRY patch", async () => {1441+      const entry = await vault.addEntry({1442+        title: "Ok",1443+        username: "june",1444+        password: "secret1",1445+        url: "https://ok.test",1446+      });1447+      await vault.updateEntry(entry.id, { url: "data:text/html,<b>x</b>" });1448+      const stored = vault.getEntryById(entry.id);1449+      expect(stored?.url).toBe("");1450+    });1451+1452+    it("keeps https and schemeless urls", async () => {1453+      const entry = await vault.addEntry({1454+        title: "Ok",1455+        username: "june",1456+        password: "secret1",1457+        url: "https://github.com",1458+        additionalUrls: ["github.com"],1459+      });1460+      const stored = vault.getEntryById(entry.id);1461+      expect(stored?.url).toBe("https://github.com");1462+      expect(stored?.additionalUrls).toEqual(["github.com"]);1463+    });1464+  });14001465 });

view file

src/background/vault.ts+21 −3
@@ -2432,6 +2432,23 @@24322432   await refreshLockedSyncState();24332433 }24342434 2435+// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2436+// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2437+// pre-date this guard in existing data. Unparseable values are kept: the2438+// reviewed render paths never execute them (favicons via _favicon/, domains2439+// via tldts); this is defense-in-depth against future sinks.2440+function allowHttpUrl(url: string | undefined): string {2441+  const trimmed = url?.trim() ?? "";2442+  if (!trimmed) return "";2443+  try {2444+    const parsed = new URL(trimmed);2445+    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2446+  } catch {2447+    // No scheme to reject.2448+  }2449+  return trimmed;2450+}2451+24352452 function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {24362453   if (!Array.isArray(values)) return undefined;24372454   const seen = new Set<string>();@@ -2439,6 +2456,7 @@24392456   for (const value of values) {24402457     const trimmed = typeof value === "string" ? value.trim() : "";24412458     if (!trimmed) continue;2459+    if (!allowHttpUrl(trimmed)) continue;24422460     const key = trimmed.toLowerCase();24432461     if (seen.has(key)) continue;24442462     seen.add(key);@@ -2478,7 +2496,7 @@24782496     username: type === "login" ? entry.username?.trim() ?? "" : undefined,24792497     email: type === "login" ? entry.email?.trim() || undefined : undefined,24802498     password: type === "login" ? entry.password : undefined,2481-    url: type === "login" ? entry.url?.trim() ?? "" : undefined,2499+    url: type === "login" ? allowHttpUrl(entry.url) : undefined,24822500     additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,24832501     totp: type === "login" ? entry.totp?.trim() || undefined : undefined,24842502     // PCH-01-019: preserve non-default TOTP params through batch-add/import so@@ -2518,7 +2536,7 @@25182536   }25192537 25202538   if ("url" in patch) {2521-    nextPatch.url = patch.url?.trim() ?? "";2539+    nextPatch.url = allowHttpUrl(patch.url);25222540   }25232541 25242542   if ("additionalUrls" in patch) {@@ -2527,7 +2545,7 @@25272545     const cleaned: string[] = [];25282546     for (const value of incoming) {25292547       const trimmed = typeof value === "string" ? value.trim() : "";2530-      if (!trimmed) continue;2548+      if (!trimmed || !allowHttpUrl(trimmed)) continue;25312549       const key = trimmed.toLowerCase();25322550       if (seen.has(key)) continue;25332551       seen.add(key);

view file

src/crypto/peachScript.test.ts+23 −0
@@ -5,6 +5,29 @@55   isPeachScript,66   normalisePeachScript,77 } from "./peachScript";8+import { IMPORT_LIMITS } from "../shared/import-limits";9+10+describe("peachScript decode size bound (PB-26-010)", () => {11+  test("decode rejects input above MAX_JSON_BYTES before allocating", () => {12+    // Build an oversized valid PeachScript string without the spread-arg13+    // limit: repeat a 1K-char chunk past the 10 MiB bound.14+    const chunk = String.fromCodePoint(0xe000).repeat(1024);15+    const tooLarge = chunk.repeat(16+      Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 1024) + 1,17+    );18+    expect(tooLarge.length).toBeGreaterThan(IMPORT_LIMITS.MAX_JSON_BYTES);19+    expect(() => decodePeachScript(tooLarge)).toThrow(20+      "PEACHSCRIPT_TOO_LARGE",21+    );22+  });23+24+  test("decode accepts input at the bound", () => {25+    const atLimit = String.fromCodePoint(26+      ...Array.from({ length: 64 }, () => 0xe041),27+    );28+    expect(decodePeachScript(atLimit).length).toBe(64);29+  });30+});831 932 describe("peachScript", () => {1033   test("byte 0 maps to U+E000", () => {

view file

src/crypto/peachScript.ts+8 −0
@@ -15,6 +15,11 @@1515 1616 const BASE = 0xe000;1717 18+// PB-26-010: decode allocates one byte per code point, so bound the input at19+// the same 10 MiB ceiling parseImportSource applies to CSV/JSON — otherwise a20+// pasted or loaded string allocates without bound before any validation.21+import { IMPORT_LIMITS } from "../shared/import-limits";22+1823 /** Encode a binary payload to a PeachScript string (one byte per code point). */1924 export function encodePeachScript(bytes: Uint8Array): string {2025   const out: string[] = [];@@ -31,6 +36,9 @@3136  * or is not PeachScript at all.3237  */3338 export function decodePeachScript(str: string): Uint8Array {39+  if (str.length > IMPORT_LIMITS.MAX_JSON_BYTES) {40+    throw new Error("PEACHSCRIPT_TOO_LARGE");41+  }3442   const codePoints = Array.from(str);3543   const bytes = new Uint8Array(codePoints.length);3644   for (let i = 0; i < codePoints.length; i++) {

view file

src/popup/views/Import.tsx+6 −0
@@ -14,6 +14,7 @@1414   normalisePeachScript,1515 } from "../../crypto/peachScript";1616 import { parsePayload } from "../../crypto/peachPayload";17+import { IMPORT_LIMITS } from "../../shared/import-limits";1718 import type { VaultEntry } from "../../shared/types";1819 1920 type ImportSubview = "menu" | "peachscript" | "file";@@ -336,6 +337,11 @@336337                 const file = input.files?.[0];337338                 if (!file) return;338339                 try {340+                  // PB-26-010: bound before reading the file into a string.341+                  if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {342+                    setError("That file is too large.");343+                    return;344+                  }339345                   const text = await file.text();340346                   setScriptInput(text);341347                 } catch (err) {

view file