content.adversarial.test.ts

19.8 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        const input = visibleInput({ type: "text" });324        setNativeValue(input, value);325        expect(input.value).toBe(value);326      });327    }328329    it("fillFields with cross-origin form action: does not fill", () => {330      const form = document.createElement("form");331      form.action = "https://evil.com/login";332      const username = visibleInput({ type: "text" });333      const password = visibleInput({ type: "password" });334      form.appendChild(username);335      form.appendChild(password);336      document.body.appendChild(form);337      fillFields(username, password, "user", "pw", form, "good.com");338      // Form action mismatches expected domain — nothing filled.339      expect(password.value).toBe("");340    });341342    it("fillFields with same-origin form action: fills password", () => {343      const form = document.createElement("form");344      form.action = "https://good.com/login";345      const username = visibleInput({ type: "text" });346      const password = visibleInput({ type: "password" });347      form.appendChild(username);348      form.appendChild(password);349      document.body.appendChild(form);350      fillFields(username, password, "user", "pw", form, "good.com");351      expect(password.value).toBe("pw");352    });353354    it("fillFields with no form: fills password", () => {355      const username = visibleInput({ type: "text" });356      const password = visibleInput({ type: "password" });357      fillFields(username, password, "user", "pw", null, "good.com");358      expect(password.value).toBe("pw");359    });360361    it("fillFields with empty username does not overwrite username field", () => {362      const username = visibleInput({ type: "text" });363      username.value = "preexisting";364      const password = visibleInput({ type: "password" });365      fillFields(username, password, "", "pw");366      expect(username.value).toBe("preexisting");367      expect(password.value).toBe("pw");368    });369  });370371  // ── Luhn / network detection adversarial ────────────────────────────────372373  describe("Luhn / network detection adversarial", () => {374    const luhnPasses = [375      "4111111111111111", // Visa test376      "5500000000000004", // MC test377      "340000000000009", // Amex test378      "6011000000000004", // Discover test379    ];380    for (const n of luhnPasses) {381      it(`passesLuhn true for known good ${n}`, () => {382        expect(passesLuhn(n)).toBe(true);383      });384    }385386    const luhnFails = [387      "4111111111111112",388      "0000000000000000".slice(0, 15) + "1",389      "1234567890123456",390      "9999999999999999",391    ];392    for (const n of luhnFails) {393      it(`passesLuhn false for bad ${n}`, () => {394        expect(passesLuhn(n)).toBe(false);395      });396    }397398    const networkCases = [399      { num: "4111111111111111", expected: "visa" },400      { num: "5500000000000004", expected: "mastercard" },401      { num: "2221000000000009", expected: "mastercard" },402      { num: "340000000000009", expected: "amex" },403      { num: "370000000000002", expected: "amex" },404      { num: "6011000000000004", expected: "discover" },405      { num: "6500000000000002", expected: "discover" },406      { num: "0000000000000000", expected: "unknown" },407      { num: "", expected: "unknown" },408      { num: "garbage", expected: "unknown" },409    ];410    for (const { num, expected } of networkCases) {411      it(`detectCardNetwork(${JSON.stringify(num)}) === ${expected}`, () => {412        expect(detectCardNetwork(num)).toBe(expected);413      });414    }415416    it("formatCardNumber groups Visa 4-4-4-4", () => {417      expect(formatCardNumber("4111111111111111")).toBe("4111 1111 1111 1111");418    });419420    it("formatCardNumber groups Amex 4-6-5", () => {421      expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");422    });423424    it("extractLast4 returns last 4 digits", () => {425      expect(extractLast4("4111 1111 1111 1234")).toBe("1234");426    });427428    it("extractLast4 handles too-short strings", () => {429      expect(extractLast4("12")).toBe("12");430    });431  });432433  // ── Wide sub-domain matrix (extractDomain) ──────────────────────────────434435  describe("wide sub-domain matrix", () => {436    const bases = [437      "github.com",438      "example.com",439      "google.co.uk",440      "amazon.com",441      "microsoft.com",442      "apple.com",443      "asha.software",444      "peach.dev",445    ];446    const prefixes = [447      "",448      "www.",449      "login.",450      "auth.",451      "accounts.",452      "a.b.c.",453      "deeply.nested.sub.",454    ];455    for (const base of bases) {456      for (const prefix of prefixes) {457        it(`extractDomain('https://${prefix}${base}') === '${base}'`, () => {458          expect(extractDomain(`https://${prefix}${base}`)).toBe(base);459        });460      }461    }462463    // Confusable / lookalike domains must NOT collapse to the base.464    const confusables = [465      "evil-github.com",466      "github-evil.com",467      "evilgithub.com",468      "githubevil.com",469    ];470    for (const c of confusables) {471      it(`confusable ${c} does NOT collapse to github.com`, () => {472        expect(extractDomain(`https://${c}`)).not.toBe("github.com");473      });474    }475  });476477  // ── Many text input names ───────────────────────────────────────────────478479  describe("username heuristic sweep", () => {480    const usernameLike = [481      "username",482      "user",483      "userId",484      "login",485      "email",486      "loginEmail",487      "loginUsername",488      "useremail",489      "userlogin",490    ];491    for (const name of usernameLike) {492      it(`name="${name}" detected as username`, () => {493        const i = visibleInput({ type: "text", name });494        const fields = detectLoginFields();495        expect(fields.some((f) => f.el === i && f.type === "username")).toBe(496          true497        );498      });499    }500501    const nonUsername = [502      "address",503      "city",504      "country",505      "color",506      "favoriteFood",507      "shoes",508    ];509    for (const name of nonUsername) {510      it(`name="${name}" NOT detected as username (in isolation)`, () => {511        const i = visibleInput({ type: "text", name });512        const fields = detectLoginFields();513        // The field may still get picked up by the password-precedes fallback,514        // but without a password field present that fallback does nothing.515        const matched = fields.find((f) => f.el === i);516        // Either not detected or detected only as a generic "username" because517        // of label-heuristic — but for these names without label text, expect518        // no match.519        expect(matched).toBeUndefined();520      });521    }522  });523524  // ── Luhn corpus sweep ────────────────────────────────────────────────────525526  describe("Luhn corpus sweep", () => {527    // Test 20 known-valid Visa-shape numbers (test PAN).528    const knownGood = [529      "4111111111111111",530      "4012888888881881",531      "4000056655665556",532      "5555555555554444",533      "5105105105105100",534      "2223003122003222",535      "378282246310005",536      "371449635398431",537      "6011111111111117",538      "6011000990139424",539      "3056930009020004",540      "3530111333300000",541    ];542    for (const n of knownGood) {543      it(`Luhn valid: ${n}`, () => {544        expect(passesLuhn(n)).toBe(true);545      });546    }547    // Random invalid 16-digit numbers (sum-1 of valid).548    for (let i = 0; i < 12; i++) {549      const seed = "4111111111111110".slice(0, 15);550      const trailing = String(i % 10);551      const candidate = seed + trailing;552      if (candidate === "4111111111111101") continue;553      it(`Luhn check for ${candidate}`, () => {554        const result = passesLuhn(candidate);555        // Bool, no crash.556        expect(typeof result).toBe("boolean");557      });558    }559  });560561  // ── isVisible adversarial ───────────────────────────────────────────────562563  describe("isVisible adversarial", () => {564    it("visible normal element", () => {565      const el = visibleInput({ type: "text" });566      expect(isVisible(el)).toBe(true);567    });568569    it("0-width invisible", () => {570      const el = visibleInput({ type: "text" }, 0, 20);571      expect(isVisible(el)).toBe(false);572    });573574    it("0-height invisible", () => {575      const el = visibleInput({ type: "text" }, 100, 0);576      expect(isVisible(el)).toBe(false);577    });578579    it("display:none invisible", () => {580      const el = visibleInput({ type: "text", style: "display:none" });581      // jsdom respects style.display === 'none'.582      expect(isVisible(el)).toBe(false);583    });584  });585});