cf426553736e
fix(security): CSV export neutralizes spreadsheet formula injection (MEDIUM #8); 2FA toast no longer renders the live code into page-readable DOM (LOW #10); password generator uses rejection sampling to remove modulo bias (INFO #12) (v3.6.128)
- time
- 2026-05-28 22:42 UTC · 5 months ago
- machine
- koh2
- parent
- c9f2283eb546
↓ peach-browser-cf426553.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/index.ts+4 −1src/popup/views/Export.csv.test.ts+24 −0src/popup/views/Export.tsx+11 −1src/shared/generate.test.ts+10 −0src/shared/generate.ts+12 −3manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.127",5+ "version": "3.6.128",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.127",4+ "version": "3.6.128",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.127",3+ "version": "3.6.128",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/content/index.ts+4 −1
@@ -360,7 +360,10 @@360360 if (filledTOTPField) {361361 showToast("2FA code filled");362362 } else if (alreadyWritten || (response as { ok?: boolean }).ok) {363- showToast("TOTP copied · " + code);363+ // Don't render the live code into the light-DOM toast — the page can read364+ // it from the DOM and consume the one-time code (audit LOW). The code is365+ // already on the clipboard; just confirm.366+ showToast("2FA code copied");364367 } else {365368 showToast("Could not copy TOTP — clipboard blocked");366369 }src/popup/views/Export.csv.test.ts+24 −0
@@ -0,0 +1,24 @@1+// @vitest-environment jsdom2+import { describe, expect, it } from "vitest";3+4+import { csvSafe } from "./Export";5+6+describe("csvSafe — CSV/spreadsheet formula-injection neutralization (audit MEDIUM)", () => {7+ it("prefixes formula-trigger leading characters with a quote", () => {8+ for (const lead of ["=", "+", "-", "@", "\t", "\r"]) {9+ expect(csvSafe(lead + "HYPERLINK(\"http://evil\")")).toBe("'" + lead + "HYPERLINK(\"http://evil\")");10+ }11+ });12+13+ it("neutralizes the classic DDE / exfiltration payloads", () => {14+ expect(csvSafe('=cmd|\'/c calc\'!A1')).toBe("'=cmd|'/c calc'!A1");15+ expect(csvSafe('@SUM(A1:A9)')).toBe("'@SUM(A1:A9)");16+ });17+18+ it("leaves ordinary values untouched", () => {19+ expect(csvSafe("[email protected]")).toBe("[email protected]"); // @ not leading20+ expect(csvSafe("https://github.com")).toBe("https://github.com");21+ expect(csvSafe("correct horse battery staple")).toBe("correct horse battery staple");22+ expect(csvSafe("")).toBe("");23+ });24+});src/popup/views/Export.tsx+11 −1
@@ -488,6 +488,16 @@488488 return { cards, passkeys, notes, logins };489489 }490490 491+// Neutralize spreadsheet formula injection (OWASP): Excel/Sheets/Calc execute492+// a cell that begins with = + - @ or a leading tab/CR as a formula, regardless493+// of CSV quoting. Vault fields can be attacker-influenced (a malicious site can494+// seed an entry username/notes), so a crafted =HYPERLINK/WEBSERVICE/DDE payload495+// in the exported CSV could exfiltrate other plaintext cells on open. Prefix496+// any such field with a single quote so the cell is treated as text (audit MEDIUM).497+export function csvSafe(value: string): string {498+ return /^[=+\-@\t\r]/.test(value) ? "'" + value : value;499+}500+491501 function buildCsv(entries: VaultEntry[]): string {492502 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];493503 const rows = entries.map((e) => {@@ -505,7 +515,7 @@505515 e.totp ?? "",506516 ]507517 .map((v) =>508- `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`518+ `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`509519 )510520 .join(",");511521 });src/shared/generate.test.ts+10 −0
@@ -73,4 +73,14 @@7373 const pw = generateSmartPassword({});7474 expect(pw).toHaveLength(20);7575 });76+77+ it("rejection sampling still yields exact length and only pool chars (audit INFO — modulo bias)", () => {78+ // The rejection loop must terminate and never under/over-fill regardless of79+ // how many CSPRNG draws land in the discarded high bucket.80+ for (let i = 0; i < 50; i++) {81+ const pw = generatePassword({ length: 64 });82+ expect(pw).toHaveLength(64);83+ expect(pw).toMatch(/^[A-Za-z0-9!@#$%^&*]+$/);84+ }85+ });7686 });src/shared/generate.ts+12 −3
@@ -37,10 +37,19 @@3737 3838 if (chars.length === 0) chars = filter("abcdefghijklmnopqrstuvwxyz") || "abcdefghijklmnopqrstuvwxyz";3939 40- const array = crypto.getRandomValues(new Uint32Array(length));40+ // Rejection sampling to eliminate modulo bias: discard CSPRNG values in the41+ // final partial bucket so every character is exactly uniform over `chars`42+ // (audit INFO — `% chars.length` alone slightly favours low indices when the43+ // alphabet size does not divide 2^32).44+ const limit = Math.floor(0x1_0000_0000 / chars.length) * chars.length;4145 let password = "";42- for (let i = 0; i < length; i++) {43- password += chars[array[i]! % chars.length];46+ while (password.length < length) {47+ const batch = crypto.getRandomValues(new Uint32Array(length - password.length));48+ for (let i = 0; i < batch.length && password.length < length; i++) {49+ const v = batch[i]!;50+ if (v >= limit) continue; // would bias the modulo — draw again51+ password += chars[v % chars.length];52+ }4453 }4554 return password;4655 }