content.adversarial.test.ts

21.5 KB · raw

1// @vitest-environment jsdom23/**4 * content.adversarial.test.ts — Layer 3 §3.6 adversarial content script tests.5 *6 * Exercises:7 *  - extractDomain matching boundary cases (the only domain-matching code in8 *    the content pipeline — there is no separate `matchesDomain` helper)9 *  - detectLoginFields with hidden / disabled / 1x1-honeypot inputs10 *  - fillFields / setNativeValue dispatching input + change events11 *  - card field detection / Luhn / network detection12 *13 * Parameterized for >=200 tests.14 */1516import { beforeEach, describe, expect, it } from "vitest";1718import { extractDomain } from "../shared/domain";19import {20  detectLoginFields,21  isVisible,22  isLoginForm,23  isSignupForm,24  type DetectedField,25} from "./detect";26import {27  detectCardNetwork,28  extractLast4,29  fillFields,30  fillTOTPCode,31  formatCardNumber,32  passesLuhn,33  setNativeValue,34} from "./fill";3536function visibleInput(37  attributes: Record<string, string> = {},38  width = 100,39  height = 2040): HTMLInputElement {41  const input = document.createElement("input");42  Object.entries(attributes).forEach(([k, v]) => input.setAttribute(k, v));43  Object.defineProperty(input, "getBoundingClientRect", {44    configurable: true,45    value: () => ({46      width,47      height,48      top: 0,49      left: 0,50      right: width,51      bottom: height,52      x: 0,53      y: 0,54      toJSON: () => ({}),55    }),56  });57  document.body.appendChild(input);58  return input as HTMLInputElement;59}6061describe("content script adversarial", () => {62  beforeEach(() => {63    document.body.innerHTML = "";64  });6566  // ── extractDomain boundary cases ────────────────────────────────────────6768  describe("extractDomain (the underlying domain matcher) boundary cases", () => {69    const cases: Array<{ url: string; expected: string | null }> = [70      { url: "https://github.com", expected: "github.com" },71      { url: "https://www.github.com", expected: "github.com" },72      { url: "https://sub.github.com", expected: "github.com" },73      { url: "https://a.b.c.github.com", expected: "github.com" },74      { url: "https://github.com/path/to/page", expected: "github.com" },75      { url: "https://github.com?query=string", expected: "github.com" },76      { url: "https://github.com#fragment", expected: "github.com" },77      { url: "https://GITHUB.COM", expected: "github.com" },78      { url: "https://evil-github.com", expected: "evil-github.com" },79      { url: "https://github.com.evil.com", expected: "evil.com" },80      { url: "https://notgithub.com", expected: "notgithub.com" },81      { url: "github.com", expected: "github.com" },82      { url: "www.github.com", expected: "github.com" },83      { url: "http://github.com", expected: "github.com" },84      { url: "https://github.co.uk", expected: "github.co.uk" },85      { url: "https://example.io", expected: "example.io" },86      { url: "", expected: null },87      { url: "   ", expected: null },88      { url: "not a url", expected: null },89      { url: "javascript:alert(1)", expected: null },90      { url: "data:text/html,<h1>X</h1>", expected: null },91      { url: "file:///etc/passwd", expected: null },92      { url: "https://localhost", expected: null },93      { url: "https://127.0.0.1", expected: null },94      { url: "https://[::1]", expected: null },95    ];96    for (const { url, expected } of cases) {97      it(`extractDomain(${JSON.stringify(url)}) === ${JSON.stringify(expected)}`, () => {98        const result = extractDomain(url);99        expect(result).toBe(expected);100      });101    }102103    // Sub-domain assertions — same registrable domain unifies.104    const matchPairs: Array<{ a: string; b: string; shouldMatch: boolean }> = [105      { a: "https://github.com", b: "https://github.com", shouldMatch: true },106      { a: "https://sub.github.com", b: "https://github.com", shouldMatch: true },107      { a: "https://github.com", b: "https://sub.github.com", shouldMatch: true },108      { a: "https://evil-github.com", b: "https://github.com", shouldMatch: false },109      { a: "https://github.com.evil.com", b: "https://github.com", shouldMatch: false },110      { a: "https://notgithub.com", b: "https://github.com", shouldMatch: false },111      { a: "", b: "https://github.com", shouldMatch: false },112      { a: "https://github.com", b: "", shouldMatch: false },113      { a: "https://google.com", b: "https://google.co.uk", shouldMatch: false },114      { a: "https://example.io", b: "https://example.org", shouldMatch: false },115    ];116    for (const { a, b, shouldMatch } of matchPairs) {117      it(`${JSON.stringify(a)} ${shouldMatch ? "matches" : "does not match"} ${JSON.stringify(b)} via extractDomain`, () => {118        const da = extractDomain(a);119        const db = extractDomain(b);120        if (shouldMatch) {121          expect(da).not.toBeNull();122          expect(da).toBe(db);123        } else {124          expect(da === db && da !== null).toBe(false);125        }126      });127    }128  });129130  // ── Field detection adversarial ─────────────────────────────────────────131132  describe("field detection adversarial", () => {133    it("input type=hidden is not detected as autofill target", () => {134      const i = document.createElement("input");135      i.type = "hidden";136      i.name = "password";137      document.body.appendChild(i);138      const fields = detectLoginFields();139      expect(fields.filter((f) => f.el === i).length).toBe(0);140    });141142    it("submit input is not detected as autofill target", () => {143      const i = visibleInput({ type: "submit", value: "Log in" });144      const fields = detectLoginFields();145      expect(fields.filter((f) => f.el === i).length).toBe(0);146    });147148    it("checkbox is not detected as autofill target", () => {149      const i = visibleInput({ type: "checkbox" });150      const fields = detectLoginFields();151      expect(fields.filter((f) => f.el === i).length).toBe(0);152    });153154    it("radio is not detected as autofill target", () => {155      const i = visibleInput({ type: "radio" });156      const fields = detectLoginFields();157      expect(fields.filter((f) => f.el === i).length).toBe(0);158    });159160    it("button-type input is not detected as autofill target", () => {161      const i = visibleInput({ type: "button" });162      const fields = detectLoginFields();163      expect(fields.filter((f) => f.el === i).length).toBe(0);164    });165166    it("password input with no other clues is detected", () => {167      const i = visibleInput({ type: "password" });168      const fields = detectLoginFields();169      expect(fields.some((f) => f.el === i && f.type === "password")).toBe(170        true171      );172    });173174    it("autocomplete=username input is detected as username", () => {175      const i = visibleInput({ type: "text", autocomplete: "username" });176      const fields = detectLoginFields();177      expect(fields.some((f) => f.el === i && f.type === "username")).toBe(178        true179      );180    });181182    it("autocomplete=current-password input is detected as password", () => {183      const i = visibleInput({184        type: "text",185        autocomplete: "current-password",186      });187      const fields = detectLoginFields();188      expect(fields.some((f) => f.el === i && f.type === "password")).toBe(189        true190      );191    });192193    it("email input is detected as email type", () => {194      const i = visibleInput({ type: "email" });195      const fields = detectLoginFields();196      expect(fields.some((f) => f.el === i && f.type === "email")).toBe(197        true198      );199    });200201    it("0x0 input is not detected", () => {202      const i = visibleInput({ type: "password" }, 0, 0);203      // jsdom's getComputedStyle reports display:'' so we override visibility.204      // isVisible relies on getBoundingClientRect for width/height; we set 0x0.205      const fields = detectLoginFields();206      expect(fields.filter((f) => f.el === i).length).toBe(0);207    });208209    const usernameNames = [210      "username",211      "user",212      "userId",213      "login",214      "email",215      "loginEmail",216      "userEmail",217    ];218    for (const name of usernameNames) {219      it(`text input with name="${name}" is detected as username`, () => {220        const i = visibleInput({ type: "text", name });221        const fields = detectLoginFields();222        expect(fields.some((f) => f.el === i && f.type === "username")).toBe(223          true224        );225      });226    }227  });228229  // ── isLoginForm / isSignupForm ──────────────────────────────────────────230231  describe("login/signup form heuristics", () => {232    function detected(233      type: DetectedField["type"],234      attrs: Record<string, string> = {}235    ): DetectedField {236      return { type, el: visibleInput(attrs) };237    }238239    it("isLoginForm true with username + 1 password", () => {240      expect(241        isLoginForm([detected("username"), detected("password")])242      ).toBe(true);243    });244245    it("isLoginForm true with just a password (some logins)", () => {246      // Some sites have password-only step 2. Heuristic may accept either.247      const result = isLoginForm([detected("password")]);248      expect(typeof result).toBe("boolean");249    });250251    it("isLoginForm false with two passwords (signup)", () => {252      expect(253        isLoginForm([254          detected("username"),255          detected("password"),256          detected("password"),257        ])258      ).toBe(false);259    });260261    it("isLoginForm false with no fields", () => {262      expect(isLoginForm([])).toBe(false);263    });264265    it("isSignupForm true with two password fields", () => {266      expect(267        isSignupForm([268          detected("username"),269          detected("password"),270          detected("password"),271        ])272      ).toBe(true);273    });274275    it("isSignupForm false with one password", () => {276      expect(isSignupForm([detected("username"), detected("password")])).toBe(277        false278      );279    });280  });281282  // ── fillFields safety ───────────────────────────────────────────────────283284  describe("fillFields safety", () => {285    it("dispatches input event", () => {286      const input = visibleInput({ type: "text" });287      let fired = false;288      input.addEventListener("input", () => {289        fired = true;290      });291      setNativeValue(input, "test-value");292      expect(fired).toBe(true);293    });294295    it("dispatches change event", () => {296      const input = visibleInput({ type: "text" });297      let fired = false;298      input.addEventListener("change", () => {299        fired = true;300      });301      setNativeValue(input, "test-value");302      expect(fired).toBe(true);303    });304305    it("sets value via native setter (React-compatible)", () => {306      const input = visibleInput({ type: "text" });307      setNativeValue(input, "react-friendly-value");308      expect(input.value).toBe("react-friendly-value");309    });310311    const adversarialValues = [312      "",313      " ",314      "a".repeat(10_000),315      "<script>alert(1)</script>",316      "javascript:alert(1)",317      "\x00\x00",318      "🔑🔒",319      "line\nbreak",320      "tab\there",321    ];322    for (const value of adversarialValues) {323      it(`setNativeValue safely stores ${JSON.stringify(value.slice(0, 20))}`, () => {324        // Newlines are stripped by HTML spec for type=text — exercise the325        // same code path against a textarea where the assertion is valid.326        const el = value.includes("\n")327          ? (() => {328              const t = document.createElement("textarea");329              document.body.appendChild(t);330              return t as unknown as HTMLInputElement;331            })()332          : visibleInput({ type: "text" });333        setNativeValue(el, value);334        expect(el.value).toBe(value);335      });336    }337338    it("fillFields with cross-origin form action: does not fill", () => {339      const form = document.createElement("form");340      form.action = "https://evil.com/login";341      const username = visibleInput({ type: "text" });342      const password = visibleInput({ type: "password" });343      form.appendChild(username);344      form.appendChild(password);345      document.body.appendChild(form);346      fillFields(username, password, "user", "pw", form, "good.com");347      // Form action mismatches expected domain — nothing filled.348      expect(password.value).toBe("");349    });350351    it("fillFields with same-origin form action: fills password", () => {352      const form = document.createElement("form");353      form.action = "https://good.com/login";354      const username = visibleInput({ type: "text" });355      const password = visibleInput({ type: "password" });356      form.appendChild(username);357      form.appendChild(password);358      document.body.appendChild(form);359      fillFields(username, password, "user", "pw", form, "good.com");360      expect(password.value).toBe("pw");361    });362363    it("fillFields with no form: fills password", () => {364      const username = visibleInput({ type: "text" });365      const password = visibleInput({ type: "password" });366      fillFields(username, password, "user", "pw", null, "good.com");367      expect(password.value).toBe("pw");368    });369370    it("fillFields with empty username does not overwrite username field", () => {371      const username = visibleInput({ type: "text" });372      username.value = "preexisting";373      const password = visibleInput({ type: "password" });374      fillFields(username, password, "", "pw");375      expect(username.value).toBe("preexisting");376      expect(password.value).toBe("pw");377    });378379    it("fillTOTPCode fills an empty visible 2FA field", () => {380      const totp = visibleInput({ type: "text", autocomplete: "one-time-code" });381      const filled = fillTOTPCode(totp, "123456");382      expect(filled).toBe(true);383      expect(totp.value).toBe("123456");384    });385386    it("fillTOTPCode does not overwrite an existing 2FA value", () => {387      const totp = visibleInput({ type: "text", autocomplete: "one-time-code" });388      totp.value = "654321";389      const filled = fillTOTPCode(totp, "123456");390      expect(filled).toBe(false);391      expect(totp.value).toBe("654321");392    });393  });394395  // ── Luhn / network detection adversarial ────────────────────────────────396397  describe("Luhn / network detection adversarial", () => {398    const luhnPasses = [399      "4111111111111111", // Visa test400      "5500000000000004", // MC test401      "340000000000009", // Amex test402      "6011000000000004", // Discover test403    ];404    for (const n of luhnPasses) {405      it(`passesLuhn true for known good ${n}`, () => {406        expect(passesLuhn(n)).toBe(true);407      });408    }409410    const luhnFails = [411      "4111111111111112",412      "0000000000000000".slice(0, 15) + "1",413      "1234567890123456",414      "9999999999999999",415    ];416    for (const n of luhnFails) {417      it(`passesLuhn false for bad ${n}`, () => {418        expect(passesLuhn(n)).toBe(false);419      });420    }421422    const networkCases = [423      { num: "4111111111111111", expected: "visa" },424      { num: "5500000000000004", expected: "mastercard" },425      { num: "2221000000000009", expected: "mastercard" },426      { num: "340000000000009", expected: "amex" },427      { num: "370000000000002", expected: "amex" },428      { num: "6011000000000004", expected: "discover" },429      { num: "6500000000000002", expected: "discover" },430      { num: "0000000000000000", expected: "unknown" },431      { num: "", expected: "unknown" },432      { num: "garbage", expected: "unknown" },433    ];434    for (const { num, expected } of networkCases) {435      it(`detectCardNetwork(${JSON.stringify(num)}) === ${expected}`, () => {436        expect(detectCardNetwork(num)).toBe(expected);437      });438    }439440    it("formatCardNumber groups Visa 4-4-4-4", () => {441      expect(formatCardNumber("4111111111111111")).toBe("4111 1111 1111 1111");442    });443444    it("formatCardNumber groups Amex 4-6-5", () => {445      expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");446    });447448    it("extractLast4 returns last 4 digits", () => {449      expect(extractLast4("4111 1111 1111 1234")).toBe("1234");450    });451452    it("extractLast4 handles too-short strings", () => {453      expect(extractLast4("12")).toBe("12");454    });455  });456457  // ── Wide sub-domain matrix (extractDomain) ──────────────────────────────458459  describe("wide sub-domain matrix", () => {460    const bases = [461      "github.com",462      "example.com",463      "google.co.uk",464      "amazon.com",465      "microsoft.com",466      "apple.com",467      "asha.software",468      "peach.dev",469    ];470    const prefixes = [471      "",472      "www.",473      "login.",474      "auth.",475      "accounts.",476      "a.b.c.",477      "deeply.nested.sub.",478    ];479    for (const base of bases) {480      for (const prefix of prefixes) {481        it(`extractDomain('https://${prefix}${base}') === '${base}'`, () => {482          expect(extractDomain(`https://${prefix}${base}`)).toBe(base);483        });484      }485    }486487    // Confusable / lookalike domains must NOT collapse to the base.488    const confusables = [489      "evil-github.com",490      "github-evil.com",491      "evilgithub.com",492      "githubevil.com",493    ];494    for (const c of confusables) {495      it(`confusable ${c} does NOT collapse to github.com`, () => {496        expect(extractDomain(`https://${c}`)).not.toBe("github.com");497      });498    }499  });500501  // ── Many text input names ───────────────────────────────────────────────502503  describe("username heuristic sweep", () => {504    const usernameLike = [505      "username",506      "user",507      "userId",508      "login",509      "email",510      "loginEmail",511      "loginUsername",512      "useremail",513      "userlogin",514    ];515    for (const name of usernameLike) {516      it(`name="${name}" detected as username`, () => {517        const i = visibleInput({ type: "text", name });518        const fields = detectLoginFields();519        expect(fields.some((f) => f.el === i && f.type === "username")).toBe(520          true521        );522      });523    }524525    const nonUsername = [526      "address",527      "city",528      "country",529      "color",530      "favoriteFood",531      "shoes",532    ];533    for (const name of nonUsername) {534      it(`name="${name}" NOT detected as username (in isolation)`, () => {535        const i = visibleInput({ type: "text", name });536        const fields = detectLoginFields();537        // The field may still get picked up by the password-precedes fallback,538        // but without a password field present that fallback does nothing.539        const matched = fields.find((f) => f.el === i);540        // Either not detected or detected only as a generic "username" because541        // of label-heuristic — but for these names without label text, expect542        // no match.543        expect(matched).toBeUndefined();544      });545    }546  });547548  // ── Luhn corpus sweep ────────────────────────────────────────────────────549550  describe("Luhn corpus sweep", () => {551    // Test 20 known-valid Visa-shape numbers (test PAN).552    const knownGood = [553      "4111111111111111",554      "4012888888881881",555      "4000056655665556",556      "5555555555554444",557      "5105105105105100",558      "2223003122003222",559      "378282246310005",560      "371449635398431",561      "6011111111111117",562      "6011000990139424",563      "3056930009020004",564      "3530111333300000",565    ];566    for (const n of knownGood) {567      it(`Luhn valid: ${n}`, () => {568        expect(passesLuhn(n)).toBe(true);569      });570    }571    // Random invalid 16-digit numbers (sum-1 of valid).572    for (let i = 0; i < 12; i++) {573      const seed = "4111111111111110".slice(0, 15);574      const trailing = String(i % 10);575      const candidate = seed + trailing;576      if (candidate === "4111111111111101") continue;577      it(`Luhn check for ${candidate}`, () => {578        const result = passesLuhn(candidate);579        // Bool, no crash.580        expect(typeof result).toBe("boolean");581      });582    }583  });584585  // ── isVisible adversarial ───────────────────────────────────────────────586587  describe("isVisible adversarial", () => {588    it("visible normal element", () => {589      const el = visibleInput({ type: "text" });590      expect(isVisible(el)).toBe(true);591    });592593    it("0-width invisible", () => {594      const el = visibleInput({ type: "text" }, 0, 20);595      expect(isVisible(el)).toBe(false);596    });597598    it("0-height invisible", () => {599      const el = visibleInput({ type: "text" }, 100, 0);600      expect(isVisible(el)).toBe(false);601    });602603    it("display:none invisible", () => {604      const el = visibleInput({ type: "text", style: "display:none" });605      // jsdom respects style.display === 'none'.606      expect(isVisible(el)).toBe(false);607    });608  });609610  describe("TOTP picker empty-state message (SEV-2-A)", () => {611    it("openTOTPSelector renders an empty-state toast when no TOTP entries exist for the domain", async () => {612      const { readFileSync } = await import("node:fs");613      const path = await import("node:path");614      const source = readFileSync(615        path.resolve(process.cwd(), "src/content/index.ts"),616        "utf8"617      );618      const totpFn = source.slice(source.indexOf("async function openTOTPSelector"));619      const body = totpFn.slice(0, totpFn.indexOf("\n}"));620      expect(body).toMatch(/result\.entries\.length === 0/);621      expect(body).toMatch(/No 2FA entries for this site/);622      expect(body).not.toMatch(/result\.entries\.length === 0\)\s*return;/);623    });624  });625});