2105d0bf213b

fix: MV-1 GET_ENTRY_FOR_FILL re-validates sender origin against entry domain set; explicitSummon flag on cross-domain summons

time
2026-06-10 02:01 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
1cb25e2b1e38
4 files src/background/messages.test.ts+78 −0src/background/messages.ts+36 −1src/content/index.ts+3 −1src/content/inject.ts+8 −4
src/background/messages.test.ts+78 −0
@@ -890,4 +890,82 @@890890     expect(storage.peach_license).toBeUndefined();891891     expect(storage.peach_pro_celebrated).toBeUndefined();892892   });893+894+  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---895+896+  function tabSender(url: string): chrome.runtime.MessageSender {897+    return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };898+  }899+900+  it("MV-1: tab request with a matching domain returns the entry", async () => {901+    const vault = await import("./vault");902+    await vault.createVault("correct-pass-12345");903+    const entry = await vault.addEntry({904+      title: "GitHub",905+      username: "june",906+      password: "secret123",907+      url: "https://github.com",908+    });909+910+    const res = await dispatch(911+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },912+      tabSender("https://github.com/login"),913+    );914+    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });915+  });916+917+  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {918+    const vault = await import("./vault");919+    await vault.createVault("correct-pass-12345");920+    const entry = await vault.addEntry({921+      title: "GitHub",922+      username: "june",923+      password: "secret123",924+      url: "https://github.com",925+    });926+927+    const res = await dispatch(928+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },929+      tabSender("https://evil.example.com/phish"),930+    );931+    // Refused with the same error as a missing id — no cross-domain oracle.932+    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });933+  });934+935+  it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {936+    const vault = await import("./vault");937+    await vault.createVault("correct-pass-12345");938+    const entry = await vault.addEntry({939+      title: "GitHub",940+      username: "june",941+      password: "secret123",942+      url: "https://github.com",943+    });944+945+    const res = await dispatch(946+      {947+        type: "GET_ENTRY_FOR_FILL",948+        payload: { id: entry.id, explicitSummon: true },949+      },950+      tabSender("https://evil.example.com/phish"),951+    );952+    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });953+  });954+955+  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {956+    const vault = await import("./vault");957+    await vault.createVault("correct-pass-12345");958+    const entry = await vault.addEntry({959+      title: "GitHub",960+      username: "june",961+      password: "secret123",962+      url: "https://github.com",963+    });964+965+    const res = await dispatch(966+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },967+      tabSender("not a url"),968+    );969+    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });970+  });893971 });

view file

src/background/messages.ts+36 −1
@@ -11,6 +11,7 @@1111 1212 import { copyToClipboard } from "./clipboard";1313 import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";14+import { extractDomain } from "../shared/domain";1415 import { setupAutoLock, touchAutoLock } from "./alarms";1516 import { runSelfTest } from "./self-test";1617 import * as vault from "./vault";@@ -621,13 +622,47 @@621622       }622623       case "GET_ENTRY_FOR_FILL": {623624         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };624-        const { id } = msg.payload as { id: string };625+        const { id, explicitSummon } = msg.payload as {626+          id: string;627+          explicitSummon?: boolean;628+        };625629         const entry = vault.getEntryById(id);626630         if (!entry) return { ok: false, error: "entry_not_found" };627631         // Passkey-only entries (passkey credential, no password) cannot be628632         // filled into a form — sign-in goes through the WebAuthn path. A629633         // login that ALSO has a passkey is still fillable for its password.630634         if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };635+636+        // MV-1: re-validate the entry against the requesting tab's origin.637+        // sender.tab.url is browser-attested — a page cannot forge it. When638+        // the request does not come from a tab at all (popup surfaces use639+        // GET_ENTRY_FOR_EDIT, not this), refuse.640+        if (sender.tab !== undefined || sender.url !== undefined) {641+          let senderDomain: string | null = null;642+          try {643+            const rawUrl = sender.tab?.url ?? sender.url ?? "";644+            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;645+          } catch {646+            senderDomain = null;647+          }648+          if (!senderDomain) return { ok: false, error: "origin_unverified" };649+650+          const entryDomains = new Set<string>();651+          const primary = extractDomain(entry.url ?? "");652+          if (primary) entryDomains.add(primary);653+          for (const extra of entry.additionalUrls ?? []) {654+            const d = extractDomain(extra);655+            if (d) entryDomains.add(d);656+          }657+658+          // explicitSummon=true means the user actively picked this entry659+          // from the cross-domain search affordance — an intentional660+          // off-domain fill. Passive fill paths must stay domain-bound.661+          if (explicitSummon !== true && !entryDomains.has(senderDomain)) {662+            // Domain mismatch — refuse silently (no oracle about which ids exist).663+            return { ok: false, error: "entry_not_found" };664+          }665+        }631666         // Strip the passkey credential AND the raw base32 TOTP seed from the632667         // response. Content scripts never need either: the private key is the633668         // authenticator's, and handing over the TOTP seed would let a

view file

src/content/index.ts+3 −1
@@ -249,7 +249,9 @@249249         row.appendChild(user);250250       }251251       row.onclick = async () => {252-        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });252+        // MV-1: vault-wide search list is a cross-domain summon — the user253+        // explicitly picked this entry to fill on this page.254+        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true } });253255         const r = resp as { ok: boolean; data?: { password?: string } };254256         if (r.ok && r.data?.password) {255257           let alreadyWritten = false;

view file

src/content/inject.ts+8 −4
@@ -2645,7 +2645,8 @@26452645       e.stopPropagation();26462646       const resp = await chrome.runtime.sendMessage({26472647         type: "GET_ENTRY_FOR_FILL",2648-        payload: { id: entry.id },2648+        // MV-1: user explicitly picked this entry from Peach's inline summon UI.2649+        payload: { id: entry.id, explicitSummon: true },26492650       }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;26502651       if (!resp?.ok || !resp.data?.password) return;26512652       let alreadyWritten = false;@@ -2684,7 +2685,8 @@26842685       async function fetchCode(): Promise<{ code: string; remaining: number } | null> {26852686         const r = await chrome.runtime.sendMessage({26862687           type: "GET_ENTRY_FOR_FILL",2687-          payload: { id: entry.id },2688+          // MV-1: explicit user summon of this entry's live TOTP code.2689+          payload: { id: entry.id, explicitSummon: true },26882690         }).catch(() => null) as { ok: boolean; data?: { totpCode?: string; totpRemaining?: number } } | null;26892691         if (!r?.ok || !r.data?.totpCode) return null;26902692         return { code: r.data.totpCode, remaining: r.data.totpRemaining ?? 30 };@@ -4207,7 +4209,8 @@42074209     fillBtn.innerHTML = checkSvgStr();42084210     const resp = await chrome.runtime.sendMessage({42094211       type: "GET_ENTRY_FOR_FILL",4210-      payload: { id: card.id },4212+      // MV-1: explicit user click to fill this card's details.4213+      payload: { id: card.id, explicitSummon: true },42114214     }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;42124215     if (resp?.ok && resp.data) options.onFill(resp.data);42134216     setTimeout(() => dismiss(), 400);@@ -4238,7 +4241,8 @@42384241   async function fetchFullCard(): Promise<import("../shared/types").VaultEntry | null> {42394242     const resp = await chrome.runtime.sendMessage({42404243       type: "GET_ENTRY_FOR_FILL",4241-      payload: { id: card.id },4244+      // MV-1: explicit user action on a card the user opened in Peach's UI.4245+      payload: { id: card.id, explicitSummon: true },42424246     }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;42434247     return resp?.ok && resp.data ? resp.data : null;42444248   }

view file