// @vitest-environment jsdom // // PB-26-105 — the form-action phishing gate must be enforced on every // content-script fill path, not only fillFields. Covers the three paths the // finding cites: the TOTP-only selector, the signup "copy & fill" surface, // and the credential path's TOTP/clipboard/canary side effects. import { vi } from "vitest"; const { predictBatch, dismissSpy } = vi.hoisted(() => ({ predictBatch: vi.fn(() => Promise.resolve([] as unknown[])), dismissSpy: vi.fn(), })); vi.mock("./inject", () => ({ injectFieldIcon: vi.fn(() => vi.fn()), showAutofillSelector: vi.fn(() => dismissSpy), showTOTPSelector: vi.fn(() => dismissSpy), showCardSelector: vi.fn(() => dismissSpy), showRememberSiteToast: vi.fn(), showPasskeyAvailable: vi.fn(), showPasskeyProGate: vi.fn(), showPasskeyRegistrationOffer: vi.fn(), })); vi.mock("./content-ml-detector", () => ({ contentMLDetector: { predictBatch }, })); import { afterEach, beforeEach, describe, expect, it } from "vitest"; // Each test reloads the content module: index.ts keeps page-wide fill state // (PAGE_FILL_GRACE_MS) that would otherwise suppress the selector in every // test after the first successful fill. async function loadSubject() { vi.resetModules(); const setup = await import("./_setup"); const index = await import("./index"); const inject = await import("./inject"); return { setup, handleFieldInteraction: index.handleFieldInteraction, showSelector: inject.showAutofillSelector as unknown as ReturnType, showTOTP: inject.showTOTPSelector as unknown as ReturnType, }; } beforeEach(() => { document.body.innerHTML = ""; predictBatch.mockReset(); predictBatch.mockReturnValue(Promise.resolve([])); }); afterEach(async () => { vi.useRealTimers(); // index.ts's url-watcher MutationObserver reads location.href; flush it while // jsdom globals still exist. await new Promise((r) => setTimeout(r, 0)); }); describe("PB-26-105: form-action gate on every fill path", () => { it("TOTP-only path refuses a live code when the form posts off-site", async () => { const { setup, handleFieldInteraction, showTOTP } = await loadSubject(); const { makeInput } = setup; const form = document.createElement("form"); form.action = "https://evil.example/verify"; document.body.appendChild(form); const otp = makeInput( { type: "text", name: "otp", autocomplete: "one-time-code" }, form, ); setup.setMessageResponder((msg: any) => { if (msg.type === "AUTOFILL_QUERY") { return { ok: true, data: [{ id: "t1", title: "Acme", username: "alice" }] }; } if (msg.type === "GET_ENTRY_FOR_FILL") { return { ok: true, data: { id: "t1", title: "Acme", username: "alice", totpCode: "123456" }, }; } return { ok: true, data: null }; }); await handleFieldInteraction(otp, true); await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled()); await (showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise)("t1"); expect(otp.value).toBe(""); }); it("TOTP-only path still fills when the form posts to the page's own domain", async () => { const { setup, handleFieldInteraction, showTOTP } = await loadSubject(); const form = document.createElement("form"); form.action = "https://localhost/verify"; document.body.appendChild(form); const otp = setup.makeInput( { type: "text", name: "otp", autocomplete: "one-time-code" }, form, ); setup.setMessageResponder((msg: any) => { if (msg.type === "AUTOFILL_QUERY") { return { ok: true, data: [{ id: "t1", title: "Acme", username: "alice" }] }; } if (msg.type === "GET_ENTRY_FOR_FILL") { return { ok: true, data: { id: "t1", title: "Acme", username: "alice", totpCode: "123456" }, }; } return { ok: true, data: null }; }); await handleFieldInteraction(otp, true); await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled()); await (showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise)("t1"); expect(otp.value).toBe("123456"); }); it("signup fill writes neither password nor confirm-password when refused", async () => { const { setup, handleFieldInteraction, showSelector } = await loadSubject(); const form = document.createElement("form"); form.action = "https://evil.example/signup"; document.body.appendChild(form); setup.makeInput({ type: "text", name: "username", autocomplete: "username" }, form); const password = setup.makeInput( { type: "password", name: "password", autocomplete: "new-password" }, form, ); const confirm = setup.makeInput( { type: "password", name: "confirmPassword", autocomplete: "new-password" }, form, ); setup.setMessageResponder((msg: any) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null }, ); await handleFieldInteraction(password, true); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); const options = showSelector.mock.calls.at(-1)![3] as any; expect(options.isSignup).toBe(true); await options.onFillFields("alice", "correct-horse-battery", ""); expect(password.value).toBe(""); expect(confirm.value).toBe(""); }); it("refused credential fill writes no TOTP, no clipboard and no canary ping", async () => { const { setup, handleFieldInteraction, showSelector } = await loadSubject(); const { form, username, password } = setup.createLoginForm(); form.action = "https://evil.example/login"; setup.setMessageResponder((msg: any) => { if (msg.type === "AUTOFILL_QUERY") { return { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] }; } if (msg.type === "GET_ENTRY_FOR_FILL") { return { ok: true, data: { id: "e1", title: "Acme", username: "alice", password: "correct-horse", totpCode: "123456", canary: { webhookUrl: "https://hooks.example/x", pingCount: 0 }, }, }; } return { ok: true, data: null }; }); await handleFieldInteraction(username, true); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); await (showSelector.mock.calls.at(-1)![2] as (id: string) => Promise)("e1"); expect(username.value).toBe(""); expect(password.value).toBe(""); expect(setup.clipboardWrite).not.toHaveBeenCalled(); expect(setup.sentMessages.some((m: any) => m?.type === "FIRE_CANARY_PING")).toBe(false); }); });