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
↓ peach-browser-7b5651a8.tar.gzatlas at this save
files changed8 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 −0manifest.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": {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",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",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 });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);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", () => {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++) {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) {