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;13const PASSWORD_PATTERNS = /pass|passwd|pwd/i;141516171819const SKIP_INPUT_TYPES = new Set([20 "submit", "button", "reset", "image",21 "checkbox", "radio", "hidden", "file",22 "range", "color", "date", "time",23 "datetime-local", "month", "week",24]);25262728function looksLikeSubmitButton(input: HTMLInputElement): boolean {29 if (input.type === "password") return false;30 const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();31 if (!val) return false;32 return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));33}3435function findLabelText(field: HTMLInputElement): string {36 const ariaLabel = field.getAttribute("aria-label") ?? "";37 if (ariaLabel) return ariaLabel.toLowerCase();38 const id = field.id;39 if (id) {40 const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);41 if (label) return label.textContent?.toLowerCase() ?? "";42 }43 const wrappingLabel = field.closest("label");44 return wrappingLabel?.textContent?.toLowerCase() ?? "";45}4647export function querySelectorAllDeep<T extends Element>(48 root: Document | ShadowRoot | Element,49 selector: string50): T[] {51 const results: T[] = [];52 const walk = (node: Document | ShadowRoot | Element) => {53 if (node instanceof Element) {54 results.push(...(node.querySelectorAll<T>(selector)));55 const shadowRoot = node.shadowRoot;56 if (shadowRoot) {57 shadowRoot.querySelectorAll<T>("*").forEach(walk);58 }59 }60 };61 root.querySelectorAll<T>("*").forEach(walk);62 return results;63}6465export function getDeepActiveInput(): HTMLInputElement | null {66 let current: Document | Element | null = document.activeElement;67 while (current) {68 if (current instanceof HTMLInputElement) return current;69 if (current instanceof Element && current.shadowRoot) {70 current = current.shadowRoot.activeElement;71 } else {72 break;73 }74 }75 return null;76}7778export function isLoginForm(fields: DetectedField[]): boolean {79 const passwords = fields.filter((f) => f.type === "password");80 81 if (passwords.length >= 2) return false;82 const hasPassword = passwords.length > 0;83 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");84 if (hasPassword && hasUsernameOrEmail) return true;85 if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {86 return true;87 }88 return false;89}9091export function isSignupForm(fields: DetectedField[]): boolean {92 const passwords = fields.filter((f) => f.type === "password");93 if (passwords.length >= 2) return true;94 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;95 if (96 passwords.some((f) => {97 const id = (f.el.id + f.el.name).toLowerCase();98 return /new|confirm/i.test(id);99 })100 ) {101 return true;102 }103 return false;104}105106export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {107 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));108 const fields: DetectedField[] = [];109110 for (const el of inputs) {111 if (!isVisible(el)) continue;112 const type = el.type.toLowerCase();113 114 if (SKIP_INPUT_TYPES.has(type)) continue;115 116 if (looksLikeSubmitButton(el)) continue;117 const ac = el.autocomplete;118 const name = (el.name + el.id + el.placeholder).toLowerCase();119120 if (ac === "current-password" || ac === "new-password" || type === "password") {121 fields.push({ type: "password", el });122 continue;123 }124125 if (ac === "username" || ac === "email" || type === "email") {126 fields.push({ type: "username", el });127 continue;128 }129130 if (USERNAME_PATTERNS.test(name)) {131 fields.push({ type: "username", el });132 continue;133 }134135 const labelText = findLabelText(el);136 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {137 fields.push({ type: "username", el });138 }139 }140141 142 143 144 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");145 const pwField = fields.find((f) => f.type === "password");146 if (!hasUsername && pwField) {147 const allInputs = Array.from(inputs);148 const pwIndex = allInputs.indexOf(pwField.el);149 for (let i = 0; i < pwIndex; i++) {150 const candidate = allInputs[i];151 const candidateType = candidate.type.toLowerCase();152 if (153 (candidateType === "text" || candidateType === "email" || candidateType === "") &&154 isVisible(candidate) &&155 !SKIP_INPUT_TYPES.has(candidateType) &&156 !fields.some((f) => f.el === candidate)157 ) {158 fields.push({ type: "username", el: candidate });159 break; 160 }161 }162 }163164 return fields;165}166167export function isVisible(el: HTMLElement): boolean {168 const s = getComputedStyle(el);169 const r = el.getBoundingClientRect();170 return (171 s.display !== "none" &&172 s.visibility !== "hidden" &&173 parseFloat(s.opacity) > 0 &&174 r.width > 0 &&175 r.height > 0176 );177}178179export function getPageDomain(): string {180 return extractDomain(location.hostname) ?? location.hostname;181}182183export interface CardFormFields {184 number: HTMLInputElement | null;185 expMonth: HTMLInputElement | null;186 expYear: HTMLInputElement | null;187 expiryCombo: HTMLInputElement | null;188 cvc: HTMLInputElement | null;189 name: HTMLInputElement | null;190}191192export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {193 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');194 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');195 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');196 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');197 const cvcByAC = root.querySelector<HTMLInputElement>(198 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'199 );200 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');201202 if (numberByAC) {203 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };204 }205206 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));207 const numberField = allInputs.find(i =>208 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)209 ) ?? null;210 const cvcField = allInputs.find(i =>211 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)212 ) ?? null;213 const expField = allInputs.find(i =>214 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)215 ) ?? null;216217 if (!numberField) return null;218 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };219}220221const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];222223export function hasPaymentProcessorIframe(): boolean {224 const iframes = Array.from(document.querySelectorAll('iframe'));225 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));226}