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 const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);44 if (label) return label.textContent?.toLowerCase() ?? "";45 }46 const wrappingLabel = field.closest("label");47 if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();4849 let node: Element | null = field;50 for (let depth = 0; node && depth < 3; depth++) {51 const previous = node.previousElementSibling;52 const text = previous?.textContent?.trim().toLowerCase() ?? "";53 if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {54 return text;55 }56 node = node.parentElement;57 }58 return "";59}6061export function querySelectorAllDeep<T extends Element>(62 root: Document | ShadowRoot | Element,63 selector: string64): T[] {65 const results: T[] = [];66 const walk = (node: Document | ShadowRoot | Element) => {67 if (node instanceof Element) {68 results.push(...(node.querySelectorAll<T>(selector)));69 const shadowRoot = node.shadowRoot;70 if (shadowRoot) {71 shadowRoot.querySelectorAll<T>("*").forEach(walk);72 }73 }74 };75 root.querySelectorAll<T>("*").forEach(walk);76 return results;77}7879export function getDeepActiveInput(): HTMLInputElement | null {80 let current: Document | Element | null = document.activeElement;81 while (current) {82 if (current instanceof HTMLInputElement) return current;83 if (current instanceof Element && current.shadowRoot) {84 current = current.shadowRoot.activeElement;85 } else {86 break;87 }88 }89 return null;90}9192export function isTOTPField(input: HTMLInputElement): boolean {93 if (!isVisible(input)) return false;94 95 96 if (input.maxLength === 1) return false;97 const autocomplete = input.autocomplete.toLowerCase();98 const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();99 if (100 autocomplete === "one-time-code" ||101 /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||102 /\b(verification|authenticator|security)\s+code\b/.test(fieldText)103 ) {104 return true;105 }106107 const labelText = findLabelText(input);108 return (109 /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||110 /\b(verification|authenticator|security)\s+code\b/.test(labelText)111 );112}113114115116const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;117const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;118119120121122123export function isEmailFirstForm(fields: DetectedField[]): boolean {124 if (!fields.some(f => f.type === "username" || f.type === "email")) return false;125 if (fields.some(f => f.type === "password")) return false;126127 128 if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;129130 131 const buttons = Array.from(document.querySelectorAll<HTMLElement>(132 'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'133 ));134 const hasAuthButton = buttons.some(b => {135 const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";136 return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);137 });138 if (!hasAuthButton) return false;139140 141 const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;142 const form = identifierEl.closest("form") ?? document.body;143 const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {144 if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;145 if (!isVisible(el)) return false;146 return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i147 .test(el.name + el.id + el.placeholder);148 });149 return !hasRegistrationFields;150}151152153154155export function isPasswordStepForm(fields: DetectedField[]): boolean {156 const passwords = fields.filter(f => f.type === "password");157 return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");158}159160161162export function isAnyCredentialForm(fields: DetectedField[]): boolean {163 return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);164}165166export function isLoginForm(fields: DetectedField[]): boolean {167 const passwords = fields.filter((f) => f.type === "password");168 169 if (passwords.length >= 2) return false;170 const hasPassword = passwords.length > 0;171 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");172 if (hasPassword && hasUsernameOrEmail) return true;173 if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {174 return true;175 }176 return false;177}178179export function isSignupForm(fields: DetectedField[]): boolean {180 const passwords = fields.filter((f) => f.type === "password");181 if (passwords.length >= 2) return true;182 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;183 if (184 passwords.some((f) => {185 const id = (f.el.id + f.el.name).toLowerCase();186 return /new|confirm/i.test(id);187 })188 ) {189 return true;190 }191 return false;192}193194export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {195 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));196 const fields: DetectedField[] = [];197198 for (const el of inputs) {199 if (!isVisible(el)) continue;200 const type = el.type.toLowerCase();201 202 if (SKIP_INPUT_TYPES.has(type)) continue;203 204 if (el.maxLength === 1) continue;205 206 if (looksLikeSubmitButton(el)) continue;207 const ac = el.autocomplete;208 const name = (el.name + el.id + el.placeholder).toLowerCase();209210 if (ac === "current-password" || ac === "new-password" || type === "password") {211 fields.push({ type: "password", el });212 continue;213 }214215 if (ac === "username") {216 fields.push({ type: "username", el });217 continue;218 }219220 221 222 if (ac === "email" || type === "email") {223 fields.push({ type: "email", el });224 continue;225 }226227 if (USERNAME_PATTERNS.test(name)) {228 fields.push({ type: "username", el });229 continue;230 }231232 const labelText = findLabelText(el);233 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {234 fields.push({ type: "username", el });235 }236 }237238 239 240 241 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");242 const pwField = fields.find((f) => f.type === "password");243 if (!hasUsername && pwField) {244 const allInputs = Array.from(inputs);245 const pwIndex = allInputs.indexOf(pwField.el);246 for (let i = 0; i < pwIndex; i++) {247 const candidate = allInputs[i];248 const candidateType = candidate.type.toLowerCase();249 const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase();250 const candidateLabel = findLabelText(candidate);251 if (252 (candidateType === "text" || candidateType === "email" || candidateType === "") &&253 isVisible(candidate) &&254 !SKIP_INPUT_TYPES.has(candidateType) &&255 !fields.some((f) => f.el === candidate) &&256 !NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel)257 ) {258 fields.push({ type: "username", el: candidate });259 break; 260 }261 }262 }263264 return fields;265}266267268269270271272export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null {273 if (el.maxLength !== 1) return null;274 const t = el.type.toLowerCase();275 if (!["text", "number", "tel", ""].includes(t)) return null;276 if (!isVisible(el)) return null;277278 const searchRoot: Element = el.closest("form") ?? document.body;279 const digitBoxes = Array.from(searchRoot.querySelectorAll<HTMLInputElement>("input")).filter(input => {280 if (input.maxLength !== 1) return false;281 if (!isVisible(input)) return false;282 const it = input.type.toLowerCase();283 return ["text", "number", "tel", ""].includes(it);284 });285286 if (digitBoxes.length < 4 || digitBoxes.length > 10) return null;287 if (!digitBoxes.includes(el)) return null;288 return digitBoxes;289}290291export function isVisible(el: HTMLElement): boolean {292 const s = getComputedStyle(el);293 const r = el.getBoundingClientRect();294 return (295 s.display !== "none" &&296 s.visibility !== "hidden" &&297 parseFloat(s.opacity) > 0 &&298 r.width > 0 &&299 r.height > 0300 );301}302303export function getPageDomain(): string {304 return extractDomain(location.hostname) ?? location.hostname;305}306307export interface CardFormFields {308 number: HTMLInputElement | null;309 expMonth: HTMLInputElement | null;310 expYear: HTMLInputElement | null;311 expiryCombo: HTMLInputElement | null;312 cvc: HTMLInputElement | null;313 name: HTMLInputElement | null;314}315316export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {317 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');318 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');319 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');320 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');321 const cvcByAC = root.querySelector<HTMLInputElement>(322 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'323 );324 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');325326 if (numberByAC) {327 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };328 }329330 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));331 const numberField = allInputs.find(i =>332 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)333 ) ?? null;334 const cvcField = allInputs.find(i =>335 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)336 ) ?? null;337 const expField = allInputs.find(i =>338 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)339 ) ?? null;340341 if (!numberField) return null;342 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };343}344345const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];346347export function hasPaymentProcessorIframe(): boolean {348 const iframes = Array.from(document.querySelectorAll('iframe'));349 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));350}