messages.entry-write-origin.test.ts

10.9 KB · raw

1// PB-26-106: content-script entry mutations must be bound to the entry's own2// domain set.3//4// The autofill READ path re-validates the browser-attested frame origin against5// the entry's domains on every request (GET_ENTRY_FOR_FILL). The write path had6// no symmetric binding, and `additionalUrls` feeds the same domain index the7// read path consults — so a content script that knew an entry id could repoint8// the entry at its own origin and then walk through the (origin-correct) read9// path to obtain the full credential.10//11// The binding has two halves and both are required:12//13//   1. the sender's attested origin must still be inside the entry's domain set14//      AFTER the write, and15//   2. every domain the write ADDS must be the sender's own origin.16//17// Half 1 alone is not enough: a content script sitting on the entry's own18// (legitimate) origin would pass it while repointing the entry at a third19// party — the escalation this file pins down. Half 2 is what makes the write20// self-binding, which is also what keeps the inline "remember this site" toast21// working: that toast legitimately adds the page the user is standing on.2223import { beforeEach, describe, expect, it, vi } from "vitest";2425type StorageShape = Record<string, unknown>;2627function makeChromeStorageMock() {28  const storage: StorageShape = {};29  const chrome = {30    storage: {31      local: {32        get: vi.fn(async (key?: string | string[]) => {33          if (!key) return { ...storage };34          if (typeof key === "string") return { [key]: storage[key] };35          return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(36            (acc, k) => { acc[k] = storage[k]; return acc; },37            {},38          );39        }),40        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),41        remove: vi.fn(async (keys: string | string[]) => {42          const ks = Array.isArray(keys) ? keys : [keys];43          for (const k of ks) delete storage[k];44        }),45      },46      session: {47        get: vi.fn(async () => ({})),48        set: vi.fn(async () => {}),49        remove: vi.fn(async () => {}),50      },51    },52    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },53    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },54    runtime: {55      id: "test-extension-id",56      getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),57    },58  } as unknown as typeof globalThis.chrome;59  return { chrome };60}6162const LEGIT = "https://example.com/login";63const FOREIGN = "https://attacker.example.net/steal";6465function pageSender(url: string): chrome.runtime.MessageSender {66  return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;67}6869const extensionSender = {70  url: "chrome-extension://test-extension-id/src/popup/index.html",71} as unknown as chrome.runtime.MessageSender;7273describe("PB-26-106: content-script entry write origin binding", () => {74  let vault: typeof import("./vault");75  let messages: typeof import("./messages");7677  beforeEach(async () => {78    vi.resetModules();79    const mock = makeChromeStorageMock();80    (globalThis as Record<string, unknown>).chrome = mock.chrome;81    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));82    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);8384    vault = await import("./vault");85    messages = await import("./messages");8687    await vault.createVault("test-messages-pass");88  });8990  async function addLegitEntry() {91    return vault.addEntry({92      type: "login",93      title: "Example",94      username: "victim",95      password: "hunter2",96      url: "https://example.com",97    });98  }99100  // The assessment's stated criterion: a foreign-origin sender may not mutate101  // an entry it does not own, even when it knows the id.102  it("UPDATE_ENTRY from a foreign-origin sender against a known id is refused", async () => {103    const entry = await addLegitEntry();104105    const res = await messages.handleMessage(106      { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "pwned" } } },107      pageSender(FOREIGN),108    );109110    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });111  });112113  // The escalation: a content script on the entry's OWN (legitimate) origin114  // passes any pre-write origin check and repoints the entry at a third party.115  // This is the case that made the read path's binding worthless.116  it("UPDATE_ENTRY from the entry's own origin cannot repoint it at a foreign domain", async () => {117    const entry = await addLegitEntry();118119    const res = await messages.handleMessage(120      {121        type: "UPDATE_ENTRY",122        payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },123      },124      pageSender(LEGIT),125    );126127    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });128    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);129  });130131  // Control: a non-origin field from the entry's own origin still succeeds.132  it("UPDATE_ENTRY from the entry's own origin still patches non-origin fields", async () => {133    const entry = await addLegitEntry();134135    const res = await messages.handleMessage(136      { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "Renamed" } } },137      pageSender(LEGIT),138    );139140    expect(res).toMatchObject({ ok: true });141    expect(vault.getEntryById(entry.id)?.title).toBe("Renamed");142  });143144  // Same escalation through the dedicated additional-url handler.145  it("ADD_ADDITIONAL_URL cannot attach a third-party origin to the entry", async () => {146    const entry = await addLegitEntry();147148    const res = await messages.handleMessage(149      {150        type: "ADD_ADDITIONAL_URL",151        payload: { id: entry.id, url: "https://attacker.example.net" },152      },153      pageSender(LEGIT),154    );155156    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });157    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);158  });159160  // A page may not grow the set even with the origin it is standing on — that161  // is precisely what the attacker in the chain below would attach.162  it("ADD_ADDITIONAL_URL is refused even for the sender's own origin", async () => {163    const entry = await addLegitEntry();164165    const res = await messages.handleMessage(166      {167        type: "ADD_ADDITIONAL_URL",168        payload: { id: entry.id, url: "https://new-site.example.org" },169      },170      pageSender("https://new-site.example.org/checkout"),171    );172173    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });174    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);175  });176177  // The legitimate replacement: the inline "remember this site" toast asks, and178  // a page may only ever ask about its own attested origin. The write itself179  // happens from the popup, which is exempt.180  it("REQUEST_REMEMBER_SITE queues the sender's own origin for the popup to confirm", async () => {181    const entry = await addLegitEntry();182183    const res = await messages.handleMessage(184      {185        type: "REQUEST_REMEMBER_SITE",186        payload: { id: entry.id, url: "https://new-site.example.org" },187      },188      pageSender("https://new-site.example.org/checkout"),189    );190191    expect(res).toMatchObject({ ok: true });192    expect(chrome.storage.session.set).toHaveBeenCalledWith({193      pendingRememberSite: { entryId: entry.id, url: "https://new-site.example.org" },194    });195    // Nothing was written yet — the popup confirms.196    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);197  });198199  it("REQUEST_REMEMBER_SITE cannot queue a third-party origin", async () => {200    const entry = await addLegitEntry();201202    const res = await messages.handleMessage(203      {204        type: "REQUEST_REMEMBER_SITE",205        payload: { id: entry.id, url: "https://attacker.example.net" },206      },207      pageSender(LEGIT),208    );209210    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });211    expect(chrome.storage.session.set).not.toHaveBeenCalled();212  });213214  // A page may still shrink the set — clearing a stale mapping needs no popup.215  it("UPDATE_ENTRY from the entry's own origin may drop an additional url", async () => {216    const entry = await addLegitEntry();217    await vault.updateEntry(entry.id, { additionalUrls: ["https://example.com"] });218219    const res = await messages.handleMessage(220      {221        type: "UPDATE_ENTRY",222        payload: { id: entry.id, patch: { additionalUrls: [] } },223      },224      pageSender(LEGIT),225    );226227    expect(res).toMatchObject({ ok: true });228    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);229  });230231  it("ADD_ENTRY from a page sender cannot create an entry bound to a foreign site", async () => {232    const res = await messages.handleMessage(233      {234        type: "ADD_ENTRY",235        payload: {236          type: "login",237          title: "Bank",238          username: "u",239          password: "attacker-chosen",240          url: "https://bank.example.org",241        },242      },243      pageSender(LEGIT),244    );245246    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });247    expect(vault.getEntries()).toHaveLength(0);248  });249250  it("ADD_ENTRY from a page sender succeeds for its own origin", async () => {251    const res = await messages.handleMessage(252      {253        type: "ADD_ENTRY",254        payload: {255          type: "login",256          title: "Example",257          username: "u",258          password: "p",259          url: "https://example.com",260        },261      },262      pageSender(LEGIT),263    );264265    expect(res).toMatchObject({ ok: true });266    expect(vault.getEntries()).toHaveLength(1);267  });268269  // The popup creates entries for sites it is not standing on. Extension pages270  // are exempt from the binding.271  it("ADD_ENTRY from the popup can still create an entry for any site", async () => {272    const res = await messages.handleMessage(273      {274        type: "ADD_ENTRY",275        payload: {276          type: "login",277          title: "Bank",278          username: "u",279          password: "p",280          url: "https://bank.example.org",281        },282      },283      extensionSender,284    );285286    expect(res).toMatchObject({ ok: true });287    expect(vault.getEntries()).toHaveLength(1);288  });289290  // End-to-end shape of the reported attack: repoint the entry, then read the291  // credential from the attacker origin. Both halves must fail.292  it("the repoint-then-read chain stays closed from the attacker origin", async () => {293    const entry = await addLegitEntry();294295    await messages.handleMessage(296      {297        type: "UPDATE_ENTRY",298        payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },299      },300      pageSender(FOREIGN),301    );302303    const res = await messages.handleMessage(304      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },305      pageSender(FOREIGN),306    );307308    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });309    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);310  });311});