7b6b2aaa225e

prevent page-forged toast UI signals with per-toast tokens and remove inactive Firefox observer listener

time
2026-09-04 00:35 UTC · 1 month ago
machine
koh2
agent
claude-code_2-1-259_agent
session
PB-26-319 postMessage UI signals
parent
0b702b354fb3
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey.ts+1 −0src/content/content.message-routing.test.ts+24 −0src/content/index.ts+12 −49src/content/passkey-toast/inject.tsx+2 −1src/content/passkey-toast/passkey-toast.test.ts+1 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.87",4+  "version": "5.0.88",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.87",5+  "version": "5.0.88",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.87",3+  "version": "5.0.88",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/passkey.ts+1 −0
@@ -609,6 +609,7 @@609609     chrome.tabs.sendMessage(tab.id, {610610       type: 'SHOW_PASSKEY_TOAST',611611       payload: {612+        toastToken: crypto.randomUUID(),612613         site: request.rpId,613614         displayName: request.userDisplayName ?? request.userName,614615         username: request.userName,

view file

src/content/content.message-routing.test.ts+24 −0
@@ -196,6 +196,30 @@196196   });197197 });198198 199+describe("PB-26-319: toast close signals require the extension token", () => {200+  it("ignores a page-forged toast-closed postMessage", async () => {201+    const { password } = createLoginForm();202+    password.focus();203+    withEntries();204+    dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "extension-token" } });205+    window.dispatchEvent(new MessageEvent("message", { source: window, data: { source: "peach-passkey-toast", type: "toast-closed", token: "page-token" } }));206+    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });207+    await new Promise((resolve) => setTimeout(resolve, 0));208+    expect(showSelector).not.toHaveBeenCalled();209+  });210+211+  it("accepts the injected toast's token", async () => {212+    const { password } = createLoginForm();213+    password.focus();214+    withEntries();215+    dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "extension-token" } });216+    window.dispatchEvent(new MessageEvent("message", { source: window, data: { source: "peach-passkey-toast", type: "toast-closed", token: "extension-token" } }));217+    await new Promise((resolve) => setTimeout(resolve, 0));218+    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });219+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());220+  });221+});222+199223 describe("T-M08–T-M10: PING, passkey gate, robustness", () => {200224   // PING is the liveness probe the background uses to confirm the content script201225   // is injected.

view file

src/content/index.ts+12 −49
@@ -20,13 +20,11 @@2020   showCardSelector,2121   showPasskeyAvailable,2222   showPasskeyProGate,23-  showPasskeyRegistrationOffer,2423   showRememberSiteToast,2524   showTotpCopyToast,2625   showTOTPSelector,2726 } from "./inject";2827 import { detectBrowser } from "../shared/browser";29-import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";3028 import { fillAllowed, fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } from "./fill";3129 import { resolveAutofillInteractionTargets } from "./interaction";3230 import { setupSaveDetection } from "./save";@@ -68,6 +66,7 @@6866 // from opening the autofill selector alongside it. Cleared by a window message6967 // posted by inject.tsx when the toast closes (and by DISMISS_PASSKEY_TOAST).7068 let passkeyToastActive = false;69+let passkeyToastToken: string | null = null;7170 function startDismissLock(): void {7271   dismissLock = true;7372   if (dismissLockTimer) clearTimeout(dismissLockTimer);@@ -1164,6 +1163,9 @@11641163   if (msg?.type === "SHOW_PASSKEY_TOAST") {11651164     // Block autofill selector for the lifetime of the toast and dismiss anything open.11661165     passkeyToastActive = true;1166+    passkeyToastToken = typeof (msg.payload as { toastToken?: unknown } | undefined)?.toastToken === "string"1167+      ? (msg.payload as { toastToken: string }).toastToken1168+      : null;11671169     if (currentDismiss) {11681170       currentDismiss();11691171       currentDismiss = null;@@ -1173,6 +1175,7 @@11731175   }11741176   if (msg?.type === "DISMISS_PASSKEY_TOAST") {11751177     passkeyToastActive = false;1178+    passkeyToastToken = null;11761179     return false; // inject.tsx handles the actual dismissal11771180   }11781181   if (msg?.type === "VAULT_UNLOCKED") {@@ -1209,56 +1212,16 @@12091212   // Reject postMessages from cross-frame senders — only the same window's12101213   // injected toast may flip this flag.12111214   if (e.source !== window) return;1212-  const d = e.data as { source?: string; type?: string } | undefined;1213-  if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1215+  const d = e.data as { source?: string; type?: string; token?: unknown } | undefined;1216+  if (1217+    d?.source === "peach-passkey-toast" &&1218+    d?.type === "toast-closed" &&1219+    d.token === passkeyToastToken1220+  ) {12141221     passkeyToastActive = false;1222+    passkeyToastToken = null;12151223   }12161224 });1217-1218-// ── Firefox passkey detection bridge (legacy observer path) ─────────────1219-//1220-// Pre-v2.12.0 the MAIN-world `passkeys-firefox.ts` was an observer — it1221-// emitted `peach-passkey-firefox` postMessages so this content script could1222-// surface State 12 (an inline "Save passkey to Peach" offer). v2.12.01223-// replaced the observer with a full interception proxy (the page never1224-// reaches a real navigator.credentials.create on Firefox anymore), and the1225-// approval UI is opened by the background as part of the ceremony itself.1226-// When the proxy is active the offer would be redundant noise, so the1227-// listener gates on the flag. Once the proxy is removed (Firefox ships1228-// webAuthenticationProxy) this whole block goes too.1229-if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1230-  let lastPasskeyOfferAt = 0;1231-  const PASSKEY_OFFER_COOLDOWN_MS = 30_000;1232-1233-  window.addEventListener("message", (event: MessageEvent) => {1234-    if (event.source !== window) return;1235-    const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1236-    if (!data || data.source !== "peach-passkey-firefox") return;1237-    if (detectBrowser() !== "firefox") return;1238-    if (data.type !== "create") return;1239-1240-    // Cooldown: a single page interaction can fire multiple credentials.create1241-    // calls. Show the offer at most once per 30 seconds per page session.1242-    const now = Date.now();1243-    if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1244-    lastPasskeyOfferAt = now;1245-1246-    const domain = location.hostname.replace(/^www\./, "");1247-    chrome.runtime.sendMessage({1248-      type: "GET_PASSKEY_OFFER_DECLINED",1249-      payload: { domain },1250-    }).then((res: { ok: boolean; data?: boolean } | undefined) => {1251-      if (res?.data === true) return;1252-      const rpId = data.rpId ?? domain;1253-      showPasskeyRegistrationOffer({1254-        rpId,1255-        rpName: data.rpName,1256-        userName: data.userName,1257-        userDisplayName: data.userDisplayName,1258-      });1259-    }).catch(() => {});1260-  });1261-}12621225 12631226 // Surface State 11 (passkey-available) only when it adds value:12641227 //   - Cross-browser entry: Chrome passkey on Firefox or vice versa — neither

view file

src/content/passkey-toast/inject.tsx+2 −1
@@ -46,6 +46,7 @@4646   displayName: string;4747   username: string;4848   requestId: string;49+  toastToken: string;4950 }) {5051   // Dismiss any existing toast, actively denying the request it holds.5152   if (currentDispose) {@@ -91,7 +92,7 @@9192     currentDispose = null;9293     currentRequestId = null;9394     // Tell index.ts the toast is gone so it can re-enable autofill.94-    window.postMessage({ source: 'peach-passkey-toast', type: 'toast-closed' }, '*');95+    window.postMessage({ source: 'peach-passkey-toast', type: 'toast-closed', token: payload.toastToken }, '*');9596   }9697 }9798 

view file

src/content/passkey-toast/passkey-toast.test.ts+1 −0
@@ -75,6 +75,7 @@7575   displayName: 'Example',7676   username: 'june',7777   requestId: 'req-1',78+  toastToken: 'extension-token',7879 };7980 8081 function sendToContentScript(

view file