messages.edit-origin.test.ts

10.8 KB · raw

1// T-022: GET_ENTRY_FOR_EDIT via sender.url + locked-vault AUTOFILL_QUERY2//3// Why these tests exist:4//5//   GET_ENTRY_FOR_EDIT origin check (PCH-01-006):6//     The edit channel returns the full TOTP seed and password history — as7//     sensitive as a fill response. PCH-01-006 added a sender.url domain check8//     to refuse cross-domain requests. Today the only caller is the popup9//     (no sender.url), so the check is a structural defense-in-depth guard10//     that the existing suite doesn't trigger (it never passes a tab-context11//     sender). An untested guard can silently be removed or inverted.12//13//   AUTOFILL_QUERY includeAll vault-locked path:14//     `includeAll` ships the entire vault inventory (all titles/usernames).15//     Two guards protect it: (a) userGesture check that strips includeAll16//     when the flag isn't set, and (b) vault_locked check that fires before17//     any data access. The vault_locked path for AUTOFILL_QUERY is tested18//     only incidentally; the includeAll guard against non-gesture callers is19//     not tested at all.2021import { beforeEach, describe, expect, it, vi } from "vitest";2223type StorageShape = Record<string, unknown>;2425function makeChromeStorageMock() {26  const storage: StorageShape = {};27  const chrome = {28    storage: {29      local: {30        get: vi.fn(async (key?: string | string[]) => {31          if (!key) return { ...storage };32          if (typeof key === "string") return { [key]: storage[key] };33          return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(34            (acc, k) => { acc[k] = storage[k]; return acc; },35            {},36          );37        }),38        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),39        remove: vi.fn(async (keys: string | string[]) => {40          const ks = Array.isArray(keys) ? keys : [keys];41          for (const k of ks) delete storage[k];42        }),43      },44      session: {45        get: vi.fn(async () => ({})),46        set: vi.fn(async () => {}),47        remove: vi.fn(async () => {}),48      },49    },50    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },51    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },52    runtime: {53      id: "test-extension-id",54      getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),55    },56  } as unknown as typeof globalThis.chrome;57  return { storage, chrome };58}5960describe("T-022: Message router origin and lock guards", () => {61  let vault: typeof import("./vault");62  let messages: typeof import("./messages");6364  beforeEach(async () => {65    vi.resetModules();66    const mock = makeChromeStorageMock();67    (globalThis as Record<string, unknown>).chrome = mock.chrome;68    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));69    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);7071    vault = await import("./vault");72    messages = await import("./messages");7374    await vault.createVault("test-messages-pass");75  });7677  // T-022a: AUTOFILL_QUERY returns vault_locked before accessing entries78  // when the vault is not unlocked.79  //80  // Bug this catches: removing the `!vault.isUnlocked()` guard at the top of81  // the AUTOFILL_QUERY case would expose domain-matched entries to a locked-82  // vault state — entries are readable from the in-memory array even when83  // the vault is "locked" at the API layer in some edge cases.84  it("T-022a: AUTOFILL_QUERY returns vault_locked when vault is not unlocked", async () => {85    // The vault was created and unlocked by createVault; lock it.86    vault.lock();87    expect(vault.isUnlocked()).toBe(false);8889    const result = await messages.handleMessage(90      { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login" } },91      {} as chrome.runtime.MessageSender,92    );9394    expect((result as { ok: boolean; error: string }).ok).toBe(false);95    expect((result as { ok: boolean; error: string }).error).toBe("vault_locked");96  });9798  // T-022b: AUTOFILL_QUERY with includeAll=true but userGesture=false → includeAll99  // is silently stripped. The response returns only domain-matched entries, not100  // the full vault inventory.101  //102  // Bug this catches: removing the `if (includeAll && userGesture !== true)`103  // guard allows a passive content-script focus event to harvest the entire104  // vault title/username inventory.105  it("T-022b: AUTOFILL_QUERY includeAll=true without userGesture → treated as includeAll=false", async () => {106    // Add an entry for a different eTLD+1 domain to confirm it's excluded from107    // domain-scoped results (queryDomain uses eTLD+1, so "other.example.com"108    // would still match "example.com" — use a genuinely different domain here)109    await vault.addEntry({110      type: "login",111      title: "Other Site",112      username: "u",113      password: "p",114      url: "https://different-site.io",115    });116117    const result = await messages.handleMessage(118      {119        type: "AUTOFILL_QUERY",120        payload: {121          domain: "example.com",122          kind: "login",123          includeAll: true,      // requested124          userGesture: false,    // NOT a gesture → includeAll must be stripped125        },126      },127      {} as chrome.runtime.MessageSender,128    );129130    const { ok, data } = result as { ok: boolean; data: unknown[] };131    expect(ok).toBe(true);132    // includeAll was stripped; only domain-matched entries are returned133    // "Other Site" (other.example.com) must not appear134    const titles = (data as Array<{ title: string }>).map((e) => e.title);135    expect(titles).not.toContain("Other Site");136  });137138  // T-022c: GET_ENTRY_FOR_EDIT with sender.url from a different domain →139  // entry_not_found (origin check fails, no id oracle).140  //141  // The entry belongs to "example.com". A sender from "attacker.example.net"142  // must not receive the entry's full data (TOTP seed, password history).143  //144  // Bug this catches: removing the senderDomain check in GET_ENTRY_FOR_EDIT145  // allows any tab-context sender to enumerate entry details by guessing IDs.146  it("T-022c: GET_ENTRY_FOR_EDIT from wrong-domain sender.url → entry_not_found", async () => {147    const entry = await vault.addEntry({148      type: "login",149      title: "Target Entry",150      username: "victim",151      password: "hunter2",152      url: "https://example.com",153    });154155    // Sender pretends to be a tab from a different domain156    const crossDomainSender = {157      tab: { url: "https://attacker.example.net/steal" },158      url: "https://attacker.example.net/steal",159    } as unknown as chrome.runtime.MessageSender;160161    const result = await messages.handleMessage(162      {163        type: "GET_ENTRY_FOR_EDIT",164        payload: { id: entry.id, explicitSummon: false },165      },166      crossDomainSender,167    );168169    expect((result as { ok: boolean; error: string }).ok).toBe(false);170    expect((result as { ok: boolean; error: string }).error).toBe("entry_not_found");171  });172173  // T-022d: GET_ENTRY_FOR_EDIT with matching sender domain → succeeds.174  // The control case for T-022c: a sender from the same domain as the entry175  // must be allowed through.176  it("T-022d: GET_ENTRY_FOR_EDIT from matching sender.url → returns entry", async () => {177    const entry = await vault.addEntry({178      type: "login",179      title: "Same Domain Entry",180      username: "legit",181      password: "pass123",182      url: "https://example.com",183    });184185    const matchingSender = {186      tab: { url: "https://example.com/path" },187      url: "https://example.com/path",188    } as unknown as chrome.runtime.MessageSender;189190    const result = await messages.handleMessage(191      {192        type: "GET_ENTRY_FOR_EDIT",193        payload: { id: entry.id, explicitSummon: false },194      },195      matchingSender,196    );197198    const { ok, data } = result as { ok: boolean; data: { title: string } };199    expect(ok).toBe(true);200    expect(data.title).toBe("Same Domain Entry");201  });202203  // PB-26-005: the frame url (sender.url) is authoritative over the top-frame204  // sender.tab.url for an all_frames content script.205  it("PB-26-005: iframe sender.url matching the entry wins over a non-matching top-frame tab.url", async () => {206    const entry = await vault.addEntry({207      type: "login",208      title: "Frame Entry",209      username: "framed",210      password: "pw12345",211      url: "https://iframe.example.net",212    });213214    // A cross-origin iframe on a foreign top-level page: the frame is the215    // real requester. Under the old tab?.url preference this was refused.216    const frameSender = {217      tab: { url: "https://top.example.com/page" },218      url: "https://iframe.example.net/form",219    } as unknown as chrome.runtime.MessageSender;220221    const result = await messages.handleMessage(222      {223        type: "GET_ENTRY_FOR_EDIT",224        payload: { id: entry.id, explicitSummon: false },225      },226      frameSender,227    );228229    const { ok } = result as { ok: boolean };230    expect(ok).toBe(true);231  });232233  it("PB-26-005: attacker iframe sender.url denies even when the top frame matches the entry", async () => {234    const entry = await vault.addEntry({235      type: "login",236      title: "Top Entry",237      username: "top",238      password: "pw12345",239      url: "https://example.com",240    });241242    const attackerFrameSender = {243      tab: { url: "https://example.com/page" },244      url: "https://attacker.example.net/frame",245    } as unknown as chrome.runtime.MessageSender;246247    const result = await messages.handleMessage(248      {249        type: "GET_ENTRY_FOR_EDIT",250        payload: { id: entry.id, explicitSummon: false },251      },252      attackerFrameSender,253    );254255    expect((result as { ok: boolean; error?: string }).ok).toBe(false);256    expect((result as { error?: string }).error).toBe("entry_not_found");257  });258259  // T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true bypasses the domain check.260  // The explicit summon flag is set only by user-initiated keyboard shortcuts,261  // never by passive autofill. It allows the edit form to open for entries on262  // a subdomain (e.g., accounts.google.com for a google.com entry).263  it("T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true from cross-domain sender → returns entry", async () => {264    const entry = await vault.addEntry({265      type: "login",266      title: "Explicit Summon Entry",267      username: "user",268      password: "pw",269      url: "https://example.com",270    });271272    const crossDomainSender = {273      tab: { url: "https://completely-different.example.net/" },274      url: "https://completely-different.example.net/",275    } as unknown as chrome.runtime.MessageSender;276277    const result = await messages.handleMessage(278      {279        type: "GET_ENTRY_FOR_EDIT",280        payload: { id: entry.id, explicitSummon: true },281      },282      crossDomainSender,283    );284285    const { ok, data } = result as { ok: boolean; data: { title: string } };286    expect(ok).toBe(true);287    expect(data.title).toBe("Explicit Summon Entry");288  });289});