messages.edit-origin.test.ts

8.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 chrome;57  return { storage, chrome };58}5960describe("T-022: Message router origin and lock guards", () => {61  let vault: typeof import("../../background/vault");62  let messages: typeof import("../../background/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"); }));6970    vault = await import("../../background/vault");71    messages = await import("../../background/messages");7273    await vault.createVault("test-messages-pass");74  });7576  // T-022a: AUTOFILL_QUERY returns vault_locked before accessing entries77  // when the vault is not unlocked.78  //79  // Bug this catches: removing the `!vault.isUnlocked()` guard at the top of80  // the AUTOFILL_QUERY case would expose domain-matched entries to a locked-81  // vault state — entries are readable from the in-memory array even when82  // the vault is "locked" at the API layer in some edge cases.83  it("T-022a: AUTOFILL_QUERY returns vault_locked when vault is not unlocked", async () => {84    // The vault was created and unlocked by createVault; lock it.85    vault.lock();86    expect(vault.isUnlocked()).toBe(false);8788    const result = await messages.handleMessage(89      { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login" } },90      {} as chrome.runtime.MessageSender,91    );9293    expect((result as { ok: boolean; error: string }).ok).toBe(false);94    expect((result as { ok: boolean; error: string }).error).toBe("vault_locked");95  });9697  // T-022b: AUTOFILL_QUERY with includeAll=true but userGesture=false → includeAll98  // is silently stripped. The response returns only domain-matched entries, not99  // the full vault inventory.100  //101  // Bug this catches: removing the `if (includeAll && userGesture !== true)`102  // guard allows a passive content-script focus event to harvest the entire103  // vault title/username inventory.104  it("T-022b: AUTOFILL_QUERY includeAll=true without userGesture → treated as includeAll=false", async () => {105    // Add an entry for a different domain to confirm it's excluded from non-includeAll results106    await vault.addEntry({107      type: "login",108      title: "Other Site",109      username: "u",110      password: "p",111      url: "https://other.example.com",112    });113114    const result = await messages.handleMessage(115      {116        type: "AUTOFILL_QUERY",117        payload: {118          domain: "example.com",119          kind: "login",120          includeAll: true,      // requested121          userGesture: false,    // NOT a gesture → includeAll must be stripped122        },123      },124      {} as chrome.runtime.MessageSender,125    );126127    const { ok, data } = result as { ok: boolean; data: unknown[] };128    expect(ok).toBe(true);129    // includeAll was stripped; only domain-matched entries are returned130    // "Other Site" (other.example.com) must not appear131    const titles = (data as Array<{ title: string }>).map((e) => e.title);132    expect(titles).not.toContain("Other Site");133  });134135  // T-022c: GET_ENTRY_FOR_EDIT with sender.url from a different domain →136  // entry_not_found (origin check fails, no id oracle).137  //138  // The entry belongs to "example.com". A sender from "attacker.example.net"139  // must not receive the entry's full data (TOTP seed, password history).140  //141  // Bug this catches: removing the senderDomain check in GET_ENTRY_FOR_EDIT142  // allows any tab-context sender to enumerate entry details by guessing IDs.143  it("T-022c: GET_ENTRY_FOR_EDIT from wrong-domain sender.url → entry_not_found", async () => {144    const entry = await vault.addEntry({145      type: "login",146      title: "Target Entry",147      username: "victim",148      password: "hunter2",149      url: "https://example.com",150    });151152    // Sender pretends to be a tab from a different domain153    const crossDomainSender = {154      tab: { url: "https://attacker.example.net/steal" },155      url: "https://attacker.example.net/steal",156    } as unknown as chrome.runtime.MessageSender;157158    const result = await messages.handleMessage(159      {160        type: "GET_ENTRY_FOR_EDIT",161        payload: { id: entry.id, explicitSummon: false },162      },163      crossDomainSender,164    );165166    expect((result as { ok: boolean; error: string }).ok).toBe(false);167    expect((result as { ok: boolean; error: string }).error).toBe("entry_not_found");168  });169170  // T-022d: GET_ENTRY_FOR_EDIT with matching sender domain → succeeds.171  // The control case for T-022c: a sender from the same domain as the entry172  // must be allowed through.173  it("T-022d: GET_ENTRY_FOR_EDIT from matching sender.url → returns entry", async () => {174    const entry = await vault.addEntry({175      type: "login",176      title: "Same Domain Entry",177      username: "legit",178      password: "pass123",179      url: "https://example.com",180    });181182    const matchingSender = {183      tab: { url: "https://example.com/path" },184      url: "https://example.com/path",185    } as unknown as chrome.runtime.MessageSender;186187    const result = await messages.handleMessage(188      {189        type: "GET_ENTRY_FOR_EDIT",190        payload: { id: entry.id, explicitSummon: false },191      },192      matchingSender,193    );194195    const { ok, data } = result as { ok: boolean; data: { title: string } };196    expect(ok).toBe(true);197    expect(data.title).toBe("Same Domain Entry");198  });199200  // T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true bypasses the domain check.201  // The explicit summon flag is set only by user-initiated keyboard shortcuts,202  // never by passive autofill. It allows the edit form to open for entries on203  // a subdomain (e.g., accounts.google.com for a google.com entry).204  it("T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true from cross-domain sender → returns entry", async () => {205    const entry = await vault.addEntry({206      type: "login",207      title: "Explicit Summon Entry",208      username: "user",209      password: "pw",210      url: "https://example.com",211    });212213    const crossDomainSender = {214      tab: { url: "https://completely-different.example.net/" },215      url: "https://completely-different.example.net/",216    } as unknown as chrome.runtime.MessageSender;217218    const result = await messages.handleMessage(219      {220        type: "GET_ENTRY_FOR_EDIT",221        payload: { id: entry.id, explicitSummon: true },222      },223      crossDomainSender,224    );225226    const { ok, data } = result as { ok: boolean; data: { title: string } };227    expect(ok).toBe(true);228    expect(data.title).toBe("Explicit Summon Entry");229  });230});