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 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 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 372373 describe("Luhn / network detection adversarial", () => {374 const luhnPasses = [375 "4111111111111111", 376 "5500000000000004", 377 "340000000000009", 378 "6011000000000004", 379 ];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 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 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 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 514 515 const matched = fields.find((f) => f.el === i);516 517 518 519 expect(matched).toBeUndefined();520 });521 }522 });523524 525526 describe("Luhn corpus sweep", () => {527 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 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 556 expect(typeof result).toBe("boolean");557 });558 }559 });560561 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 582 expect(isVisible(el)).toBe(false);583 });584 });585});