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
8 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 −3
manifest.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": {

view file

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

view file

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": {

view file

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     }

view file

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

view file

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

view file

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

view file

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 }

view file