12345678910111213141516import { 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 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 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 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 203 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 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 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 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 324 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 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 380381 describe("Luhn / network detection adversarial", () => {382 const luhnPasses = [383 "4111111111111111", 384 "5500000000000004", 385 "340000000000009", 386 "6011000000000004", 387 ];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 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 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 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 522 523 const matched = fields.find((f) => f.el === i);524 525 526 527 expect(matched).toBeUndefined();528 });529 }530 });531532 533534 describe("Luhn corpus sweep", () => {535 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 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 564 expect(typeof result).toBe("boolean");565 });566 }567 });568569 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 590 expect(isVisible(el)).toBe(false);591 });592 });593});