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
↓ peach-browser-2105d0bf.tar.gzatlas at this save
files changed4 files
src/background/messages.test.ts+78 −0src/background/messages.ts+36 −1src/content/index.ts+3 −1src/content/inject.ts+8 −4src/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 });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 asrc/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;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 }