1import { extractDomain } from "../shared/domain";23export interface DetectedField {4 type: "username" | "password" | "email";5 el: HTMLInputElement;6}789101112const USERNAME_PATTERNS = /(user(name|id|login|email)?|e?mail(box)?|login(email|username)?|account|identifier|signin|sign.?in|credential)/i;131415161718const SKIP_INPUT_TYPES = new Set([19 "submit", "button", "reset", "image",20 "checkbox", "radio", "hidden", "file",21 "range", "color", "date", "time",22 "datetime-local", "month", "week",23]);24252627function looksLikeSubmitButton(input: HTMLInputElement): boolean {28 if (input.type === "password") return false;29 const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();30 if (!val) return false;31 return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));32}3334function findLabelText(field: HTMLInputElement): string {35 const ariaLabel = field.getAttribute("aria-label") ?? "";36 if (ariaLabel) return ariaLabel.toLowerCase();37 const id = field.id;38 if (id) {39 const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);40 if (label) return label.textContent?.toLowerCase() ?? "";41 }42 const wrappingLabel = field.closest("label");43 if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();4445 let node: Element | null = field;46 for (let depth = 0; node && depth < 3; depth++) {47 const previous = node.previousElementSibling;48 const text = previous?.textContent?.trim().toLowerCase() ?? "";49 if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {50 return text;51 }52 node = node.parentElement;53 }54 return "";55}5657export function querySelectorAllDeep<T extends Element>(58 root: Document | ShadowRoot | Element,59 selector: string60): T[] {61 const results: T[] = [];62 const walk = (node: Document | ShadowRoot | Element) => {63 if (node instanceof Element) {64 results.push(...(node.querySelectorAll<T>(selector)));65 const shadowRoot = node.shadowRoot;66 if (shadowRoot) {67 shadowRoot.querySelectorAll<T>("*").forEach(walk);68 }69 }70 };71 root.querySelectorAll<T>("*").forEach(walk);72 return results;73}7475export function getDeepActiveInput(): HTMLInputElement | null {76 let current: Document | Element | null = document.activeElement;77 while (current) {78 if (current instanceof HTMLInputElement) return current;79 if (current instanceof Element && current.shadowRoot) {80 current = current.shadowRoot.activeElement;81 } else {82 break;83 }84 }85 return null;86}8788export function isTOTPField(input: HTMLInputElement): boolean {89 if (!isVisible(input)) return false;90 91 92 if (input.maxLength === 1) return false;93 const autocomplete = input.autocomplete.toLowerCase();94 const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();95 if (96 autocomplete === "one-time-code" ||97 /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||98 /\b(verification|authenticator|security)\s+code\b/.test(fieldText)99 ) {100 return true;101 }102103 const labelText = findLabelText(input);104 return (105 /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||106 /\b(verification|authenticator|security)\s+code\b/.test(labelText)107 );108}109110111112const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;113const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;114115116117118119export function isEmailFirstForm(fields: DetectedField[]): boolean {120 if (!fields.some(f => f.type === "username" || f.type === "email")) return false;121 if (fields.some(f => f.type === "password")) return false;122123 124 if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;125126 127 const buttons = Array.from(document.querySelectorAll<HTMLElement>(128 'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'129 ));130 const hasAuthButton = buttons.some(b => {131 const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";132 return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);133 });134 if (!hasAuthButton) return false;135136 137 const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;138 const form = identifierEl.closest("form") ?? document.body;139 const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {140 if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;141 if (!isVisible(el)) return false;142 return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i143 .test(el.name + el.id + el.placeholder);144 });145 return !hasRegistrationFields;146}147148149150151export function isPasswordStepForm(fields: DetectedField[]): boolean {152 const passwords = fields.filter(f => f.type === "password");153 return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");154}155156157158export function isAnyCredentialForm(fields: DetectedField[]): boolean {159 return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);160}161162export function isLoginForm(fields: DetectedField[]): boolean {163 const passwords = fields.filter((f) => f.type === "password");164 165 if (passwords.length >= 2) return false;166 const hasPassword = passwords.length > 0;167 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");168 if (hasPassword && hasUsernameOrEmail) return true;169 if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {170 return true;171 }172 return false;173}174175export function isSignupForm(fields: DetectedField[]): boolean {176 const passwords = fields.filter((f) => f.type === "password");177 if (passwords.length >= 2) return true;178 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;179 if (180 passwords.some((f) => {181 const id = (f.el.id + f.el.name).toLowerCase();182 return /new|confirm/i.test(id);183 })184 ) {185 return true;186 }187 return false;188}189190export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {191 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));192 const fields: DetectedField[] = [];193194 for (const el of inputs) {195 if (!isVisible(el)) continue;196 const type = el.type.toLowerCase();197 198 if (SKIP_INPUT_TYPES.has(type)) continue;199 200 if (el.maxLength === 1) continue;201 202 if (looksLikeSubmitButton(el)) continue;203 const ac = el.autocomplete;204 const name = (el.name + el.id + el.placeholder).toLowerCase();205206 if (ac === "current-password" || ac === "new-password" || type === "password") {207 fields.push({ type: "password", el });208 continue;209 }210211 if (ac === "username" || ac === "email" || type === "email") {212 fields.push({ type: "username", el });213 continue;214 }215216 if (USERNAME_PATTERNS.test(name)) {217 fields.push({ type: "username", el });218 continue;219 }220221 const labelText = findLabelText(el);222 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {223 fields.push({ type: "username", el });224 }225 }226227 228 229 230 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");231 const pwField = fields.find((f) => f.type === "password");232 if (!hasUsername && pwField) {233 const allInputs = Array.from(inputs);234 const pwIndex = allInputs.indexOf(pwField.el);235 for (let i = 0; i < pwIndex; i++) {236 const candidate = allInputs[i];237 const candidateType = candidate.type.toLowerCase();238 if (239 (candidateType === "text" || candidateType === "email" || candidateType === "") &&240 isVisible(candidate) &&241 !SKIP_INPUT_TYPES.has(candidateType) &&242 !fields.some((f) => f.el === candidate)243 ) {244 fields.push({ type: "username", el: candidate });245 break; 246 }247 }248 }249250 return fields;251}252253export function isVisible(el: HTMLElement): boolean {254 const s = getComputedStyle(el);255 const r = el.getBoundingClientRect();256 return (257 s.display !== "none" &&258 s.visibility !== "hidden" &&259 parseFloat(s.opacity) > 0 &&260 r.width > 0 &&261 r.height > 0262 );263}264265export function getPageDomain(): string {266 return extractDomain(location.hostname) ?? location.hostname;267}268269export interface CardFormFields {270 number: HTMLInputElement | null;271 expMonth: HTMLInputElement | null;272 expYear: HTMLInputElement | null;273 expiryCombo: HTMLInputElement | null;274 cvc: HTMLInputElement | null;275 name: HTMLInputElement | null;276}277278export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {279 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');280 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');281 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');282 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');283 const cvcByAC = root.querySelector<HTMLInputElement>(284 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'285 );286 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');287288 if (numberByAC) {289 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };290 }291292 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));293 const numberField = allInputs.find(i =>294 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)295 ) ?? null;296 const cvcField = allInputs.find(i =>297 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)298 ) ?? null;299 const expField = allInputs.find(i =>300 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)301 ) ?? null;302303 if (!numberField) return null;304 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };305}306307const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];308309export function hasPaymentProcessorIframe(): boolean {310 const iframes = Array.from(document.querySelectorAll('iframe'));311 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));312}