messages.edit-origin.test.ts

9.0 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  // T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true bypasses the domain check.204  // The explicit summon flag is set only by user-initiated keyboard shortcuts,205  // never by passive autofill. It allows the edit form to open for entries on206  // a subdomain (e.g., accounts.google.com for a google.com entry).207  it("T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true from cross-domain sender → returns entry", async () => {208    const entry = await vault.addEntry({209      type: "login",210      title: "Explicit Summon Entry",211      username: "user",212      password: "pw",213      url: "https://example.com",214    });215216    const crossDomainSender = {217      tab: { url: "https://completely-different.example.net/" },218      url: "https://completely-different.example.net/",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: true },225      },226      crossDomainSender,227    );228229    const { ok, data } = result as { ok: boolean; data: { title: string } };230    expect(ok).toBe(true);231    expect(data.title).toBe("Explicit Summon Entry");232  });233});