c43ce993a056

fix: Keep passkey toast token inside isolated world (v5.1.5)

time
2026-09-05 20:46 UTC · 1 month ago
machine
koh2
parent
b211fef3edc9
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/content.message-routing.test.ts+14 −2src/content/index.ts+5 −14src/content/passkey-toast/inject.tsx+9 −6src/content/passkey-toast/lifecycle.ts+11 −0src/content/passkey-toast/passkey-toast.test.ts+59 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.1.4",4+  "version": "5.1.5",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.1.4",5+  "version": "5.1.5",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.1.4",3+  "version": "5.1.5",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/content/content.message-routing.test.ts+14 −2
@@ -38,6 +38,7 @@3838 import { resetChromeMock, setMessageResponder, sentMessages, messageListeners, createLoginForm, makeInput } from "./_setup";3939 import "./index"; // registers the onMessage listener at import time4040 import * as inject from "./inject";41+import { notifyPasskeyToastClosed } from "./passkey-toast/lifecycle";4142 4243 const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;4344 const showCardSelector = inject.showCardSelector as unknown as ReturnType<typeof vi.fn>;@@ -208,15 +209,26 @@208209     expect(showSelector).not.toHaveBeenCalled();209210   });210211 211-  it("accepts the injected toast's token", async () => {212+  it("accepts the isolated-world toast lifecycle token", async () => {212213     const { password } = createLoginForm();213214     password.focus();214215     withEntries();215216     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+    notifyPasskeyToastClosed("extension-token");217218     await new Promise((resolve) => setTimeout(resolve, 0));218219     dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });219220     await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());221+  });222+223+  it("ignores a stale isolated-world close token after a replacement toast appears", async () => {224+    const { password } = createLoginForm();225+    password.focus();226+    withEntries();227+    dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "replacement-token" } });228+    notifyPasskeyToastClosed("previous-token");229+    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });230+    await new Promise((resolve) => setTimeout(resolve, 0));231+    expect(showSelector).not.toHaveBeenCalled();220232   });221233 });222234 

view file

src/content/index.ts+5 −14
@@ -32,6 +32,7 @@3232 import { detectPasswordPolicy } from "./policy";3333 import type { FieldPrediction } from "../shared/ml";3434 import type { VaultEntry } from "../shared/types";35+import { setPasskeyToastClosedListener } from "./passkey-toast/lifecycle";3536 3637 // WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value3738 // so we can tear down listeners if we ever need to re-process the same field.@@ -63,8 +64,8 @@6364 // (the passkey toast handles the next step instead).6465 let passkeySummonActive = false;6566 // Set while the passkey approval toast is visible. Prevents handleFieldInteraction66-// from opening the autofill selector alongside it. Cleared by a window message67-// posted by inject.tsx when the toast closes (and by DISMISS_PASSKEY_TOAST).67+// from opening the autofill selector alongside it. Cleared by the isolated-world68+// toast lifecycle channel when the toast closes (and by DISMISS_PASSKEY_TOAST).6869 let passkeyToastActive = false;6970 let passkeyToastToken: string | null = null;7071 function startDismissLock(): void {@@ -1216,18 +1217,8 @@12161217   return true;12171218 });12181219 1219-// inject.tsx posts this when the passkey toast closes (approve, deny, or timeout)1220-// so we know it's safe to allow autofill again.1221-window.addEventListener("message", (e: MessageEvent) => {1222-  // Reject postMessages from cross-frame senders — only the same window's1223-  // injected toast may flip this flag.1224-  if (e.source !== window) return;1225-  const d = e.data as { source?: string; type?: string; token?: unknown } | undefined;1226-  if (1227-    d?.source === "peach-passkey-toast" &&1228-    d?.type === "toast-closed" &&1229-    d.token === passkeyToastToken1230-  ) {1220+setPasskeyToastClosedListener((toastToken) => {1221+  if (toastToken === passkeyToastToken) {12311222     passkeyToastActive = false;12321223     passkeyToastToken = null;12331224   }

view file

src/content/passkey-toast/inject.tsx+9 −6
@@ -4,6 +4,7 @@44 // rather than letting Vite/crx inject it into the page document head.55 import styles from './passkey-toast.css?inline';66 import { ACCENT_COLORS } from '../../shared/types';7+import { notifyPasskeyToastClosed } from './lifecycle';78 89 let currentDispose: (() => void) | null = null;910 let currentRequestId: string | null = null;@@ -84,15 +85,17 @@8485   currentRequestId = payload.requestId;8586 8687   function cleanup() {87-    if (dispose) {88-      dispose();88+    const ownedDispose = dispose;89+    if (ownedDispose) {90+      ownedDispose();8991       dispose = null;9092     }9193     wrapper.remove();92-    currentDispose = null;93-    currentRequestId = null;94-    // Tell index.ts the toast is gone so it can re-enable autofill.95-    window.postMessage({ source: 'peach-passkey-toast', type: 'toast-closed', token: payload.toastToken }, '*');94+    if (currentDispose === ownedDispose) {95+      currentDispose = null;96+      currentRequestId = null;97+    }98+    notifyPasskeyToastClosed(payload.toastToken);9699   }97100 }98101 

view file

src/content/passkey-toast/lifecycle.ts+11 −0
@@ -0,0 +1,11 @@1+type PasskeyToastClosedListener = (toastToken: string) => void;2+3+let closedListener: PasskeyToastClosedListener | null = null;4+5+export function setPasskeyToastClosedListener(listener: PasskeyToastClosedListener): void {6+  closedListener = listener;7+}8+9+export function notifyPasskeyToastClosed(toastToken: string): void {10+  closedListener?.(toastToken);11+}

view file

src/content/passkey-toast/passkey-toast.test.ts+59 −0
@@ -217,6 +217,65 @@217217     // Container element stays in DOM; the toast content inside it is cleared218218     expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();219219   });220+221+  it('PB-26-501: does not publish the toast token to page message listeners when the toast closes', async () => {222+    const originalAttachShadow = Element.prototype.attachShadow;223+    const attachSpy = vi.spyOn(Element.prototype, 'attachShadow').mockImplementation(function (224+      this: Element,225+      init: ShadowRootInit,226+    ) {227+      return originalAttachShadow.call(this, { ...init, mode: 'open' });228+    });229+    const observedMessages: unknown[] = [];230+    const pageListener = (event: MessageEvent) => observedMessages.push(event.data);231+    window.addEventListener('message', pageListener);232+233+    try {234+      sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });235+      const root = document.querySelector<HTMLElement>('#peach-passkey-toast-root');236+      const denyButton = root?.shadowRoot?.querySelector<HTMLButtonElement>('.peach-passkey-toast-btn--deny');237+      expect(denyButton).toBeTruthy();238+239+      denyButton?.click();240+      await new Promise((resolve) => setTimeout(resolve, 250));241+      await new Promise((resolve) => setTimeout(resolve, 0));242+243+      expect(observedMessages).not.toContainEqual(expect.objectContaining({244+        source: 'peach-passkey-toast',245+        token: mockPayload.toastToken,246+      }));247+    } finally {248+      window.removeEventListener('message', pageListener);249+      attachSpy.mockRestore();250+    }251+  });252+253+  it('keeps a replacement toast dismissible when the previous toast finishes closing', async () => {254+    const originalAttachShadow = Element.prototype.attachShadow;255+    const attachSpy = vi.spyOn(Element.prototype, 'attachShadow').mockImplementation(function (256+      this: Element,257+      init: ShadowRootInit,258+    ) {259+      return originalAttachShadow.call(this, { ...init, mode: 'open' });260+    });261+262+    try {263+      sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });264+      const root = document.querySelector<HTMLElement>('#peach-passkey-toast-root');265+      root?.shadowRoot?.querySelector<HTMLButtonElement>('.peach-passkey-toast-btn--deny')?.click();266+267+      sendToContentScript(contentListener, {268+        type: 'SHOW_PASSKEY_TOAST',269+        payload: { ...mockPayload, requestId: 'req-2', toastToken: 'replacement-token' },270+      });271+      await new Promise((resolve) => setTimeout(resolve, 250));272+      sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });273+274+      expect(root?.shadowRoot?.querySelector('.peach-passkey-toast')).toBeNull();275+    } finally {276+      attachSpy.mockRestore();277+    }278+  });220279 });221280 222281 // ── Autofill blocking while toast active ──────────────────────────────────────

view file