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}9899100101102103104105106function isSingleDigitBox(el: HTMLInputElement): boolean {107 return el.getAttribute("maxlength") === "1";108}109110export function isTOTPField(input: HTMLInputElement): boolean {111 if (!isVisible(input)) return false;112 113 114 if (isSingleDigitBox(input)) return false;115 const autocomplete = input.autocomplete.toLowerCase();116 const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();117 if (118 autocomplete === "one-time-code" ||119 /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||120 /\b(verification|authenticator|security)\s+code\b/.test(fieldText)121 ) {122 return true;123 }124125 const labelText = findLabelText(input);126 return (127 /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||128 /\b(verification|authenticator|security)\s+code\b/.test(labelText)129 );130}131132133134const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;135const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;136137138139140141export function isEmailFirstForm(fields: DetectedField[]): boolean {142 if (!fields.some(f => f.type === "username" || f.type === "email")) return false;143 if (fields.some(f => f.type === "password")) return false;144145 146 if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;147148 149 const buttons = Array.from(document.querySelectorAll<HTMLElement>(150 'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'151 ));152 const hasAuthButton = buttons.some(b => {153 const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";154 return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);155 });156 if (!hasAuthButton) return false;157158 159 const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;160 const form = identifierEl.closest("form") ?? document.body;161 const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {162 if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;163 if (!isVisible(el)) return false;164 return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i165 .test(el.name + el.id + el.placeholder);166 });167 return !hasRegistrationFields;168}169170171172173export function isPasswordStepForm(fields: DetectedField[]): boolean {174 const passwords = fields.filter(f => f.type === "password");175 return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");176}177178179180export function isAnyCredentialForm(fields: DetectedField[]): boolean {181 return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);182}183184export function isLoginForm(fields: DetectedField[]): boolean {185 const passwords = fields.filter((f) => f.type === "password");186 187 if (passwords.length >= 2) return false;188 const hasPassword = passwords.length > 0;189 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");190 if (hasPassword && hasUsernameOrEmail) return true;191 if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {192 return true;193 }194 return false;195}196197export function isSignupForm(fields: DetectedField[]): boolean {198 const passwords = fields.filter((f) => f.type === "password");199 if (passwords.length >= 2) return true;200 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;201 if (202 passwords.some((f) => {203 const id = (f.el.id + f.el.name).toLowerCase();204 return /new|confirm/i.test(id);205 })206 ) {207 return true;208 }209 return false;210}211212export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {213 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));214 const fields: DetectedField[] = [];215216 for (const el of inputs) {217 if (!isVisible(el)) continue;218 const type = el.type.toLowerCase();219 220 if (SKIP_INPUT_TYPES.has(type)) continue;221 222 if (isSingleDigitBox(el)) continue;223 224 if (looksLikeSubmitButton(el)) continue;225 const ac = el.autocomplete;226 const name = (el.name + el.id + el.placeholder).toLowerCase();227228 if (ac === "current-password" || ac === "new-password" || type === "password") {229 fields.push({ type: "password", el });230 continue;231 }232233 if (ac === "username") {234 fields.push({ type: "username", el });235 continue;236 }237238 239 240 if (ac === "email" || type === "email") {241 fields.push({ type: "email", el });242 continue;243 }244245 if (USERNAME_PATTERNS.test(name)) {246 fields.push({ type: "username", el });247 continue;248 }249250 const labelText = findLabelText(el);251 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {252 fields.push({ type: "username", el });253 }254 }255256 257 258 259 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");260 const pwField = fields.find((f) => f.type === "password");261 if (!hasUsername && pwField) {262 const allInputs = Array.from(inputs);263 const pwIndex = allInputs.indexOf(pwField.el);264 for (let i = 0; i < pwIndex; i++) {265 const candidate = allInputs[i];266 const candidateType = candidate.type.toLowerCase();267 const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase();268 const candidateLabel = findLabelText(candidate);269 if (270 (candidateType === "text" || candidateType === "email" || candidateType === "") &&271 isVisible(candidate) &&272 !SKIP_INPUT_TYPES.has(candidateType) &&273 !fields.some((f) => f.el === candidate) &&274 !NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel)275 ) {276 fields.push({ type: "username", el: candidate });277 break; 278 }279 }280 }281282 return fields;283}284285286287288289290export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null {291 if (!isSingleDigitBox(el)) return null;292 const t = el.type.toLowerCase();293 if (!["text", "number", "tel", ""].includes(t)) return null;294 if (!isVisible(el)) return null;295296 const searchRoot: Element = el.closest("form") ?? document.body;297 const digitBoxes = Array.from(searchRoot.querySelectorAll<HTMLInputElement>("input")).filter(input => {298 if (!isSingleDigitBox(input)) return false;299 if (!isVisible(input)) return false;300 const it = input.type.toLowerCase();301 return ["text", "number", "tel", ""].includes(it);302 });303304 if (digitBoxes.length < 4 || digitBoxes.length > 10) return null;305 if (!digitBoxes.includes(el)) return null;306 return digitBoxes;307}308309310311312313314315316317318319320321export function isSplitOtpGroup(field: HTMLInputElement): boolean {322 if (!isSingleDigitBox(field)) return false;323 const form = field.form;324 if (!form) return false;325 const siblings = Array.from(form.querySelectorAll<HTMLInputElement>("input")).filter(326 (el) =>327 isSingleDigitBox(el) &&328 (el.inputMode === "numeric" ||329 el.type === "tel" ||330 el.pattern === "[0-9]*" ||331 el.pattern === "\\d*" ||332 el.autocomplete === "one-time-code")333 );334 return siblings.length >= 3 && siblings.includes(field);335}336337338339340341342343export function findOtpSignalingGroup(field: HTMLInputElement): HTMLInputElement[] | null {344 if (!isVisible(field)) return null;345 if (!isTOTPField(field)) return null;346 const root: Element = field.form ?? document.body;347 const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input")).filter(isTOTPField);348 if (peers.length < 4 || peers.length > 10) return null;349 return peers.includes(field) ? peers : null;350}351352export function isVisible(el: HTMLElement): boolean {353 const s = getComputedStyle(el);354 const r = el.getBoundingClientRect();355 return (356 s.display !== "none" &&357 s.visibility !== "hidden" &&358 parseFloat(s.opacity) > 0 &&359 r.width > 0 &&360 r.height > 0361 );362}363364export function getPageDomain(): string {365 return extractDomain(location.hostname) ?? location.hostname;366}367368export interface CardFormFields {369 number: HTMLInputElement | null;370 expMonth: HTMLInputElement | null;371 expYear: HTMLInputElement | null;372 expiryCombo: HTMLInputElement | null;373 cvc: HTMLInputElement | null;374 name: HTMLInputElement | null;375}376377export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {378 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');379 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');380 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');381 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');382 const cvcByAC = root.querySelector<HTMLInputElement>(383 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'384 );385 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');386387 if (numberByAC) {388 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };389 }390391 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));392 const numberField = allInputs.find(i =>393 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)394 ) ?? null;395 const cvcField = allInputs.find(i =>396 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)397 ) ?? null;398 const expField = allInputs.find(i =>399 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)400 ) ?? null;401402 if (!numberField) return null;403 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };404}405406const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];407408export function hasPaymentProcessorIframe(): boolean {409 const iframes = Array.from(document.querySelectorAll('iframe'));410 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));411}