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
4 files src/background/messages.test.ts+45 −0src/background/messages.ts+13 −2src/content/index.ts+17 −11src/content/inject.ts+39 −4
src/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 });

view file

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 is

view file

src/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,

view file

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);

view file