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
↓ peach-browser-7b6b2aaa.tar.gzatlas at this save
files changed8 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 −0manifest.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": {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",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",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,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.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 — neithersrc/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 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(