content.form-action-gate.test.ts

6.6 KB · raw

1// @vitest-environment jsdom2//3// PB-26-105 — the form-action phishing gate must be enforced on every4// content-script fill path, not only fillFields. Covers the three paths the5// finding cites: the TOTP-only selector, the signup "copy & fill" surface,6// and the credential path's TOTP/clipboard/canary side effects.78import { vi } from "vitest";910const { predictBatch, dismissSpy } = vi.hoisted(() => ({11  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),12  dismissSpy: vi.fn(),13}));1415vi.mock("./inject", () => ({16  injectFieldIcon: vi.fn(() => vi.fn()),17  showAutofillSelector: vi.fn(() => dismissSpy),18  showTOTPSelector: vi.fn(() => dismissSpy),19  showCardSelector: vi.fn(() => dismissSpy),20  showRememberSiteToast: vi.fn(),21  showPasskeyAvailable: vi.fn(),22  showPasskeyProGate: vi.fn(),23  showPasskeyRegistrationOffer: vi.fn(),24}));2526vi.mock("./content-ml-detector", () => ({27  contentMLDetector: { predictBatch },28}));2930import { afterEach, beforeEach, describe, expect, it } from "vitest";3132// Each test reloads the content module: index.ts keeps page-wide fill state33// (PAGE_FILL_GRACE_MS) that would otherwise suppress the selector in every34// test after the first successful fill.35async function loadSubject() {36  vi.resetModules();37  const setup = await import("./_setup");38  const index = await import("./index");39  const inject = await import("./inject");40  return {41    setup,42    handleFieldInteraction: index.handleFieldInteraction,43    showSelector: inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>,44    showTOTP: inject.showTOTPSelector as unknown as ReturnType<typeof vi.fn>,45  };46}4748beforeEach(() => {49  document.body.innerHTML = "";50  predictBatch.mockReset();51  predictBatch.mockReturnValue(Promise.resolve([]));52});5354afterEach(async () => {55  vi.useRealTimers();56  // index.ts's url-watcher MutationObserver reads location.href; flush it while57  // jsdom globals still exist.58  await new Promise((r) => setTimeout(r, 0));59});6061describe("PB-26-105: form-action gate on every fill path", () => {62  it("TOTP-only path refuses a live code when the form posts off-site", async () => {63    const { setup, handleFieldInteraction, showTOTP } = await loadSubject();64    const { makeInput } = setup;65    const form = document.createElement("form");66    form.action = "https://evil.example/verify";67    document.body.appendChild(form);68    const otp = makeInput(69      { type: "text", name: "otp", autocomplete: "one-time-code" },70      form,71    );72    setup.setMessageResponder((msg: any) => {73      if (msg.type === "AUTOFILL_QUERY") {74        return { ok: true, data: [{ id: "t1", title: "Acme", username: "alice" }] };75      }76      if (msg.type === "GET_ENTRY_FOR_FILL") {77        return {78          ok: true,79          data: { id: "t1", title: "Acme", username: "alice", totpCode: "123456" },80        };81      }82      return { ok: true, data: null };83    });8485    await handleFieldInteraction(otp, true);86    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());87    await (showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>)("t1");8889    expect(otp.value).toBe("");90  });9192  it("TOTP-only path still fills when the form posts to the page's own domain", async () => {93    const { setup, handleFieldInteraction, showTOTP } = await loadSubject();94    const form = document.createElement("form");95    form.action = "https://localhost/verify";96    document.body.appendChild(form);97    const otp = setup.makeInput(98      { type: "text", name: "otp", autocomplete: "one-time-code" },99      form,100    );101    setup.setMessageResponder((msg: any) => {102      if (msg.type === "AUTOFILL_QUERY") {103        return { ok: true, data: [{ id: "t1", title: "Acme", username: "alice" }] };104      }105      if (msg.type === "GET_ENTRY_FOR_FILL") {106        return {107          ok: true,108          data: { id: "t1", title: "Acme", username: "alice", totpCode: "123456" },109        };110      }111      return { ok: true, data: null };112    });113114    await handleFieldInteraction(otp, true);115    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());116    await (showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>)("t1");117118    expect(otp.value).toBe("123456");119  });120121  it("signup fill writes neither password nor confirm-password when refused", async () => {122    const { setup, handleFieldInteraction, showSelector } = await loadSubject();123    const form = document.createElement("form");124    form.action = "https://evil.example/signup";125    document.body.appendChild(form);126    setup.makeInput({ type: "text", name: "username", autocomplete: "username" }, form);127    const password = setup.makeInput(128      { type: "password", name: "password", autocomplete: "new-password" },129      form,130    );131    const confirm = setup.makeInput(132      { type: "password", name: "confirmPassword", autocomplete: "new-password" },133      form,134    );135    setup.setMessageResponder((msg: any) =>136      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null },137    );138139    await handleFieldInteraction(password, true);140    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());141142    const options = showSelector.mock.calls.at(-1)![3] as any;143    expect(options.isSignup).toBe(true);144    await options.onFillFields("alice", "correct-horse-battery", "");145146    expect(password.value).toBe("");147    expect(confirm.value).toBe("");148  });149150  it("refused credential fill writes no TOTP, no clipboard and no canary ping", async () => {151    const { setup, handleFieldInteraction, showSelector } = await loadSubject();152    const { form, username, password } = setup.createLoginForm();153    form.action = "https://evil.example/login";154    setup.setMessageResponder((msg: any) => {155      if (msg.type === "AUTOFILL_QUERY") {156        return { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] };157      }158      if (msg.type === "GET_ENTRY_FOR_FILL") {159        return {160          ok: true,161          data: {162            id: "e1",163            title: "Acme",164            username: "alice",165            password: "correct-horse",166            totpCode: "123456",167            canary: { webhookUrl: "https://hooks.example/x", pingCount: 0 },168          },169        };170      }171      return { ok: true, data: null };172    });173174    await handleFieldInteraction(username, true);175    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());176177    await (showSelector.mock.calls.at(-1)![2] as (id: string) => Promise<void>)("e1");178179    expect(username.value).toBe("");180    expect(password.value).toBe("");181    expect(setup.clipboardWrite).not.toHaveBeenCalled();182    expect(setup.sentMessages.some((m: any) => m?.type === "FIRE_CANARY_PING")).toBe(false);183  });184});