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 return wrappingLabel?.textContent?.toLowerCase() ?? "";44}4546export function querySelectorAllDeep<T extends Element>(47 root: Document | ShadowRoot | Element,48 selector: string49): T[] {50 const results: T[] = [];51 const walk = (node: Document | ShadowRoot | Element) => {52 if (node instanceof Element) {53 results.push(...(node.querySelectorAll<T>(selector)));54 const shadowRoot = node.shadowRoot;55 if (shadowRoot) {56 shadowRoot.querySelectorAll<T>("*").forEach(walk);57 }58 }59 };60 root.querySelectorAll<T>("*").forEach(walk);61 return results;62}6364export function getDeepActiveInput(): HTMLInputElement | null {65 let current: Document | Element | null = document.activeElement;66 while (current) {67 if (current instanceof HTMLInputElement) return current;68 if (current instanceof Element && current.shadowRoot) {69 current = current.shadowRoot.activeElement;70 } else {71 break;72 }73 }74 return null;75}7677export function isTOTPField(input: HTMLInputElement): boolean {78 if (!isVisible(input)) return false;79 const autocomplete = input.autocomplete.toLowerCase();80 const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();81 if (82 autocomplete === "one-time-code" ||83 /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||84 /\b(verification|authenticator|security)\s+code\b/.test(fieldText)85 ) {86 return true;87 }8889 const labelText = findLabelText(input);90 return (91 /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||92 /\b(verification|authenticator|security)\s+code\b/.test(labelText)93 );94}9596export function isLoginForm(fields: DetectedField[]): boolean {97 const passwords = fields.filter((f) => f.type === "password");98 99 if (passwords.length >= 2) return false;100 const hasPassword = passwords.length > 0;101 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");102 if (hasPassword && hasUsernameOrEmail) return true;103 if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {104 return true;105 }106 return false;107}108109export function isSignupForm(fields: DetectedField[]): boolean {110 const passwords = fields.filter((f) => f.type === "password");111 if (passwords.length >= 2) return true;112 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;113 if (114 passwords.some((f) => {115 const id = (f.el.id + f.el.name).toLowerCase();116 return /new|confirm/i.test(id);117 })118 ) {119 return true;120 }121 return false;122}123124export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {125 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));126 const fields: DetectedField[] = [];127128 for (const el of inputs) {129 if (!isVisible(el)) continue;130 const type = el.type.toLowerCase();131 132 if (SKIP_INPUT_TYPES.has(type)) continue;133 134 if (looksLikeSubmitButton(el)) continue;135 const ac = el.autocomplete;136 const name = (el.name + el.id + el.placeholder).toLowerCase();137138 if (ac === "current-password" || ac === "new-password" || type === "password") {139 fields.push({ type: "password", el });140 continue;141 }142143 if (ac === "username" || ac === "email" || type === "email") {144 fields.push({ type: "username", el });145 continue;146 }147148 if (USERNAME_PATTERNS.test(name)) {149 fields.push({ type: "username", el });150 continue;151 }152153 const labelText = findLabelText(el);154 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {155 fields.push({ type: "username", el });156 }157 }158159 160 161 162 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");163 const pwField = fields.find((f) => f.type === "password");164 if (!hasUsername && pwField) {165 const allInputs = Array.from(inputs);166 const pwIndex = allInputs.indexOf(pwField.el);167 for (let i = 0; i < pwIndex; i++) {168 const candidate = allInputs[i];169 const candidateType = candidate.type.toLowerCase();170 if (171 (candidateType === "text" || candidateType === "email" || candidateType === "") &&172 isVisible(candidate) &&173 !SKIP_INPUT_TYPES.has(candidateType) &&174 !fields.some((f) => f.el === candidate)175 ) {176 fields.push({ type: "username", el: candidate });177 break; 178 }179 }180 }181182 return fields;183}184185export function isVisible(el: HTMLElement): boolean {186 const s = getComputedStyle(el);187 const r = el.getBoundingClientRect();188 return (189 s.display !== "none" &&190 s.visibility !== "hidden" &&191 parseFloat(s.opacity) > 0 &&192 r.width > 0 &&193 r.height > 0194 );195}196197export function getPageDomain(): string {198 return extractDomain(location.hostname) ?? location.hostname;199}200201export interface CardFormFields {202 number: HTMLInputElement | null;203 expMonth: HTMLInputElement | null;204 expYear: HTMLInputElement | null;205 expiryCombo: HTMLInputElement | null;206 cvc: HTMLInputElement | null;207 name: HTMLInputElement | null;208}209210export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {211 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');212 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');213 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');214 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');215 const cvcByAC = root.querySelector<HTMLInputElement>(216 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'217 );218 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');219220 if (numberByAC) {221 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };222 }223224 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));225 const numberField = allInputs.find(i =>226 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)227 ) ?? null;228 const cvcField = allInputs.find(i =>229 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)230 ) ?? null;231 const expField = allInputs.find(i =>232 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)233 ) ?? null;234235 if (!numberField) return null;236 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };237}238239const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];240241export function hasPaymentProcessorIframe(): boolean {242 const iframes = Array.from(document.querySelectorAll('iframe'));243 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));244}