2da1a2aff52c
fix(security): inline locked bubble no longer collects the master password in page DOM — routes to the trusted popup via OPEN_POPUP; removes page-observable keystroke surface and the trim/no-trim inconsistency (HIGH #3 + LOW #9) (v3.6.125)
- time
- 2026-05-28 22:21 UTC · 5 months ago
- machine
- koh2
- parent
- c0204fa478df
↓ peach-browser-2da1a2af.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+9 −0src/content/inject.ts+28 −106src/shared/types.ts+1 −0manifest.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.124",5+ "version": "3.6.125",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.124",4+ "version": "3.6.125",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.124",3+ "version": "3.6.125",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/messages.ts+9 −0
@@ -811,6 +811,15 @@811811 try { await chrome.action.openPopup(); } catch { /* badge fallback */ }812812 return { ok: true };813813 }814+ case "OPEN_POPUP": {815+ // Content script asks to open the trusted extension popup — used by the816+ // inline locked bubble so the master password is entered in the popup,817+ // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a818+ // user gesture + Chrome 127+/Firefox; on failure the user clicks the819+ // toolbar icon. The badge already signals Peach needs attention.820+ try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }821+ return { ok: true };822+ }814823 case "GET_IDENTITIES": {815824 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };816825 return { ok: true, data: vault.getIdentities() };src/content/inject.ts+28 −106
@@ -954,6 +954,7 @@954954 display: flex; align-items: center; justify-content: center;955955 }956956 .locked-title { font-size: 11.5px; color: var(--peach-text); font-weight: 500; }957+ .locked-sub { font-size: 10.5px; line-height: 1.4; color: var(--peach-text-muted); text-align: center; margin: 2px 0 2px; }957958 .locked-input {958959 width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);959960 border-radius: 7px; color: var(--peach-text); padding: 7px 10px;@@ -2543,105 +2544,38 @@25432544 title.textContent = "unlock to autofill";25442545 head.append(icon, title);25452546 2546- // Password input2547- const pwInp = document.createElement("input");2548- pwInp.className = "locked-input";2549- pwInp.type = "password";2550- pwInp.placeholder = "master password";2551- pwInp.setAttribute("autocomplete", "current-password");2547+ // The master password is NEVER collected in page-hosted DOM. A malicious2548+ // page can observe keystrokes on the shared DOM event stream (a capture-2549+ // phase listener registered before Peach injects sees every key before any2550+ // stopImmediatePropagation), which would leak the vault's root secret2551+ // (audit HIGH). Instead, route unlocking to the trusted extension popup,2552+ // where keystrokes are isolated from the page. After the user unlocks2553+ // there, the background's VAULT_UNLOCKED broadcast re-triggers autofill2554+ // automatically (see content/index.ts), so the flow resumes on its own.2555+ const sub = document.createElement("div");2556+ sub.className = "locked-sub";2557+ sub.textContent = "Unlock in Peach — your master password is never typed on this page.";25522558 2553- // Error line (hidden initially)2554- const errEl = document.createElement("div");2555- errEl.className = "locked-error";2556- errEl.style.display = "none";2557-2558- // Unlock button2559- const unlockBtn = document.createElement("button");2560- unlockBtn.className = "locked-btn";2561- unlockBtn.type = "button";2562- unlockBtn.textContent = "unlock";2563-2564- async function tryUnlock() {2565- const pw = pwInp.value.trim();2566- if (!pw) { pwInp.focus(); return; }2567- unlockBtn.disabled = true;2568- unlockBtn.textContent = "unlocking…";2569- errEl.style.display = "none";2570- try {2571- const res = await chrome.runtime.sendMessage({2572- type: "UNLOCK",2573- payload: { password: pw },2574- });2575- if ((res as { ok?: boolean }).ok) {2576- dismiss();2577- options.onUnlocked?.();2578- } else {2579- errEl.textContent = "incorrect password";2580- errEl.style.display = "block";2581- pwInp.value = "";2582- pwInp.focus();2583- unlockBtn.disabled = false;2584- unlockBtn.textContent = "unlock";2559+ const openBtn = document.createElement("button");2560+ openBtn.className = "locked-btn";2561+ openBtn.type = "button";2562+ openBtn.textContent = "Open Peach to unlock";2563+ openBtn.addEventListener("click", () => {2564+ chrome.runtime.sendMessage({ type: "OPEN_POPUP" }).catch(() => {});2565+ openBtn.textContent = "opening Peach…";2566+ openBtn.disabled = true;2567+ // openPopup() can't always open programmatically (browser/version/gesture2568+ // limits); reset so the user can retry or click the toolbar icon.2569+ setTimeout(() => {2570+ if (openBtn.isConnected) {2571+ openBtn.textContent = "Open Peach to unlock";2572+ openBtn.disabled = false;25852573 }2586- } catch {2587- errEl.textContent = "could not reach Peach";2588- errEl.style.display = "block";2589- unlockBtn.disabled = false;2590- unlockBtn.textContent = "unlock";2591- }2592- }2593-2594- pwInp.addEventListener("keydown", (e) => {2595- e.stopPropagation();2596- if (e.key === "Enter") void tryUnlock();2574+ }, 2500);25972575 });2598- pwInp.addEventListener("keyup", (e) => e.stopPropagation());2599- pwInp.addEventListener("keypress", (e) => e.stopPropagation());2600- unlockBtn.addEventListener("click", () => void tryUnlock());26012576 2602- wrap.append(head, pwInp, errEl, unlockBtn);2577+ wrap.append(head, sub, openBtn);26032578 container.appendChild(wrap);2604-2605- // Keep the MV3 service worker alive while the locked popup is visible.2606- // Without this, the SW goes idle (~30 s) while the user types their2607- // password, and the UNLOCK message pays a 100-300 ms wakeup penalty2608- // before the crypto even starts — perceptible as a delay vs the always-2609- // live extension popup page.2610- const keepaliveId = setInterval(() => {2611- chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});2612- }, 20_000);2613- cleanupHooks.push(() => clearInterval(keepaliveId));2614-2615- // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.)2616- // the page reclaims focus from our Shadow DOM input before the user can2617- // type. Fighting over focus with a focusout→refocus loop hangs the tab.2618- // Instead: intercept keystrokes at the document capture phase — this runs2619- // before any page handler regardless of which element technically has2620- // OS focus — and route them into our input.2621- const captureKeydown = (e: KeyboardEvent) => {2622- if (!host.isConnected) {2623- document.removeEventListener("keydown", captureKeydown, true);2624- return;2625- }2626- // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through.2627- if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return;2628- // Intercept before page handlers and give our input focus.2629- e.stopImmediatePropagation();2630- if (e.key === "Escape") { dismiss(true); return; }2631- if (e.key === "Enter") { void tryUnlock(); return; }2632- // For all other printable/editing keys, ensure our input has focus so2633- // the browser delivers the keystroke there naturally.2634- if (document.activeElement !== host) {2635- pwInp.focus({ preventScroll: true });2636- }2637- };2638- captureKeydownRef = captureKeydown;2639- document.addEventListener("keydown", captureKeydown, true);2640-2641- // Best-effort focus on open — may be overridden by page focus trap,2642- // but the capture listener above handles typing regardless.2643- pwInp.focus({ preventScroll: true });2644- requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); });26452579 }26462580 26472581 function handleKeyDown(e: KeyboardEvent) {@@ -2707,14 +2641,6 @@27072641 host.style.left = `${r.left + window.scrollX}px`;27082642 host.style.width = `${popupWidth}px`;27092643 }2710- // These bindings are referenced from renderLocked()/dismiss() — they MUST2711- // be initialized before renderLocked() runs, or the synchronous assignment2712- // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ2713- // and throws ReferenceError. The throw lands after the popup is rendered2714- // but before showAutofillSelector returns its dismiss handle, leaving an2715- // orphan host in the DOM (which is what caused the locked-popup stacking2716- // and missing autofocus on every click).2717- let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null;27182644 const stopObserving = observeAnchorPosition(anchorEl, updatePosition);27192645 27202646 if (options.locked) {@@ -2744,10 +2670,6 @@27442670 stopObserving();27452671 document.removeEventListener("click", outsideClick, { capture: true });27462672 document.removeEventListener("keydown", handleKeyDown);2747- if (captureKeydownRef) {2748- document.removeEventListener("keydown", captureKeydownRef, true);2749- captureKeydownRef = null;2750- }27512673 for (const cleanup of cleanupHooks) {27522674 try { cleanup(); } catch { /* defensive — one hook must not block others */ }27532675 }src/shared/types.ts+1 −0
@@ -319,6 +319,7 @@319319 | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free320320 | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup321321 | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section322+ | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)322323 | "SHOW_PASSKEY_TOAST" // background → content tab: show passkey auth toast323324 | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast324325 | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast