56ea59d6835a
fix: MV-2 vault inventory lazy-loaded behind userGesture gate; inline popup search loads vault-wide list only on explicit search gesture
- time
- 2026-06-10 02:06 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- audit fix integration
- parent
- 2105d0bf213b
↓ peach-browser-56ea59d6.tar.gzatlas at this save
files changed4 files
src/background/messages.test.ts+45 −0src/background/messages.ts+13 −2src/content/index.ts+17 −11src/content/inject.ts+39 −4src/background/messages.test.ts+45 −0
@@ -968,4 +968,49 @@968968 );969969 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });970970 });971+972+ // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---973+974+ async function seedTwoDomainVault() {975+ const vault = await import("./vault");976+ await vault.createVault("correct-pass-12345");977+ await vault.addEntry({978+ title: "GitHub",979+ username: "june",980+ password: "gh",981+ url: "https://github.com",982+ });983+ await vault.addEntry({984+ title: "Example",985+ username: "june",986+ password: "ex",987+ url: "https://example.com",988+ });989+ }990+991+ it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {992+ await seedTwoDomainVault();993+994+ const res = await dispatch({995+ type: "AUTOFILL_QUERY",996+ payload: { domain: "github.com", includeAll: true },997+ });998+ expect(res.ok).toBe(true);999+ const data = res.data as Array<{ title: string }>;1000+ // includeAll was stripped — only the github.com match comes back.1001+ expect(data).toHaveLength(1);1002+ expect(data[0].title).toBe("GitHub");1003+ });1004+1005+ it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1006+ await seedTwoDomainVault();1007+1008+ const res = await dispatch({1009+ type: "AUTOFILL_QUERY",1010+ payload: { domain: "github.com", includeAll: true, userGesture: true },1011+ });1012+ expect(res.ok).toBe(true);1013+ const data = res.data as Array<{ title: string }>;1014+ expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1015+ });9711016 });src/background/messages.ts+13 −2
@@ -537,11 +537,22 @@537537 if (!vault.isUnlocked()) {538538 return { ok: false, error: "vault_locked" };539539 }540- const { domain, includeAll, kind } = msg.payload as {540+ let { includeAll } = msg.payload as { includeAll?: boolean };541+ const { domain, kind, userGesture } = msg.payload as {541542 domain: string;542- includeAll?: boolean;543543 kind?: "login" | "card" | "totp";544+ userGesture?: boolean;544545 };546+ // MV-2: the vault-wide inventory (every title/username/email/url)547+ // may only leave the background on an explicit user gesture — the548+ // user opening the search affordance — never on passive form focus.549+ // userGesture is self-declared by our own content script; the value550+ // of this gate is that PASSIVE code paths (which never set it)551+ // cannot ship the inventory, shrinking what a compromised page that552+ // merely triggers focus events can observe.553+ if (includeAll && userGesture !== true) {554+ includeAll = false;555+ }545556 // kind="card": return saved cards as compact summaries for the546557 // inline card silver platter. Cards are not domain-scoped — every547558 // saved card is a candidate on every checkout page — so domain issrc/content/index.ts+17 −11
@@ -282,7 +282,9 @@282282 283283 searchInput.oninput = () => { query = searchInput.value; render(); };284284 285- fetchAutofillEntries(getPageDomain(), true).then(result => {285+ // MV-2: the summon palette is an explicit user gesture (keyboard shortcut /286+ // toolbar icon), so it may load the vault-wide inventory.287+ fetchAutofillEntries(getPageDomain(), true, true).then(result => {286288 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }287289 allEntries = result.entries;288290 render();@@ -382,11 +384,12 @@382384 383385 async function fetchAutofillEntries(384386 domain: string,385- includeAll = false387+ includeAll = false,388+ userGesture = false386389 ): Promise<{ entries: SelectorEntry[]; locked: boolean }> {387390 const response = await chrome.runtime.sendMessage({388391 type: "AUTOFILL_QUERY",389- payload: { domain, includeAll },392+ payload: { domain, includeAll, userGesture },390393 });391394 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };392395 if (!res.ok && res.error === "vault_locked") {@@ -438,7 +441,7 @@438441 usernameEl: HTMLInputElement | null,439442 passwordEl: HTMLInputElement | null,440443 domainEntries: SelectorEntry[],441- searchableEntries: SelectorEntry[],444+ loadSearchableEntries: () => Promise<SelectorEntry[]>,442445 domain: string,443446 identityKind: "email" | "username",444447 emailEl?: HTMLInputElement | null@@ -496,7 +499,7 @@496499 },497500 {498501 domain,499- searchableEntries,502+ loadSearchableEntries,500503 isSignup,501504 identityKind,502505 hasEmailField: !!emailEl,@@ -689,11 +692,14 @@689692 }690693 691694 const entries = firstResult.entries;692- // Always fetch the vault-wide list so the inline popup's search affordance693- // can reach entries that don't match this domain — the user may have a694- // valid login filed under a different URL (e.g. corp account on a vanity695- // domain) and still want to fill it here.696- const allEntries = (await fetchAutofillEntries(domain, true)).entries;695+ // MV-2: the vault-wide list is NOT fetched here. It is loaded lazily,696+ // only when the user activates the search affordance inside the inline697+ // popup (an explicit gesture), via the thunk below. Passive form focus698+ // never ships the full account inventory into the page's content-script699+ // world — and never hands out the entry ids that made MV-1 a full-vault700+ // primitive.701+ const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>702+ (await fetchAutofillEntries(domain, true, true)).entries;697703 698704 if (passkeyToastActive) return;699705 @@ -709,7 +715,7 @@709715 usernameEl,710716 passwordEl,711717 entries,712- allEntries,718+ loadSearchableEntries,713719 domain,714720 autofillFieldType === "email" ? "email" : "username",715721 emailEl,src/content/inject.ts+39 −4
@@ -69,6 +69,13 @@6969 interface SelectorOptions {7070 domain?: string;7171 searchableEntries?: SelectorEntry[];72+ /**73+ * MV-2: lazy loader for the vault-wide searchable list. Invoked only on an74+ * explicit user search gesture, never on passive popup open, so the full75+ * account inventory never ships into the page world unless the user asks.76+ * Result is cached for the popup's lifetime.77+ */78+ loadSearchableEntries?: () => Promise<SelectorEntry[]>;7279 /** True when shown on a signup form — changes header label and entry-click behaviour */7380 isSignup?: boolean;7481 /**@@ -1426,7 +1433,27 @@14261433 container.className = "peach-inline-popup";14271434 container.setAttribute("data-theme", popupTheme);14281435 1429- const searchableEntries = options.searchableEntries ?? [];1436+ // MV-2: the vault-wide list is loaded lazily on the first search gesture,1437+ // never eagerly on passive popup open. Cached for the popup's lifetime.1438+ let searchableEntries: SelectorEntry[] = options.searchableEntries ?? [];1439+ let searchableLoading = false;1440+ let searchableLoaded = !options.loadSearchableEntries;1441+1442+ async function ensureSearchableLoaded(rerender: () => void): Promise<void> {1443+ if (searchableLoaded || searchableLoading || !options.loadSearchableEntries) {1444+ return;1445+ }1446+ searchableLoading = true;1447+ try {1448+ searchableEntries = await options.loadSearchableEntries();1449+ } catch {1450+ searchableEntries = [];1451+ } finally {1452+ searchableLoaded = true;1453+ searchableLoading = false;1454+ rerender();1455+ }1456+ }14301457 const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();14311458 14321459 let query = "";@@ -1711,14 +1738,17 @@17111738 // switches to the full searchable list so they can find and fill it;17121739 // selecting an entry there will trigger the remember-site toast if17131740 // the domain isn't already associated.1714- if ((options.searchableEntries?.length ?? 0) > 0) {1741+ // MV-2: with lazy loading we no longer know the vault-wide count up front,1742+ // so offer the escape hatch whenever a loader (or an eager array) exists.1743+ if (options.loadSearchableEntries || (options.searchableEntries?.length ?? 0) > 0) {17151744 const escapeRow = container.appendChild(document.createElement("div"));17161745 escapeRow.className = "identity-escape-row";17171746 escapeRow.textContent = "I already have an account →";17181747 escapeRow.setAttribute("tabindex", "0");1719- escapeRow.addEventListener("click", () => renderList());1748+ const toSearch = () => { void ensureSearchableLoaded(renderList); renderList(); };1749+ escapeRow.addEventListener("click", toSearch);17201750 escapeRow.addEventListener("keydown", (e) => {1721- if (e.key === "Enter" || e.key === " ") { e.preventDefault(); renderList(); }1751+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toSearch(); }17221752 });17231753 }17241754 @@ -2046,6 +2076,9 @@20462076 searchInput.value = query;20472077 searchInput.addEventListener("input", () => {20482078 query = searchInput?.value ?? "";2079+ // MV-2: typing in the search box is the explicit gesture that loads the2080+ // vault-wide list (once, cached); it re-renders when it resolves.2081+ void ensureSearchableLoaded(renderList);20492082 renderList();20502083 searchInput?.focus();20512084 searchInput?.setSelectionRange(query.length, query.length);@@ -3206,6 +3239,8 @@32063239 e.preventDefault();32073240 e.stopImmediatePropagation();32083241 query = e.key;3242+ // MV-2: type-to-search is an explicit gesture — load the vault-wide list.3243+ void ensureSearchableLoaded(renderList);32093244 renderList();32103245 };32113246 document.addEventListener("keydown", silverPlatterTypeHandler, true);