f0ba500e86ed
bind content-script entry writes to the entry domain set and route remember-site through the popup (PB-26-106, v5.0.42)
- time
- 2026-09-02 04:00 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- assessment remediation corrections
- parent
- 08e9038e7ce8
↓ peach-browser-f0ba500e.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/message-policy.test.ts+1 −0src/background/message-policy.ts+1 −0src/background/messages.entry-write-origin.test.ts+311 −0src/background/messages.ts+139 −14src/content/inject.ts+4 −1src/popup/App.tsx+65 −0src/shared/types.ts+2 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.41",4+ "version": "5.0.42",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.41",5+ "version": "5.0.42",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.41",3+ "version": "5.0.42",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/message-policy.test.ts+1 −0
@@ -15,6 +15,7 @@1515 "REJECT_VOUCHED_DEVICE", "VOUCHED_DEVICE_PENDING", "ROTATE_SYNC_IDENTITY", "DEVICE_KEY_ROTATED",1616 "UNLOCK", "UNLOCK_WITH_PIN", "GET_PIN_STATUS", "LOCK", "IS_UNLOCKED", "HAS_VAULT", "GET_ENTRIES", "ADD_ENTRY",1717 "BATCH_ADD_ENTRIES", "RESTORE_ENTRIES", "CLEAR_VAULT_ENTRIES", "UPDATE_ENTRY", "ADD_ADDITIONAL_URL",18+ "REQUEST_REMEMBER_SITE",1819 "DELETE_ENTRY", "AUTOFILL_QUERY", "COPY", "CREATE_VAULT", "CREATE_VAULT_FROM_ROOT_KEY",1920 "GET_ENTRY_FOR_FILL", "GET_ENTRY_FOR_EDIT", "CHECK_ALL_BREACHES", "GET_SETTINGS", "UPDATE_SETTINGS",2021 "SETUP_PIN", "DISABLE_PIN", "FIRE_CANARY_PING", "ACTIVATE_LICENSE", "GET_LICENSE", "GET_LICENSE_KEY",src/background/message-policy.ts+1 −0
@@ -102,6 +102,7 @@102102 ADD_ENTRY: "content-script-allowed",103103 UPDATE_ENTRY: "content-script-allowed",104104 ADD_ADDITIONAL_URL: "content-script-allowed",105+ REQUEST_REMEMBER_SITE: "content-script-allowed",105106 AUTOFILL_QUERY: "content-script-allowed",106107 COPY: "content-script-allowed",107108 FIRE_CANARY_PING: "content-script-allowed",src/background/messages.entry-write-origin.test.ts+311 −0
@@ -0,0 +1,311 @@1+// PB-26-106: content-script entry mutations must be bound to the entry's own2+// domain set.3+//4+// The autofill READ path re-validates the browser-attested frame origin against5+// the entry's domains on every request (GET_ENTRY_FOR_FILL). The write path had6+// no symmetric binding, and `additionalUrls` feeds the same domain index the7+// read path consults — so a content script that knew an entry id could repoint8+// the entry at its own origin and then walk through the (origin-correct) read9+// path to obtain the full credential.10+//11+// The binding has two halves and both are required:12+//13+// 1. the sender's attested origin must still be inside the entry's domain set14+// AFTER the write, and15+// 2. every domain the write ADDS must be the sender's own origin.16+//17+// Half 1 alone is not enough: a content script sitting on the entry's own18+// (legitimate) origin would pass it while repointing the entry at a third19+// party — the escalation this file pins down. Half 2 is what makes the write20+// self-binding, which is also what keeps the inline "remember this site" toast21+// working: that toast legitimately adds the page the user is standing on.22+23+import { beforeEach, describe, expect, it, vi } from "vitest";24+25+type StorageShape = Record<string, unknown>;26+27+function makeChromeStorageMock() {28+ const storage: StorageShape = {};29+ const chrome = {30+ storage: {31+ local: {32+ get: vi.fn(async (key?: string | string[]) => {33+ if (!key) return { ...storage };34+ if (typeof key === "string") return { [key]: storage[key] };35+ return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(36+ (acc, k) => { acc[k] = storage[k]; return acc; },37+ {},38+ );39+ }),40+ set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),41+ remove: vi.fn(async (keys: string | string[]) => {42+ const ks = Array.isArray(keys) ? keys : [keys];43+ for (const k of ks) delete storage[k];44+ }),45+ },46+ session: {47+ get: vi.fn(async () => ({})),48+ set: vi.fn(async () => {}),49+ remove: vi.fn(async () => {}),50+ },51+ },52+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },53+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },54+ runtime: {55+ id: "test-extension-id",56+ getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),57+ },58+ } as unknown as typeof globalThis.chrome;59+ return { chrome };60+}61+62+const LEGIT = "https://example.com/login";63+const FOREIGN = "https://attacker.example.net/steal";64+65+function pageSender(url: string): chrome.runtime.MessageSender {66+ return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;67+}68+69+const extensionSender = {70+ url: "chrome-extension://test-extension-id/src/popup/index.html",71+} as unknown as chrome.runtime.MessageSender;72+73+describe("PB-26-106: content-script entry write origin binding", () => {74+ let vault: typeof import("./vault");75+ let messages: typeof import("./messages");76+77+ beforeEach(async () => {78+ vi.resetModules();79+ const mock = makeChromeStorageMock();80+ (globalThis as Record<string, unknown>).chrome = mock.chrome;81+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));82+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);83+84+ vault = await import("./vault");85+ messages = await import("./messages");86+87+ await vault.createVault("test-messages-pass");88+ });89+90+ async function addLegitEntry() {91+ return vault.addEntry({92+ type: "login",93+ title: "Example",94+ username: "victim",95+ password: "hunter2",96+ url: "https://example.com",97+ });98+ }99+100+ // The assessment's stated criterion: a foreign-origin sender may not mutate101+ // an entry it does not own, even when it knows the id.102+ it("UPDATE_ENTRY from a foreign-origin sender against a known id is refused", async () => {103+ const entry = await addLegitEntry();104+105+ const res = await messages.handleMessage(106+ { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "pwned" } } },107+ pageSender(FOREIGN),108+ );109+110+ expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });111+ });112+113+ // The escalation: a content script on the entry's OWN (legitimate) origin114+ // passes any pre-write origin check and repoints the entry at a third party.115+ // This is the case that made the read path's binding worthless.116+ it("UPDATE_ENTRY from the entry's own origin cannot repoint it at a foreign domain", async () => {117+ const entry = await addLegitEntry();118+119+ const res = await messages.handleMessage(120+ {121+ type: "UPDATE_ENTRY",122+ payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },123+ },124+ pageSender(LEGIT),125+ );126+127+ expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });128+ expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);129+ });130+131+ // Control: a non-origin field from the entry's own origin still succeeds.132+ it("UPDATE_ENTRY from the entry's own origin still patches non-origin fields", async () => {133+ const entry = await addLegitEntry();134+135+ const res = await messages.handleMessage(136+ { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "Renamed" } } },137+ pageSender(LEGIT),138+ );139+140+ expect(res).toMatchObject({ ok: true });141+ expect(vault.getEntryById(entry.id)?.title).toBe("Renamed");142+ });143+144+ // Same escalation through the dedicated additional-url handler.145+ it("ADD_ADDITIONAL_URL cannot attach a third-party origin to the entry", async () => {146+ const entry = await addLegitEntry();147+148+ const res = await messages.handleMessage(149+ {150+ type: "ADD_ADDITIONAL_URL",151+ payload: { id: entry.id, url: "https://attacker.example.net" },152+ },153+ pageSender(LEGIT),154+ );155+156+ expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });157+ expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);158+ });159+160+ // A page may not grow the set even with the origin it is standing on — that161+ // is precisely what the attacker in the chain below would attach.162+ it("ADD_ADDITIONAL_URL is refused even for the sender's own origin", async () => {163+ const entry = await addLegitEntry();164+165+ const res = await messages.handleMessage(166+ {167+ type: "ADD_ADDITIONAL_URL",168+ payload: { id: entry.id, url: "https://new-site.example.org" },169+ },170+ pageSender("https://new-site.example.org/checkout"),171+ );172+173+ expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });174+ expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);175+ });176+177+ // The legitimate replacement: the inline "remember this site" toast asks, and178+ // a page may only ever ask about its own attested origin. The write itself179+ // happens from the popup, which is exempt.180+ it("REQUEST_REMEMBER_SITE queues the sender's own origin for the popup to confirm", async () => {181+ const entry = await addLegitEntry();182+183+ const res = await messages.handleMessage(184+ {185+ type: "REQUEST_REMEMBER_SITE",186+ payload: { id: entry.id, url: "https://new-site.example.org" },187+ },188+ pageSender("https://new-site.example.org/checkout"),189+ );190+191+ expect(res).toMatchObject({ ok: true });192+ expect(chrome.storage.session.set).toHaveBeenCalledWith({193+ pendingRememberSite: { entryId: entry.id, url: "https://new-site.example.org" },194+ });195+ // Nothing was written yet — the popup confirms.196+ expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);197+ });198+199+ it("REQUEST_REMEMBER_SITE cannot queue a third-party origin", async () => {200+ const entry = await addLegitEntry();201+202+ const res = await messages.handleMessage(203+ {204+ type: "REQUEST_REMEMBER_SITE",205+ payload: { id: entry.id, url: "https://attacker.example.net" },206+ },207+ pageSender(LEGIT),208+ );209+210+ expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });211+ expect(chrome.storage.session.set).not.toHaveBeenCalled();212+ });213+214+ // A page may still shrink the set — clearing a stale mapping needs no popup.215+ it("UPDATE_ENTRY from the entry's own origin may drop an additional url", async () => {216+ const entry = await addLegitEntry();217+ await vault.updateEntry(entry.id, { additionalUrls: ["https://example.com"] });218+219+ const res = await messages.handleMessage(220+ {221+ type: "UPDATE_ENTRY",222+ payload: { id: entry.id, patch: { additionalUrls: [] } },223+ },224+ pageSender(LEGIT),225+ );226+227+ expect(res).toMatchObject({ ok: true });228+ expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);229+ });230+231+ it("ADD_ENTRY from a page sender cannot create an entry bound to a foreign site", async () => {232+ const res = await messages.handleMessage(233+ {234+ type: "ADD_ENTRY",235+ payload: {236+ type: "login",237+ title: "Bank",238+ username: "u",239+ password: "attacker-chosen",240+ url: "https://bank.example.org",241+ },242+ },243+ pageSender(LEGIT),244+ );245+246+ expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });247+ expect(vault.getEntries()).toHaveLength(0);248+ });249+250+ it("ADD_ENTRY from a page sender succeeds for its own origin", async () => {251+ const res = await messages.handleMessage(252+ {253+ type: "ADD_ENTRY",254+ payload: {255+ type: "login",256+ title: "Example",257+ username: "u",258+ password: "p",259+ url: "https://example.com",260+ },261+ },262+ pageSender(LEGIT),263+ );264+265+ expect(res).toMatchObject({ ok: true });266+ expect(vault.getEntries()).toHaveLength(1);267+ });268+269+ // The popup creates entries for sites it is not standing on. Extension pages270+ // are exempt from the binding.271+ it("ADD_ENTRY from the popup can still create an entry for any site", async () => {272+ const res = await messages.handleMessage(273+ {274+ type: "ADD_ENTRY",275+ payload: {276+ type: "login",277+ title: "Bank",278+ username: "u",279+ password: "p",280+ url: "https://bank.example.org",281+ },282+ },283+ extensionSender,284+ );285+286+ expect(res).toMatchObject({ ok: true });287+ expect(vault.getEntries()).toHaveLength(1);288+ });289+290+ // End-to-end shape of the reported attack: repoint the entry, then read the291+ // credential from the attacker origin. Both halves must fail.292+ it("the repoint-then-read chain stays closed from the attacker origin", async () => {293+ const entry = await addLegitEntry();294+295+ await messages.handleMessage(296+ {297+ type: "UPDATE_ENTRY",298+ payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },299+ },300+ pageSender(FOREIGN),301+ );302+303+ const res = await messages.handleMessage(304+ { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },305+ pageSender(FOREIGN),306+ );307+308+ expect(res).toMatchObject({ ok: false, error: "entry_not_found" });309+ expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);310+ });311+});src/background/messages.ts+139 −14
@@ -207,22 +207,105 @@207207 return undefined;208208 }209209 210-function entryAllowsSender(entry: VaultEntry, sender: Partial<chrome.runtime.MessageSender>): boolean {211- if (sender.url?.startsWith("chrome-extension://") || sender.url?.startsWith("moz-extension://")) return true;212- if (sender.tab === undefined && sender.url === undefined) return false;210+function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {211+ return sender.url?.startsWith("chrome-extension://") === true ||212+ sender.url?.startsWith("moz-extension://") === true;213+}214+215+/** True when the message came from a page frame rather than extension UI. */216+function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {217+ return !isExtensionPageSender(sender) &&218+ (sender.tab !== undefined || sender.url !== undefined);219+}220+221+function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {222+ const domains = new Set<string>();223+ const primary = typeof url === "string" ? extractDomain(url) : null;224+ if (primary) domains.add(primary);225+ if (Array.isArray(additionalUrls)) {226+ for (const extra of additionalUrls) {227+ if (typeof extra !== "string") continue;228+ const domain = extractDomain(extra);229+ if (domain) domains.add(domain);230+ }231+ }232+ return domains;233+}234+235+/**236+ * PB-26-106: the autofill read path re-validates the frame origin against the237+ * entry's domains on every request, so an unbound write path would let a238+ * content script repoint an entry at its own origin and then read the239+ * credential through a read path that is behaving correctly.240+ *241+ * Creating an entry: a page may only bind it to itself. Mutating one: a page242+ * may never GROW the domain set — only shrink it, and never below its own243+ * attested origin.244+ *245+ * Self-binding on mutation was considered and is not enough: the content246+ * script on a foreign origin IS the attacker in the reported chain, so letting247+ * it attach "its own" origin lets it mint the very capability the read path248+ * exists to withhold. The legitimate flow that needs a new domain — the inline249+ * "remember this site" toast — therefore asks the popup instead250+ * (REQUEST_REMEMBER_SITE), which is an extension page and exempt here.251+ *252+ * Extension pages are exempt: the popup edits entries for sites it is not on.253+ */254+function senderMayBindEntryDomains(255+ sender: Partial<chrome.runtime.MessageSender>,256+ domains: Set<string>,257+): boolean {258+ if (isExtensionPageSender(sender)) return true;259+ const domain = attestedSenderDomain(sender);260+ if (domain === null) return false;261+ for (const candidate of domains) {262+ if (candidate !== domain) return false;263+ }264+ return true;265+}266+267+function senderMayMutateEntryDomains(268+ sender: Partial<chrome.runtime.MessageSender>,269+ before: Set<string>,270+ after: Set<string>,271+): boolean {272+ if (isExtensionPageSender(sender)) return true;273+ const domain = attestedSenderDomain(sender);274+ if (domain === null || !after.has(domain)) return false;275+ for (const kept of after) {276+ if (!before.has(kept)) return false;277+ }278+ return true;279+}280+281+/**282+ * Null for extension pages (which the callers exempt), for senders with no283+ * attested origin at all, and for unparseable frame urls.284+ */285+function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {286+ if (sender.tab === undefined && sender.url === undefined) return null;213287 const origin = attestedSenderOrigin(sender);214- if (!origin) return false;215- let hostname: string;216- try { hostname = new URL(origin).hostname; } catch { return false; }217- const domains = [entry.url, ...(entry.additionalUrls ?? [])]218- .map((url) => extractDomain(url ?? ""))219- .filter((domain): domain is string => Boolean(domain));220- return domains.includes(extractDomain(hostname) ?? "");288+ if (!origin) return null;289+ try { return extractDomain(new URL(origin).hostname); } catch { return null; }221290 }222291 223292 function patchChangesEntryOrigin(patch: object): boolean {224293 return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");225294 }295+296+/** The entry's domain-bearing fields as the patch would leave them. */297+function patchedUrlShape(298+ patch: object,299+ existing: Pick<VaultEntry, "url" | "additionalUrls">,300+): { url: unknown; additionalUrls: unknown } {301+ const candidate = patch as { url?: unknown; additionalUrls?: unknown };302+ return {303+ url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,304+ additionalUrls: Object.hasOwn(patch, "additionalUrls")305+ ? candidate.additionalUrls306+ : existing.additionalUrls,307+ };308+}226309 // Resolves once restoreFromSession() has run, so handlers see the correct227310 // post-restore unlock state. entry.ts registers this BEFORE the message228311 // handler so the onMessage listener is added synchronously at SW startup@@ -464,6 +547,15 @@464547 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {465548 return { ok: false, error: "invalid_payload" };466549 }550+ // PB-26-106: a page may only create an entry bound to itself. Entries551+ // carrying no url have no domain surface to bind, so they are exempt.552+ if (isPageSender(sender)) {553+ const draft = payload as { url?: unknown; additionalUrls?: unknown };554+ const domains = entryDomains(draft.url, draft.additionalUrls);555+ if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {556+ return { ok: false, error: "origin_binding_required" };557+ }558+ }467559 const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);468560 return { ok: true, data: entry };469561 }@@ -534,8 +626,16 @@534626 }535627 const existingEntry = vault.getEntryById(id);536628 if (!existingEntry) return { ok: false, error: "entry_not_found" };537- if ((patchChangesEntryOrigin(patch) || sender.tab !== undefined || sender.url !== undefined) && !entryAllowsSender(existingEntry, sender)) {538- return { ok: false, error: "origin_binding_required" };629+ if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {630+ const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);631+ const after = patchedUrlShape(patch, existingEntry);632+ if (!senderMayMutateEntryDomains(633+ sender,634+ before,635+ entryDomains(after.url, after.additionalUrls),636+ )) {637+ return { ok: false, error: "origin_binding_required" };638+ }539639 }540640 // Single enforcement point for the Canary Entries Pro gate. Only541641 // gates the transition into a canary entry — clearing/disabling a@@ -558,10 +658,35 @@558658 if (!trimmed) return { ok: false, error: "url_required" };559659 const existing = vault.getEntryById(id);560660 if (!existing) return { ok: false, error: "entry_not_found" };561- if (!entryAllowsSender(existing, sender)) return { ok: false, error: "origin_binding_required" };562- if (existing.type !== "login") return { ok: false, error: "login_entries_only" };563661 const merged = [...(existing.additionalUrls ?? []), trimmed];662+ if (!senderMayMutateEntryDomains(663+ sender,664+ entryDomains(existing.url, existing.additionalUrls),665+ entryDomains(existing.url, merged),666+ )) {667+ return { ok: false, error: "origin_binding_required" };668+ }669+ if (existing.type !== "login") return { ok: false, error: "login_entries_only" };564670 await vault.updateEntry(id, { additionalUrls: merged });671+ return { ok: true };672+ }673+ case "REQUEST_REMEMBER_SITE": {674+ // PB-26-106: growing an entry's domain set needs a decision the page675+ // cannot make for itself, so the page asks and the popup confirms. A676+ // page may only ask about its own attested origin — otherwise this677+ // becomes the same unbound write under a friendlier name.678+ const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };679+ if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };680+ const trimmed = typeof url === "string" ? url.trim() : "";681+ if (!trimmed) return { ok: false, error: "url_required" };682+ if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {683+ return { ok: false, error: "origin_binding_required" };684+ }685+ if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };686+ if (chrome.storage.session) {687+ await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });688+ }689+ try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }565690 return { ok: true };566691 }567692 case "DELETE_ENTRY": {src/content/inject.ts+4 −1
@@ -206,8 +206,11 @@206206 const yesBtn = document.createElement("button"); yesBtn.className = "yes";207207 yesBtn.textContent = "Remember";208208 yesBtn.addEventListener("click", async () => {209+ // The write itself is popup-confirmed: growing an entry's domain set is210+ // the one autofill decision a page must not make for itself, because the211+ // domain set is what gates future fills (PB-26-106).209212 await chrome.runtime.sendMessage({210- type: "ADD_ADDITIONAL_URL",213+ type: "REQUEST_REMEMBER_SITE",211214 payload: { id: entryId, url: location.origin },212215 });213216 host.remove();src/popup/App.tsx+65 −0
@@ -221,6 +221,14 @@221221 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);222222 const [pendingCelebration, setPendingCelebration] = createSignal(false);223223 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);224+ // A page asked to be remembered for an entry it did not match. Pages cannot225+ // grow an entry's domain set themselves (PB-26-106), so the background queues226+ // the request here and this banner is the confirmation gate.227+ const [pendingRememberSite, setPendingRememberSite] = createSignal<{228+ entryId: string;229+ url: string;230+ title: string;231+ } | null>(null);224232 // True while Panic Step 1 has bounced the user out to Export. When Export's225233 // onBack fires we return to Panic instead of Settings so the user keeps the226234 // destruction context they came from.@@ -711,6 +719,28 @@711719 await chrome.storage.session.remove("pendingProNotice");712720 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });713721 }722+ const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {723+ pendingRememberSite?: { entryId?: unknown; url?: unknown };724+ };725+ const request = remembered.pendingRememberSite;726+ if (727+ typeof request?.entryId === "string" &&728+ typeof request.url === "string"729+ ) {730+ const entry = await sendMessage({ type: "GET_ENTRIES" });731+ const match = entry.ok732+ ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(733+ (candidate) => candidate.id === request.entryId,734+ )735+ : undefined;736+ if (match) {737+ let host = request.url;738+ try { host = new URL(request.url).hostname; } catch { /* keep raw */ }739+ setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });740+ } else {741+ await chrome.storage.session.remove("pendingRememberSite");742+ }743+ }714744 }715745 716746 // Warm the lazy-module cache so the Suspense fallback never shows for@@ -1493,6 +1523,41 @@14931523 </button>14941524 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>14951525 </div>1526+ </Show>1527+1528+ <Show when={pendingRememberSite()}>1529+ {(request) => {1530+ let host = request().url;1531+ try { host = new URL(request().url).hostname; } catch { /* keep raw */ }1532+ return (1533+ <div class="peach-pro-notice-banner">1534+ <span>Also use “{request().title}” on {host}?</span>1535+ <button1536+ class="peach-pro-notice-link"1537+ onClick={async () => {1538+ await sendMessage({1539+ type: "ADD_ADDITIONAL_URL",1540+ payload: { id: request().entryId, url: request().url },1541+ });1542+ await chrome.storage.session.remove("pendingRememberSite");1543+ setPendingRememberSite(null);1544+ }}1545+ >1546+ Add1547+ </button>1548+ <button1549+ class="peach-pro-notice-close"1550+ onClick={async () => {1551+ await chrome.storage.session.remove("pendingRememberSite");1552+ setPendingRememberSite(null);1553+ }}1554+ aria-label="Dismiss"1555+ >1556+ ✕1557+ </button>1558+ </div>1559+ );1560+ }}14961561 </Show>14971562 14981563 <div class="peach-shell-body">src/shared/types.ts+2 −0
@@ -215,6 +215,7 @@215215 | "CLEAR_VAULT_ENTRIES"216216 | "UPDATE_ENTRY"217217 | "ADD_ADDITIONAL_URL"218+ | "REQUEST_REMEMBER_SITE"218219 | "DELETE_ENTRY"219220 | "AUTOFILL_QUERY"220221 | "COPY"@@ -313,6 +314,7 @@313314 | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup314315 | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section315316 | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)317+ | "REQUEST_REMEMBER_SITE" // content → background: queue "use this entry on this site" for the popup to confirm316318 | "OPEN_DETACHED_POPUP" // popup → background: open the popup HTML in a standalone window (file-picker flows)317319 | "SHOW_PASSKEY_TOAST" // background → content tab: show passkey auth toast318320 | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast