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
↓ peach-browser-c43ce993.tar.gzatlas at this save
files changed8 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 −0manifest.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": {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",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",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 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 }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 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+}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 ──────────────────────────────────────