content.adversarial.test.ts

20.2 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  formatCardNumber,31  passesLuhn,32  setNativeValue,33} from "./fill";3435function visibleInput(36  attributes: Record<string, string> = {},37  width = 100,38  height = 2039): HTMLInputElement {40  const input = document.createElement("input");41  Object.entries(attributes).forEach(([k, v]) => input.setAttribute(k, v));42  Object.defineProperty(input, "getBoundingClientRect", {43    configurable: true,44    value: () => ({45      width,46      height,47      top: 0,48      left: 0,49      right: width,50      bottom: height,51      x: 0,52      y: 0,53      toJSON: () => ({}),54    }),55  });56  document.body.appendChild(input);57  return input as HTMLInputElement;58}5960describe("content script adversarial", () => {61  beforeEach(() => {62    document.body.innerHTML = "";63  });6465  // ── extractDomain boundary cases ────────────────────────────────────────6667  describe("extractDomain (the underlying domain matcher) boundary cases", () => {68    const cases: Array<{ url: string; expected: string | null }> = [69      { url: "https://github.com", expected: "github.com" },70      { url: "https://www.github.com", expected: "github.com" },71      { url: "https://sub.github.com", expected: "github.com" },72      { url: "https://a.b.c.github.com", expected: "github.com" },73      { url: "https://github.com/path/to/page", expected: "github.com" },74      { url: "https://github.com?query=string", expected: "github.com" },75      { url: "https://github.com#fragment", expected: "github.com" },76      { url: "https://GITHUB.COM", expected: "github.com" },77      { url: "https://evil-github.com", expected: "evil-github.com" },78      { url: "https://github.com.evil.com", expected: "evil.com" },79      { url: "https://notgithub.com", expected: "notgithub.com" },80      { url: "github.com", expected: "github.com" },81      { url: "www.github.com", expected: "github.com" },82      { url: "http://github.com", expected: "github.com" },83      { url: "https://github.co.uk", expected: "github.co.uk" },84      { url: "https://example.io", expected: "example.io" },85      { url: "", expected: null },86      { url: "   ", expected: null },87      { url: "not a url", expected: null },88      { url: "javascript:alert(1)", expected: null },89      { url: "data:text/html,<h1>X</h1>", expected: null },90      { url: "file:///etc/passwd", expected: null },91      { url: "https://localhost", expected: null },92      { url: "https://127.0.0.1", expected: null },93      { url: "https://[::1]", expected: null },94    ];95    for (const { url, expected } of cases) {96      it(`extractDomain(${JSON.stringify(url)}) === ${JSON.stringify(expected)}`, () => {97        const result = extractDomain(url);98        expect(result).toBe(expected);99      });100    }101102    // Sub-domain assertions — same registrable domain unifies.103    const matchPairs: Array<{ a: string; b: string; shouldMatch: boolean }> = [104      { a: "https://github.com", b: "https://github.com", shouldMatch: true },105      { a: "https://sub.github.com", b: "https://github.com", shouldMatch: true },106      { a: "https://github.com", b: "https://sub.github.com", shouldMatch: true },107      { a: "https://evil-github.com", b: "https://github.com", shouldMatch: false },108      { a: "https://github.com.evil.com", b: "https://github.com", shouldMatch: false },109      { a: "https://notgithub.com", b: "https://github.com", shouldMatch: false },110      { a: "", b: "https://github.com", shouldMatch: false },111      { a: "https://github.com", b: "", shouldMatch: false },112      { a: "https://google.com", b: "https://google.co.uk", shouldMatch: false },113      { a: "https://example.io", b: "https://example.org", shouldMatch: false },114    ];115    for (const { a, b, shouldMatch } of matchPairs) {116      it(`${JSON.stringify(a)} ${shouldMatch ? "matches" : "does not match"} ${JSON.stringify(b)} via extractDomain`, () => {117        const da = extractDomain(a);118        const db = extractDomain(b);119        if (shouldMatch) {120          expect(da).not.toBeNull();121          expect(da).toBe(db);122        } else {123          expect(da === db && da !== null).toBe(false);124        }125      });126    }127  });128129  // ── Field detection adversarial ─────────────────────────────────────────130131  describe("field detection adversarial", () => {132    it("input type=hidden is not detected as autofill target", () => {133      const i = document.createElement("input");134      i.type = "hidden";135      i.name = "password";136      document.body.appendChild(i);137      const fields = detectLoginFields();138      expect(fields.filter((f) => f.el === i).length).toBe(0);139    });140141    it("submit input is not detected as autofill target", () => {142      const i = visibleInput({ type: "submit", value: "Log in" });143      const fields = detectLoginFields();144      expect(fields.filter((f) => f.el === i).length).toBe(0);145    });146147    it("checkbox is not detected as autofill target", () => {148      const i = visibleInput({ type: "checkbox" });149      const fields = detectLoginFields();150      expect(fields.filter((f) => f.el === i).length).toBe(0);151    });152153    it("radio is not detected as autofill target", () => {154      const i = visibleInput({ type: "radio" });155      const fields = detectLoginFields();156      expect(fields.filter((f) => f.el === i).length).toBe(0);157    });158159    it("button-type input is not detected as autofill target", () => {160      const i = visibleInput({ type: "button" });161      const fields = detectLoginFields();162      expect(fields.filter((f) => f.el === i).length).toBe(0);163    });164165    it("password input with no other clues is detected", () => {166      const i = visibleInput({ type: "password" });167      const fields = detectLoginFields();168      expect(fields.some((f) => f.el === i && f.type === "password")).toBe(169        true170      );171    });172173    it("autocomplete=username input is detected as username", () => {174      const i = visibleInput({ type: "text", autocomplete: "username" });175      const fields = detectLoginFields();176      expect(fields.some((f) => f.el === i && f.type === "username")).toBe(177        true178      );179    });180181    it("autocomplete=current-password input is detected as password", () => {182      const i = visibleInput({183        type: "text",184        autocomplete: "current-password",185      });186      const fields = detectLoginFields();187      expect(fields.some((f) => f.el === i && f.type === "password")).toBe(188        true189      );190    });191192    it("email input is detected as username", () => {193      const i = visibleInput({ type: "email" });194      const fields = detectLoginFields();195      expect(fields.some((f) => f.el === i && f.type === "username")).toBe(196        true197      );198    });199200    it("0x0 input is not detected", () => {201      const i = visibleInput({ type: "password" }, 0, 0);202      // jsdom's getComputedStyle reports display:'' so we override visibility.203      // isVisible relies on getBoundingClientRect for width/height; we set 0x0.204      const fields = detectLoginFields();205      expect(fields.filter((f) => f.el === i).length).toBe(0);206    });207208    const usernameNames = [209      "username",210      "user",211      "userId",212      "login",213      "email",214      "loginEmail",215      "userEmail",216    ];217    for (const name of usernameNames) {218      it(`text input with name="${name}" is detected as username`, () => {219        const i = visibleInput({ type: "text", name });220        const fields = detectLoginFields();221        expect(fields.some((f) => f.el === i && f.type === "username")).toBe(222          true223        );224      });225    }226  });227228  // ── isLoginForm / isSignupForm ──────────────────────────────────────────229230  describe("login/signup form heuristics", () => {231    function detected(232      type: DetectedField["type"],233      attrs: Record<string, string> = {}234    ): DetectedField {235      return { type, el: visibleInput(attrs) };236    }237238    it("isLoginForm true with username + 1 password", () => {239      expect(240        isLoginForm([detected("username"), detected("password")])241      ).toBe(true);242    });243244    it("isLoginForm true with just a password (some logins)", () => {245      // Some sites have password-only step 2. Heuristic may accept either.246      const result = isLoginForm([detected("password")]);247      expect(typeof result).toBe("boolean");248    });249250    it("isLoginForm false with two passwords (signup)", () => {251      expect(252        isLoginForm([253          detected("username"),254          detected("password"),255          detected("password"),256        ])257      ).toBe(false);258    });259260    it("isLoginForm false with no fields", () => {261      expect(isLoginForm([])).toBe(false);262    });263264    it("isSignupForm true with two password fields", () => {265      expect(266        isSignupForm([267          detected("username"),268          detected("password"),269          detected("password"),270        ])271      ).toBe(true);272    });273274    it("isSignupForm false with one password", () => {275      expect(isSignupForm([detected("username"), detected("password")])).toBe(276        false277      );278    });279  });280281  // ── fillFields safety ───────────────────────────────────────────────────282283  describe("fillFields safety", () => {284    it("dispatches input event", () => {285      const input = visibleInput({ type: "text" });286      let fired = false;287      input.addEventListener("input", () => {288        fired = true;289      });290      setNativeValue(input, "test-value");291      expect(fired).toBe(true);292    });293294    it("dispatches change event", () => {295      const input = visibleInput({ type: "text" });296      let fired = false;297      input.addEventListener("change", () => {298        fired = true;299      });300      setNativeValue(input, "test-value");301      expect(fired).toBe(true);302    });303304    it("sets value via native setter (React-compatible)", () => {305      const input = visibleInput({ type: "text" });306      setNativeValue(input, "react-friendly-value");307      expect(input.value).toBe("react-friendly-value");308    });309310    const adversarialValues = [311      "",312      " ",313      "a".repeat(10_000),314      "<script>alert(1)</script>",315      "javascript:alert(1)",316      "\x00\x00",317      "🔑🔒",318      "line\nbreak",319      "tab\there",320    ];321    for (const value of adversarialValues) {322      it(`setNativeValue safely stores ${JSON.stringify(value.slice(0, 20))}`, () => {323        // Newlines are stripped by HTML spec for type=text — exercise the324        // same code path against a textarea where the assertion is valid.325        const el = value.includes("\n")326          ? (() => {327              const t = document.createElement("textarea");328              document.body.appendChild(t);329              return t as unknown as HTMLInputElement;330            })()331          : visibleInput({ type: "text" });332        setNativeValue(el, value);333        expect(el.value).toBe(value);334      });335    }336337    it("fillFields with cross-origin form action: does not fill", () => {338      const form = document.createElement("form");339      form.action = "https://evil.com/login";340      const username = visibleInput({ type: "text" });341      const password = visibleInput({ type: "password" });342      form.appendChild(username);343      form.appendChild(password);344      document.body.appendChild(form);345      fillFields(username, password, "user", "pw", form, "good.com");346      // Form action mismatches expected domain — nothing filled.347      expect(password.value).toBe("");348    });349350    it("fillFields with same-origin form action: fills password", () => {351      const form = document.createElement("form");352      form.action = "https://good.com/login";353      const username = visibleInput({ type: "text" });354      const password = visibleInput({ type: "password" });355      form.appendChild(username);356      form.appendChild(password);357      document.body.appendChild(form);358      fillFields(username, password, "user", "pw", form, "good.com");359      expect(password.value).toBe("pw");360    });361362    it("fillFields with no form: fills password", () => {363      const username = visibleInput({ type: "text" });364      const password = visibleInput({ type: "password" });365      fillFields(username, password, "user", "pw", null, "good.com");366      expect(password.value).toBe("pw");367    });368369    it("fillFields with empty username does not overwrite username field", () => {370      const username = visibleInput({ type: "text" });371      username.value = "preexisting";372      const password = visibleInput({ type: "password" });373      fillFields(username, password, "", "pw");374      expect(username.value).toBe("preexisting");375      expect(password.value).toBe("pw");376    });377  });378379  // ── Luhn / network detection adversarial ────────────────────────────────380381  describe("Luhn / network detection adversarial", () => {382    const luhnPasses = [383      "4111111111111111", // Visa test384      "5500000000000004", // MC test385      "340000000000009", // Amex test386      "6011000000000004", // Discover test387    ];388    for (const n of luhnPasses) {389      it(`passesLuhn true for known good ${n}`, () => {390        expect(passesLuhn(n)).toBe(true);391      });392    }393394    const luhnFails = [395      "4111111111111112",396      "0000000000000000".slice(0, 15) + "1",397      "1234567890123456",398      "9999999999999999",399    ];400    for (const n of luhnFails) {401      it(`passesLuhn false for bad ${n}`, () => {402        expect(passesLuhn(n)).toBe(false);403      });404    }405406    const networkCases = [407      { num: "4111111111111111", expected: "visa" },408      { num: "5500000000000004", expected: "mastercard" },409      { num: "2221000000000009", expected: "mastercard" },410      { num: "340000000000009", expected: "amex" },411      { num: "370000000000002", expected: "amex" },412      { num: "6011000000000004", expected: "discover" },413      { num: "6500000000000002", expected: "discover" },414      { num: "0000000000000000", expected: "unknown" },415      { num: "", expected: "unknown" },416      { num: "garbage", expected: "unknown" },417    ];418    for (const { num, expected } of networkCases) {419      it(`detectCardNetwork(${JSON.stringify(num)}) === ${expected}`, () => {420        expect(detectCardNetwork(num)).toBe(expected);421      });422    }423424    it("formatCardNumber groups Visa 4-4-4-4", () => {425      expect(formatCardNumber("4111111111111111")).toBe("4111 1111 1111 1111");426    });427428    it("formatCardNumber groups Amex 4-6-5", () => {429      expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");430    });431432    it("extractLast4 returns last 4 digits", () => {433      expect(extractLast4("4111 1111 1111 1234")).toBe("1234");434    });435436    it("extractLast4 handles too-short strings", () => {437      expect(extractLast4("12")).toBe("12");438    });439  });440441  // ── Wide sub-domain matrix (extractDomain) ──────────────────────────────442443  describe("wide sub-domain matrix", () => {444    const bases = [445      "github.com",446      "example.com",447      "google.co.uk",448      "amazon.com",449      "microsoft.com",450      "apple.com",451      "asha.software",452      "peach.dev",453    ];454    const prefixes = [455      "",456      "www.",457      "login.",458      "auth.",459      "accounts.",460      "a.b.c.",461      "deeply.nested.sub.",462    ];463    for (const base of bases) {464      for (const prefix of prefixes) {465        it(`extractDomain('https://${prefix}${base}') === '${base}'`, () => {466          expect(extractDomain(`https://${prefix}${base}`)).toBe(base);467        });468      }469    }470471    // Confusable / lookalike domains must NOT collapse to the base.472    const confusables = [473      "evil-github.com",474      "github-evil.com",475      "evilgithub.com",476      "githubevil.com",477    ];478    for (const c of confusables) {479      it(`confusable ${c} does NOT collapse to github.com`, () => {480        expect(extractDomain(`https://${c}`)).not.toBe("github.com");481      });482    }483  });484485  // ── Many text input names ───────────────────────────────────────────────486487  describe("username heuristic sweep", () => {488    const usernameLike = [489      "username",490      "user",491      "userId",492      "login",493      "email",494      "loginEmail",495      "loginUsername",496      "useremail",497      "userlogin",498    ];499    for (const name of usernameLike) {500      it(`name="${name}" detected as username`, () => {501        const i = visibleInput({ type: "text", name });502        const fields = detectLoginFields();503        expect(fields.some((f) => f.el === i && f.type === "username")).toBe(504          true505        );506      });507    }508509    const nonUsername = [510      "address",511      "city",512      "country",513      "color",514      "favoriteFood",515      "shoes",516    ];517    for (const name of nonUsername) {518      it(`name="${name}" NOT detected as username (in isolation)`, () => {519        const i = visibleInput({ type: "text", name });520        const fields = detectLoginFields();521        // The field may still get picked up by the password-precedes fallback,522        // but without a password field present that fallback does nothing.523        const matched = fields.find((f) => f.el === i);524        // Either not detected or detected only as a generic "username" because525        // of label-heuristic — but for these names without label text, expect526        // no match.527        expect(matched).toBeUndefined();528      });529    }530  });531532  // ── Luhn corpus sweep ────────────────────────────────────────────────────533534  describe("Luhn corpus sweep", () => {535    // Test 20 known-valid Visa-shape numbers (test PAN).536    const knownGood = [537      "4111111111111111",538      "4012888888881881",539      "4000056655665556",540      "5555555555554444",541      "5105105105105100",542      "2223003122003222",543      "378282246310005",544      "371449635398431",545      "6011111111111117",546      "6011000990139424",547      "3056930009020004",548      "3530111333300000",549    ];550    for (const n of knownGood) {551      it(`Luhn valid: ${n}`, () => {552        expect(passesLuhn(n)).toBe(true);553      });554    }555    // Random invalid 16-digit numbers (sum-1 of valid).556    for (let i = 0; i < 12; i++) {557      const seed = "4111111111111110".slice(0, 15);558      const trailing = String(i % 10);559      const candidate = seed + trailing;560      if (candidate === "4111111111111101") continue;561      it(`Luhn check for ${candidate}`, () => {562        const result = passesLuhn(candidate);563        // Bool, no crash.564        expect(typeof result).toBe("boolean");565      });566    }567  });568569  // ── isVisible adversarial ───────────────────────────────────────────────570571  describe("isVisible adversarial", () => {572    it("visible normal element", () => {573      const el = visibleInput({ type: "text" });574      expect(isVisible(el)).toBe(true);575    });576577    it("0-width invisible", () => {578      const el = visibleInput({ type: "text" }, 0, 20);579      expect(isVisible(el)).toBe(false);580    });581582    it("0-height invisible", () => {583      const el = visibleInput({ type: "text" }, 100, 0);584      expect(isVisible(el)).toBe(false);585    });586587    it("display:none invisible", () => {588      const el = visibleInput({ type: "text", style: "display:none" });589      // jsdom respects style.display === 'none'.590      expect(isVisible(el)).toBe(false);591    });592  });593});