1import { extractDomain } from "../shared/domain";23export interface DetectedField {4 type: "username" | "password" | "email";5 el: HTMLInputElement;6}789const USERNAME_PATTERNS = /\b(user(name)?|e?mail(box)?|login|account|identifier|signin|sign.?in|credential)\b/i;10const PASSWORD_PATTERNS = /pass|passwd|pwd/i;111213141516const SKIP_INPUT_TYPES = new Set([17 "submit", "button", "reset", "image",18 "checkbox", "radio", "hidden", "file",19 "range", "color", "date", "time",20 "datetime-local", "month", "week",21]);22232425function looksLikeSubmitButton(input: HTMLInputElement): boolean {26 if (input.type === "password") return false;27 const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();28 if (!val) return false;29 return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));30}3132function findLabelText(field: HTMLInputElement): string {33 const ariaLabel = field.getAttribute("aria-label") ?? "";34 if (ariaLabel) return ariaLabel.toLowerCase();35 const id = field.id;36 if (id) {37 const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);38 if (label) return label.textContent?.toLowerCase() ?? "";39 }40 const wrappingLabel = field.closest("label");41 return wrappingLabel?.textContent?.toLowerCase() ?? "";42}4344export function querySelectorAllDeep<T extends Element>(45 root: Document | ShadowRoot | Element,46 selector: string47): T[] {48 const results: T[] = [];49 const walk = (node: Document | ShadowRoot | Element) => {50 if (node instanceof Element) {51 results.push(...(node.querySelectorAll<T>(selector)));52 const shadowRoot = node.shadowRoot;53 if (shadowRoot) {54 shadowRoot.querySelectorAll<T>("*").forEach(walk);55 }56 }57 };58 root.querySelectorAll<T>("*").forEach(walk);59 return results;60}6162export function getDeepActiveInput(): HTMLInputElement | null {63 let current: Document | Element | null = document.activeElement;64 while (current) {65 if (current instanceof HTMLInputElement) return current;66 if (current instanceof Element && current.shadowRoot) {67 current = current.shadowRoot.activeElement;68 } else {69 break;70 }71 }72 return null;73}7475export function isLoginForm(fields: DetectedField[]): boolean {76 const hasPassword = fields.some((f) => f.type === "password");77 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");78 if (hasPassword && hasUsernameOrEmail) return true;79 const singlePassword = fields.filter((f) => f.type === "password");80 if (singlePassword.length === 1 && singlePassword[0].el.autocomplete === "current-password") {81 return true;82 }83 return false;84}8586export function isSignupForm(fields: DetectedField[]): boolean {87 const passwords = fields.filter((f) => f.type === "password");88 if (passwords.length >= 2) return true;89 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;90 if (91 passwords.some((f) => {92 const id = (f.el.id + f.el.name).toLowerCase();93 return /new|confirm/i.test(id);94 })95 ) {96 return true;97 }98 return false;99}100101export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {102 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));103 const fields: DetectedField[] = [];104105 for (const el of inputs) {106 if (!isVisible(el)) continue;107 const type = el.type.toLowerCase();108 109 if (SKIP_INPUT_TYPES.has(type)) continue;110 111 if (looksLikeSubmitButton(el)) continue;112 const ac = el.autocomplete;113 const name = (el.name + el.id + el.placeholder).toLowerCase();114115 if (ac === "current-password" || ac === "new-password" || type === "password") {116 fields.push({ type: "password", el });117 continue;118 }119120 if (ac === "username" || ac === "email" || type === "email") {121 fields.push({ type: "username", el });122 continue;123 }124125 if (USERNAME_PATTERNS.test(name)) {126 fields.push({ type: "username", el });127 continue;128 }129130 const labelText = findLabelText(el);131 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {132 fields.push({ type: "username", el });133 }134 }135136 137 138 139 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");140 const pwField = fields.find((f) => f.type === "password");141 if (!hasUsername && pwField) {142 const allInputs = Array.from(inputs);143 const pwIndex = allInputs.indexOf(pwField.el);144 for (let i = 0; i < pwIndex; i++) {145 const candidate = allInputs[i];146 const candidateType = candidate.type.toLowerCase();147 if (148 (candidateType === "text" || candidateType === "email" || candidateType === "") &&149 isVisible(candidate) &&150 !SKIP_INPUT_TYPES.has(candidateType) &&151 !fields.some((f) => f.el === candidate)152 ) {153 fields.push({ type: "username", el: candidate });154 break; 155 }156 }157 }158159 return fields;160}161162export function isVisible(el: HTMLElement): boolean {163 const s = getComputedStyle(el);164 const r = el.getBoundingClientRect();165 return (166 s.display !== "none" &&167 s.visibility !== "hidden" &&168 parseFloat(s.opacity) > 0 &&169 r.width > 0 &&170 r.height > 0171 );172}173174export function getPageDomain(): string {175 return extractDomain(location.hostname) ?? location.hostname;176}177178export interface CardFormFields {179 number: HTMLInputElement | null;180 expMonth: HTMLInputElement | null;181 expYear: HTMLInputElement | null;182 expiryCombo: HTMLInputElement | null;183 cvc: HTMLInputElement | null;184 name: HTMLInputElement | null;185}186187export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {188 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');189 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');190 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');191 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');192 const cvcByAC = root.querySelector<HTMLInputElement>(193 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'194 );195 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');196197 if (numberByAC) {198 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };199 }200201 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));202 const numberField = allInputs.find(i =>203 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)204 ) ?? null;205 const cvcField = allInputs.find(i =>206 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)207 ) ?? null;208 const expField = allInputs.find(i =>209 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)210 ) ?? null;211212 if (!numberField) return null;213 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };214}215216const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];217218export function hasPaymentProcessorIframe(): boolean {219 const iframes = Array.from(document.querySelectorAll('iframe'));220 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));221}