// T-022: GET_ENTRY_FOR_EDIT via sender.url + locked-vault AUTOFILL_QUERY // // Why these tests exist: // // GET_ENTRY_FOR_EDIT origin check (PCH-01-006): // The edit channel returns the full TOTP seed and password history — as // sensitive as a fill response. PCH-01-006 added a sender.url domain check // to refuse cross-domain requests. Today the only caller is the popup // (no sender.url), so the check is a structural defense-in-depth guard // that the existing suite doesn't trigger (it never passes a tab-context // sender). An untested guard can silently be removed or inverted. // // AUTOFILL_QUERY includeAll vault-locked path: // `includeAll` ships the entire vault inventory (all titles/usernames). // Two guards protect it: (a) userGesture check that strips includeAll // when the flag isn't set, and (b) vault_locked check that fires before // any data access. The vault_locked path for AUTOFILL_QUERY is tested // only incidentally; the includeAll guard against non-gesture callers is // not tested at all. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; function makeChromeStorageMock() { const storage: StorageShape = {}; const chrome = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; if (typeof key === "string") return { [key]: storage[key] }; return (Array.isArray(key) ? key : [key]).reduce( (acc, k) => { acc[k] = storage[k]; return acc; }, {}, ); }), set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const ks = Array.isArray(keys) ? keys : [keys]; for (const k of ks) delete storage[k]; }), }, session: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { id: "test-extension-id", getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`), }, } as unknown as typeof globalThis.chrome; return { storage, chrome }; } describe("T-022: Message router origin and lock guards", () => { let vault: typeof import("./vault"); let messages: typeof import("./messages"); beforeEach(async () => { vi.resetModules(); const mock = makeChromeStorageMock(); (globalThis as Record).chrome = mock.chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); vault = await import("./vault"); messages = await import("./messages"); await vault.createVault("test-messages-pass"); }); // T-022a: AUTOFILL_QUERY returns vault_locked before accessing entries // when the vault is not unlocked. // // Bug this catches: removing the `!vault.isUnlocked()` guard at the top of // the AUTOFILL_QUERY case would expose domain-matched entries to a locked- // vault state — entries are readable from the in-memory array even when // the vault is "locked" at the API layer in some edge cases. it("T-022a: AUTOFILL_QUERY returns vault_locked when vault is not unlocked", async () => { // The vault was created and unlocked by createVault; lock it. vault.lock(); expect(vault.isUnlocked()).toBe(false); const result = await messages.handleMessage( { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login" } }, {} as chrome.runtime.MessageSender, ); expect((result as { ok: boolean; error: string }).ok).toBe(false); expect((result as { ok: boolean; error: string }).error).toBe("vault_locked"); }); // T-022b: AUTOFILL_QUERY with includeAll=true but userGesture=false → includeAll // is silently stripped. The response returns only domain-matched entries, not // the full vault inventory. // // Bug this catches: removing the `if (includeAll && userGesture !== true)` // guard allows a passive content-script focus event to harvest the entire // vault title/username inventory. it("T-022b: AUTOFILL_QUERY includeAll=true without userGesture → treated as includeAll=false", async () => { // Add an entry for a different eTLD+1 domain to confirm it's excluded from // domain-scoped results (queryDomain uses eTLD+1, so "other.example.com" // would still match "example.com" — use a genuinely different domain here) await vault.addEntry({ type: "login", title: "Other Site", username: "u", password: "p", url: "https://different-site.io", }); const result = await messages.handleMessage( { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login", includeAll: true, // requested userGesture: false, // NOT a gesture → includeAll must be stripped }, }, {} as chrome.runtime.MessageSender, ); const { ok, data } = result as { ok: boolean; data: unknown[] }; expect(ok).toBe(true); // Only domain-matched entries are returned // "Other Site" (other.example.com) must not appear const titles = (data as Array<{ title: string }>).map((e) => e.title); expect(titles).not.toContain("Other Site"); }); // T-022c: GET_ENTRY_FOR_EDIT with sender.url from a different domain → // entry_not_found (origin check fails, no id oracle). // // The entry belongs to "example.com". A sender from "attacker.example.net" // must not receive the entry's full data (TOTP seed, password history). // // Bug this catches: removing the senderDomain check in GET_ENTRY_FOR_EDIT // allows any tab-context sender to enumerate entry details by guessing IDs. it("PB-26-201: GET_ENTRY_FOR_EDIT is unreachable from tab senders", async () => { const entry = await vault.addEntry({ type: "login", title: "Target Entry", username: "victim", password: "hunter2", url: "https://example.com", }); // Sender pretends to be a tab from a different domain const crossDomainSender = { tab: { url: "https://attacker.example.net/steal" }, url: "https://attacker.example.net/steal", } as unknown as chrome.runtime.MessageSender; const result = await messages.handleMessage( { type: "GET_ENTRY_FOR_EDIT", payload: { id: entry.id, explicitSummon: false }, }, crossDomainSender, ); expect((result as { ok: boolean; error: string }).ok).toBe(false); expect((result as { ok: boolean; error: string }).error).toBe("invalid_sender"); }); // T-022d: GET_ENTRY_FOR_EDIT with matching sender domain → succeeds. // The control case for T-022c: a sender from the same domain as the entry // must be allowed through. it("PB-26-201: matching tab sender cannot call GET_ENTRY_FOR_EDIT", async () => { const entry = await vault.addEntry({ type: "login", title: "Same Domain Entry", username: "legit", password: "pass123", url: "https://example.com", }); const matchingSender = { tab: { url: "https://example.com/path" }, url: "https://example.com/path", } as unknown as chrome.runtime.MessageSender; const result = await messages.handleMessage( { type: "GET_ENTRY_FOR_EDIT", payload: { id: entry.id, explicitSummon: false }, }, matchingSender, ); const { ok } = result as { ok: boolean }; expect(ok).toBe(false); expect((result as { error?: string }).error).toBe("invalid_sender"); }); // PB-26-005: the frame url (sender.url) is authoritative over the top-frame // sender.tab.url for an all_frames content script. it("PB-26-201: iframe tab sender cannot call GET_ENTRY_FOR_EDIT", async () => { const entry = await vault.addEntry({ type: "login", title: "Frame Entry", username: "framed", password: "pw12345", url: "https://iframe.example.net", }); // A cross-origin iframe on a foreign top-level page: the frame is the // real requester. Under the old tab?.url preference this was refused. const frameSender = { tab: { url: "https://top.example.com/page" }, url: "https://iframe.example.net/form", } as unknown as chrome.runtime.MessageSender; const result = await messages.handleMessage( { type: "GET_ENTRY_FOR_EDIT", payload: { id: entry.id, explicitSummon: false }, }, frameSender, ); const { ok } = result as { ok: boolean }; expect(ok).toBe(false); expect((result as { error?: string }).error).toBe("invalid_sender"); }); it("PB-26-005: attacker iframe sender.url denies even when the top frame matches the entry", async () => { const entry = await vault.addEntry({ type: "login", title: "Top Entry", username: "top", password: "pw12345", url: "https://example.com", }); const attackerFrameSender = { tab: { url: "https://example.com/page" }, url: "https://attacker.example.net/frame", } as unknown as chrome.runtime.MessageSender; const result = await messages.handleMessage( { type: "GET_ENTRY_FOR_EDIT", payload: { id: entry.id, explicitSummon: false }, }, attackerFrameSender, ); expect((result as { ok: boolean; error?: string }).ok).toBe(false); expect((result as { error?: string }).error).toBe("invalid_sender"); }); // T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true bypasses the domain check. // The explicit summon flag is set only by user-initiated keyboard shortcuts, // never by passive autofill. It allows the edit form to open for entries on // a subdomain (e.g., accounts.google.com for a google.com entry). it("PB-26-201: explicitSummon cannot authorize GET_ENTRY_FOR_EDIT", async () => { const entry = await vault.addEntry({ type: "login", title: "Explicit Summon Entry", username: "user", password: "pw", url: "https://example.com", }); const crossDomainSender = { tab: { url: "https://completely-different.example.net/" }, url: "https://completely-different.example.net/", } as unknown as chrome.runtime.MessageSender; const result = await messages.handleMessage( { type: "GET_ENTRY_FOR_EDIT", payload: { id: entry.id, explicitSummon: true }, }, crossDomainSender, ); const { ok } = result as { ok: boolean }; expect(ok).toBe(false); expect((result as { error?: string }).error).toBe("invalid_sender"); }); });