messages.passkey-offer-decline.test.ts

5.3 KB · raw

1// PB-26-109: the passkey offer-decline handler is page-relay, so any page's2// content script may send it. It used to take `payload.domain` — any non-empty3// string — and write peach_passkey_offer_declined:<domain> into storage.local4// with no origin binding and no length bound.5//6// Two things followed. Keys for domains never re-queried accumulated without7// limit (the 30-day TTL is only swept lazily), and a page could pre-decline a8// domain it does not own, silently suppressing Peach's "save passkey to Peach"9// offer on a site the user visits later.10//11// The handler now validates the domain against the browser-attested frame origin12// with the same isValidRpId check the sibling GET_PASSKEY_FOR_DOMAIN uses, and13// the stored key is length-bounded.1415import { beforeEach, describe, expect, it, vi } from "vitest";1617type StorageShape = Record<string, unknown>;1819const DECLINE_PREFIX = "peach_passkey_offer_declined:";2021function makeChromeMock(storage: StorageShape) {22  const chrome = {23    storage: {24      local: {25        get: vi.fn(async (key?: string | string[]) => {26          if (!key) return { ...storage };27          const keys = Array.isArray(key) ? key : [key];28          return keys.reduce<StorageShape>((acc, k) => { acc[k] = storage[k]; return acc; }, {});29        }),30        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),31        remove: vi.fn(async (keys: string | string[]) => {32          for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];33        }),34      },35      session: {36        get: vi.fn(async () => ({})),37        set: vi.fn(async () => {}),38        remove: vi.fn(async () => {}),39      },40    },41    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },42    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },43    runtime: {44      id: "test-extension-id",45      getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),46    },47  } as unknown as typeof globalThis.chrome;48  return chrome;49}5051function pageSender(url: string): chrome.runtime.MessageSender {52  return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;53}5455describe("PB-26-109: passkey offer-decline origin binding", () => {56  let storage: StorageShape;57  let messages: typeof import("./messages");5859  beforeEach(async () => {60    vi.resetModules();61    storage = {};62    (globalThis as Record<string, unknown>).chrome = makeChromeMock(storage);63    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));64    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);65    messages = await import("./messages");66  });6768  it("a cross-origin decline is refused and writes nothing", async () => {69    const res = await messages.handleMessage(70      { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "bank.example" } },71      pageSender("https://attacker.example.net/page"),72    );7374    expect(res).toMatchObject({ ok: false, error: "invalid_domain" });75    expect(storage[`${DECLINE_PREFIX}bank.example`]).toBeUndefined();76  });7778  it("an own-origin decline is accepted", async () => {79    const res = await messages.handleMessage(80      { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },81      pageSender("https://example.com/login"),82    );8384    expect(res).toMatchObject({ ok: true });85    expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number));86  });8788  // A page may decline a parent domain it sits under, matching WebAuthn's own89  // related-origin rules — that is what makes the offer meaningful on90  // accounts.example.com for an example.com credential.91  it("a subdomain may decline its registrable parent", async () => {92    const res = await messages.handleMessage(93      { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },94      pageSender("https://login.example.com/auth"),95    );9697    expect(res).toMatchObject({ ok: true });98    expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number));99  });100101  // The suffix trap: this host ends with ".attacker.example", not ".example.com".102  it("a lookalike host cannot decline example.com", async () => {103    const res = await messages.handleMessage(104      { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },105      pageSender("https://example.com.attacker.example/login"),106    );107108    expect(res).toMatchObject({ ok: false, error: "invalid_domain" });109    expect(storage[`${DECLINE_PREFIX}example.com`]).toBeUndefined();110  });111112  it("a bare public suffix is refused", async () => {113    const res = await messages.handleMessage(114      { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "com" } },115      pageSender("https://example.com/login"),116    );117118    expect(res).toMatchObject({ ok: false, error: "invalid_domain" });119  });120121  // The bound is enforced at the write, not by echoing an error: an over-long122  // domain must not become a storage key.123  it("an over-long domain is never written", async () => {124    const long = `${"a".repeat(250)}.example`;125    expect(long.length).toBeGreaterThan(253);126127    await messages.handleMessage(128      { type: "PASSKEY_OFFER_DECLINE", payload: { domain: long } },129      pageSender(`https://${long}/login`),130    );131132    expect(storage[`${DECLINE_PREFIX}${long}`]).toBeUndefined();133    expect(Object.keys(storage)).toHaveLength(0);134  });135});