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;13141516const NAME_FIELD_PATTERNS = /\b(first[\s_\-]?name|last[\s_\-]?name|full[\s_\-]?name|display[\s_\-]?name|real[\s_\-]?name|given[\s_\-]?name|family[\s_\-]?name|middle[\s_\-]?name)\b|^(name|fname|lname|fullname|displayname|realname)$/i;171819202122const SKIP_INPUT_TYPES = new Set([23 "submit", "button", "reset", "image",24 "checkbox", "radio", "hidden", "file",25 "range", "color", "date", "time",26 "datetime-local", "month", "week",27]);28293031function looksLikeSubmitButton(input: HTMLInputElement): boolean {32 if (input.type === "password") return false;33 const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();34 if (!val) return false;35 return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));36}3738function findLabelText(field: HTMLInputElement): string {39 const ariaLabel = field.getAttribute("aria-label") ?? "";40 if (ariaLabel) return ariaLabel.toLowerCase();41 const id = field.id;42 if (id) {43 44 45 46 try {47 const label = document.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(id)}"]`);48 if (label) return label.textContent?.toLowerCase() ?? "";49 } catch {50 51 }52 }53 const wrappingLabel = field.closest("label");54 if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();5556 let node: Element | null = field;57 for (let depth = 0; node && depth < 3; depth++) {58 const previous = node.previousElementSibling;59 const text = previous?.textContent?.trim().toLowerCase() ?? "";60 if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {61 return text;62 }63 node = node.parentElement;64 }65 return "";66}6768export function querySelectorAllDeep<T extends Element>(69 root: Document | ShadowRoot | Element,70 selector: string71): T[] {72 const results: T[] = [];73 const walk = (node: Document | ShadowRoot | Element) => {74 if (node instanceof Element) {75 results.push(...(node.querySelectorAll<T>(selector)));76 const shadowRoot = node.shadowRoot;77 if (shadowRoot) {78 shadowRoot.querySelectorAll<T>("*").forEach(walk);79 }80 }81 };82 root.querySelectorAll<T>("*").forEach(walk);83 return results;84}8586export function getDeepActiveInput(): HTMLInputElement | null {87 let current: Document | Element | null = document.activeElement;88 while (current) {89 if (current instanceof HTMLInputElement) return current;90 if (current instanceof Element && current.shadowRoot) {91 current = current.shadowRoot.activeElement;92 } else {93 break;94 }95 }96 return null;97}9899export function isTOTPField(input: HTMLInputElement): boolean {100 if (!isVisible(input)) return false;101 102 103 if (input.maxLength === 1) return false;104 const autocomplete = input.autocomplete.toLowerCase();105 const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();106 if (107 autocomplete === "one-time-code" ||108 /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||109 /\b(verification|authenticator|security)\s+code\b/.test(fieldText)110 ) {111 return true;112 }113114 const labelText = findLabelText(input);115 return (116 /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||117 /\b(verification|authenticator|security)\s+code\b/.test(labelText)118 );119}120121122123const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;124const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;125126127128129130export function isEmailFirstForm(fields: DetectedField[]): boolean {131 if (!fields.some(f => f.type === "username" || f.type === "email")) return false;132 if (fields.some(f => f.type === "password")) return false;133134 135 if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;136137 138 const buttons = Array.from(document.querySelectorAll<HTMLElement>(139 'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'140 ));141 const hasAuthButton = buttons.some(b => {142 const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";143 return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);144 });145 if (!hasAuthButton) return false;146147 148 const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;149 const form = identifierEl.closest("form") ?? document.body;150 const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {151 if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;152 if (!isVisible(el)) return false;153 return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i154 .test(el.name + el.id + el.placeholder);155 });156 return !hasRegistrationFields;157}158159160161162export function isPasswordStepForm(fields: DetectedField[]): boolean {163 const passwords = fields.filter(f => f.type === "password");164 return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");165}166167168169export function isAnyCredentialForm(fields: DetectedField[]): boolean {170 return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);171}172173export function isLoginForm(fields: DetectedField[]): boolean {174 const passwords = fields.filter((f) => f.type === "password");175 176 if (passwords.length >= 2) return false;177 const hasPassword = passwords.length > 0;178 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");179 if (hasPassword && hasUsernameOrEmail) return true;180 if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {181 return true;182 }183 return false;184}185186export function isSignupForm(fields: DetectedField[]): boolean {187 const passwords = fields.filter((f) => f.type === "password");188 if (passwords.length >= 2) return true;189 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;190 if (191 passwords.some((f) => {192 const id = (f.el.id + f.el.name).toLowerCase();193 return /new|confirm/i.test(id);194 })195 ) {196 return true;197 }198 return false;199}200201export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {202 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));203 const fields: DetectedField[] = [];204205 for (const el of inputs) {206 if (!isVisible(el)) continue;207 const type = el.type.toLowerCase();208 209 if (SKIP_INPUT_TYPES.has(type)) continue;210 211 if (el.maxLength === 1) continue;212 213 if (looksLikeSubmitButton(el)) continue;214 const ac = el.autocomplete;215 const name = (el.name + el.id + el.placeholder).toLowerCase();216217 if (ac === "current-password" || ac === "new-password" || type === "password") {218 fields.push({ type: "password", el });219 continue;220 }221222 if (ac === "username") {223 fields.push({ type: "username", el });224 continue;225 }226227 228 229 if (ac === "email" || type === "email") {230 fields.push({ type: "email", el });231 continue;232 }233234 if (USERNAME_PATTERNS.test(name)) {235 fields.push({ type: "username", el });236 continue;237 }238239 const labelText = findLabelText(el);240 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {241 fields.push({ type: "username", el });242 }243 }244245 246 247 248 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");249 const pwField = fields.find((f) => f.type === "password");250 if (!hasUsername && pwField) {251 const allInputs = Array.from(inputs);252 const pwIndex = allInputs.indexOf(pwField.el);253 for (let i = 0; i < pwIndex; i++) {254 const candidate = allInputs[i];255 const candidateType = candidate.type.toLowerCase();256 const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase();257 const candidateLabel = findLabelText(candidate);258 if (259 (candidateType === "text" || candidateType === "email" || candidateType === "") &&260 isVisible(candidate) &&261 !SKIP_INPUT_TYPES.has(candidateType) &&262 !fields.some((f) => f.el === candidate) &&263 !NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel)264 ) {265 fields.push({ type: "username", el: candidate });266 break; 267 }268 }269 }270271 return fields;272}273274275276277278279export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null {280 if (el.maxLength !== 1) return null;281 const t = el.type.toLowerCase();282 if (!["text", "number", "tel", ""].includes(t)) return null;283 if (!isVisible(el)) return null;284285 const searchRoot: Element = el.closest("form") ?? document.body;286 const digitBoxes = Array.from(searchRoot.querySelectorAll<HTMLInputElement>("input")).filter(input => {287 if (input.maxLength !== 1) return false;288 if (!isVisible(input)) return false;289 const it = input.type.toLowerCase();290 return ["text", "number", "tel", ""].includes(it);291 });292293 if (digitBoxes.length < 4 || digitBoxes.length > 10) return null;294 if (!digitBoxes.includes(el)) return null;295 return digitBoxes;296}297298299300301302303304305306307308309310export function isSplitOtpGroup(field: HTMLInputElement): boolean {311 if (field.maxLength !== 1) return false;312 const form = field.form;313 if (!form) return false;314 const siblings = Array.from(form.querySelectorAll<HTMLInputElement>("input")).filter(315 (el) =>316 el.maxLength === 1 &&317 (el.inputMode === "numeric" ||318 el.type === "tel" ||319 el.pattern === "[0-9]*" ||320 el.pattern === "\\d*" ||321 el.autocomplete === "one-time-code")322 );323 return siblings.length >= 3 && siblings.includes(field);324}325326export function isVisible(el: HTMLElement): boolean {327 const s = getComputedStyle(el);328 const r = el.getBoundingClientRect();329 return (330 s.display !== "none" &&331 s.visibility !== "hidden" &&332 parseFloat(s.opacity) > 0 &&333 r.width > 0 &&334 r.height > 0335 );336}337338export function getPageDomain(): string {339 return extractDomain(location.hostname) ?? location.hostname;340}341342export interface CardFormFields {343 number: HTMLInputElement | null;344 expMonth: HTMLInputElement | null;345 expYear: HTMLInputElement | null;346 expiryCombo: HTMLInputElement | null;347 cvc: HTMLInputElement | null;348 name: HTMLInputElement | null;349}350351export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {352 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');353 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');354 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');355 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');356 const cvcByAC = root.querySelector<HTMLInputElement>(357 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'358 );359 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');360361 if (numberByAC) {362 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };363 }364365 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));366 const numberField = allInputs.find(i =>367 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)368 ) ?? null;369 const cvcField = allInputs.find(i =>370 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)371 ) ?? null;372 const expField = allInputs.find(i =>373 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)374 ) ?? null;375376 if (!numberField) return null;377 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };378}379380const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];381382export function hasPaymentProcessorIframe(): boolean {383 const iframes = Array.from(document.querySelectorAll('iframe'));384 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));385}