// PB-26-106: content-script entry mutations must be bound to the entry's own // domain set. // // The autofill READ path re-validates the browser-attested frame origin against // the entry's domains on every request (GET_ENTRY_FOR_FILL). The write path had // no symmetric binding, and `additionalUrls` feeds the same domain index the // read path consults — so a content script that knew an entry id could repoint // the entry at its own origin and then walk through the (origin-correct) read // path to obtain the full credential. // // The binding has two halves and both are required: // // 1. the sender's attested origin must still be inside the entry's domain set // AFTER the write, and // 2. every domain the write ADDS must be the sender's own origin. // // Half 1 alone is not enough: a content script sitting on the entry's own // (legitimate) origin would pass it while repointing the entry at a third // party — the escalation this file pins down. Half 2 is what makes the write // self-binding, which is also what keeps the inline "remember this site" toast // working: that toast legitimately adds the page the user is standing on. 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 { chrome }; } const LEGIT = "https://example.com/login"; const FOREIGN = "https://attacker.example.net/steal"; function pageSender(url: string): chrome.runtime.MessageSender { return { tab: { url }, url } as unknown as chrome.runtime.MessageSender; } const extensionSender = { url: "chrome-extension://test-extension-id/src/popup/index.html", } as unknown as chrome.runtime.MessageSender; describe("PB-26-106: content-script entry write origin binding", () => { 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"); }); async function addLegitEntry() { return vault.addEntry({ type: "login", title: "Example", username: "victim", password: "hunter2", url: "https://example.com", }); } // The assessment's stated criterion: a foreign-origin sender may not mutate // an entry it does not own, even when it knows the id. it("UPDATE_ENTRY from a foreign-origin sender against a known id is refused", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "pwned" } } }, pageSender(FOREIGN), ); expect(res).toMatchObject({ ok: false, error: "origin_binding_required" }); }); // The escalation: a content script on the entry's OWN (legitimate) origin // passes any pre-write origin check and repoints the entry at a third party. // This is the case that made the read path's binding worthless. it("UPDATE_ENTRY from the entry's own origin cannot repoint it at a foreign domain", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } }, }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: false, error: "origin_binding_required" }); expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]); }); // Control: a non-origin field from the entry's own origin still succeeds. it("UPDATE_ENTRY from the entry's own origin still patches non-origin fields", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "Renamed" } } }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: true }); expect(vault.getEntryById(entry.id)?.title).toBe("Renamed"); }); // Same escalation through the dedicated additional-url handler. it("ADD_ADDITIONAL_URL cannot attach a third-party origin to the entry", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "ADD_ADDITIONAL_URL", payload: { id: entry.id, url: "https://attacker.example.net" }, }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: false, error: "origin_binding_required" }); expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]); }); // A page may not grow the set even with the origin it is standing on — that // is precisely what the attacker in the chain below would attach. it("ADD_ADDITIONAL_URL is refused even for the sender's own origin", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "ADD_ADDITIONAL_URL", payload: { id: entry.id, url: "https://new-site.example.org" }, }, pageSender("https://new-site.example.org/checkout"), ); expect(res).toMatchObject({ ok: false, error: "origin_binding_required" }); expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]); }); // The legitimate replacement: the inline "remember this site" toast asks, and // a page may only ever ask about its own attested origin. The write itself // happens from the popup, which is exempt. it("REQUEST_REMEMBER_SITE queues the sender's own origin for the popup to confirm", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "REQUEST_REMEMBER_SITE", payload: { id: entry.id, url: "https://new-site.example.org" }, }, pageSender("https://new-site.example.org/checkout"), ); expect(res).toMatchObject({ ok: true }); expect(chrome.storage.session.set).toHaveBeenCalledWith({ pendingRememberSite: { entryId: entry.id, url: "https://new-site.example.org" }, }); // Nothing was written yet — the popup confirms. expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]); }); it("REQUEST_REMEMBER_SITE cannot queue a third-party origin", async () => { const entry = await addLegitEntry(); const res = await messages.handleMessage( { type: "REQUEST_REMEMBER_SITE", payload: { id: entry.id, url: "https://attacker.example.net" }, }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: false, error: "origin_binding_required" }); expect(chrome.storage.session.set).not.toHaveBeenCalled(); }); // A page may still shrink the set — clearing a stale mapping needs no popup. it("UPDATE_ENTRY from the entry's own origin may drop an additional url", async () => { const entry = await addLegitEntry(); await vault.updateEntry(entry.id, { additionalUrls: ["https://example.com"] }); const res = await messages.handleMessage( { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { additionalUrls: [] } }, }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: true }); expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]); }); it("ADD_ENTRY from a page sender cannot create an entry bound to a foreign site", async () => { const res = await messages.handleMessage( { type: "ADD_ENTRY", payload: { type: "login", title: "Bank", username: "u", password: "attacker-chosen", url: "https://bank.example.org", }, }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: false, error: "origin_binding_required" }); expect(vault.getEntries()).toHaveLength(0); }); it("ADD_ENTRY from a page sender succeeds for its own origin", async () => { const res = await messages.handleMessage( { type: "ADD_ENTRY", payload: { type: "login", title: "Example", username: "u", password: "p", url: "https://example.com", }, }, pageSender(LEGIT), ); expect(res).toMatchObject({ ok: true }); expect(vault.getEntries()).toHaveLength(1); }); // The popup creates entries for sites it is not standing on. Extension pages // are exempt from the binding. it("ADD_ENTRY from the popup can still create an entry for any site", async () => { const res = await messages.handleMessage( { type: "ADD_ENTRY", payload: { type: "login", title: "Bank", username: "u", password: "p", url: "https://bank.example.org", }, }, extensionSender, ); expect(res).toMatchObject({ ok: true }); expect(vault.getEntries()).toHaveLength(1); }); // End-to-end shape of the reported attack: repoint the entry, then read the // credential from the attacker origin. Both halves must fail. it("the repoint-then-read chain stays closed from the attacker origin", async () => { const entry = await addLegitEntry(); await messages.handleMessage( { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } }, }, pageSender(FOREIGN), ); const res = await messages.handleMessage( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } }, pageSender(FOREIGN), ); expect(res).toMatchObject({ ok: false, error: "entry_not_found" }); expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]); }); });