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 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 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 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 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 204 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 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 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 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 325 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 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 396397 describe("Luhn / network detection adversarial", () => {398 const luhnPasses = [399 "4111111111111111", 400 "5500000000000004", 401 "340000000000009", 402 "6011000000000004", 403 ];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 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 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 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 538 539 const matched = fields.find((f) => f.el === i);540 541 542 543 expect(matched).toBeUndefined();544 });545 }546 });547548 549550 describe("Luhn corpus sweep", () => {551 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 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 580 expect(typeof result).toBe("boolean");581 });582 }583 });584585 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 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});