import { extractDomain } from "../shared/domain"; export interface DetectedField { type: "username" | "password" | "email"; el: HTMLInputElement; } // Conservative: "name" alone is too broad (matches "tag-name", "first-name", etc.). // No \b anchors — modern sites use camelCase like "userId", "loginEmail" where // the trigger token is contiguous with other word chars and would not match // at a word boundary. const USERNAME_PATTERNS = /(user(name|id|login|email)?|e?mail(box)?|login(email|username)?|account|identifier|signin|sign.?in|credential)/i; // Input types that are never text-entry fields and must never get autofill // icons. submit/button/reset/image are interactive controls — putting the // icon over them blocks the click. The rest are structurally incapable of // holding a username/password. const SKIP_INPUT_TYPES = new Set([ "submit", "button", "reset", "image", "checkbox", "radio", "hidden", "file", "range", "color", "date", "time", "datetime-local", "month", "week", ]); // Some sites render submit buttons as with no type attribute (defaults to "text") // and a value like "Login" or "Submit". Catch those before injecting icons. function looksLikeSubmitButton(input: HTMLInputElement): boolean { if (input.type === "password") return false; const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim(); if (!val) return false; return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " ")); } function findLabelText(field: HTMLInputElement): string { const ariaLabel = field.getAttribute("aria-label") ?? ""; if (ariaLabel) return ariaLabel.toLowerCase(); const id = field.id; if (id) { const label = document.querySelector(`label[for="${id}"]`); if (label) return label.textContent?.toLowerCase() ?? ""; } const wrappingLabel = field.closest("label"); return wrappingLabel?.textContent?.toLowerCase() ?? ""; } export function querySelectorAllDeep( root: Document | ShadowRoot | Element, selector: string ): T[] { const results: T[] = []; const walk = (node: Document | ShadowRoot | Element) => { if (node instanceof Element) { results.push(...(node.querySelectorAll(selector))); const shadowRoot = node.shadowRoot; if (shadowRoot) { shadowRoot.querySelectorAll("*").forEach(walk); } } }; root.querySelectorAll("*").forEach(walk); return results; } export function getDeepActiveInput(): HTMLInputElement | null { let current: Document | Element | null = document.activeElement; while (current) { if (current instanceof HTMLInputElement) return current; if (current instanceof Element && current.shadowRoot) { current = current.shadowRoot.activeElement; } else { break; } } return null; } export function isLoginForm(fields: DetectedField[]): boolean { const passwords = fields.filter((f) => f.type === "password"); // Two or more password fields means signup/change-password, not login if (passwords.length >= 2) return false; const hasPassword = passwords.length > 0; const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email"); if (hasPassword && hasUsernameOrEmail) return true; if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") { return true; } return false; } export function isSignupForm(fields: DetectedField[]): boolean { const passwords = fields.filter((f) => f.type === "password"); if (passwords.length >= 2) return true; if (passwords.some((f) => f.el.autocomplete === "new-password")) return true; if ( passwords.some((f) => { const id = (f.el.id + f.el.name).toLowerCase(); return /new|confirm/i.test(id); }) ) { return true; } return false; } export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] { const inputs = Array.from(querySelectorAllDeep(root, "input")); const fields: DetectedField[] = []; for (const el of inputs) { if (!isVisible(el)) continue; const type = el.type.toLowerCase(); // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets if (SKIP_INPUT_TYPES.has(type)) continue; // Catch with no type attribute whose value text identifies it as a submit button if (looksLikeSubmitButton(el)) continue; const ac = el.autocomplete; const name = (el.name + el.id + el.placeholder).toLowerCase(); if (ac === "current-password" || ac === "new-password" || type === "password") { fields.push({ type: "password", el }); continue; } if (ac === "username" || ac === "email" || type === "email") { fields.push({ type: "username", el }); continue; } if (USERNAME_PATTERNS.test(name)) { fields.push({ type: "username", el }); continue; } const labelText = findLabelText(el); if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) { fields.push({ type: "username", el }); } } // Fallback: if we have a password field but no username/email, look for // the first visible text input that appears before the password in DOM order. // This catches sites with unconventional field names (e.g. name="identifier"). const hasUsername = fields.some((f) => f.type === "username" || f.type === "email"); const pwField = fields.find((f) => f.type === "password"); if (!hasUsername && pwField) { const allInputs = Array.from(inputs); const pwIndex = allInputs.indexOf(pwField.el); for (let i = 0; i < pwIndex; i++) { const candidate = allInputs[i]; const candidateType = candidate.type.toLowerCase(); if ( (candidateType === "text" || candidateType === "email" || candidateType === "") && isVisible(candidate) && !SKIP_INPUT_TYPES.has(candidateType) && !fields.some((f) => f.el === candidate) ) { fields.push({ type: "username", el: candidate }); break; // only take the first one } } } return fields; } export function isVisible(el: HTMLElement): boolean { const s = getComputedStyle(el); const r = el.getBoundingClientRect(); return ( s.display !== "none" && s.visibility !== "hidden" && parseFloat(s.opacity) > 0 && r.width > 0 && r.height > 0 ); } export function getPageDomain(): string { return extractDomain(location.hostname) ?? location.hostname; } export interface CardFormFields { number: HTMLInputElement | null; expMonth: HTMLInputElement | null; expYear: HTMLInputElement | null; expiryCombo: HTMLInputElement | null; cvc: HTMLInputElement | null; name: HTMLInputElement | null; } export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null { const numberByAC = root.querySelector('input[autocomplete="cc-number"]'); const expByAC = root.querySelector('input[autocomplete="cc-exp"]'); const expMonthByAC = root.querySelector('input[autocomplete="cc-exp-month"]'); const expYearByAC = root.querySelector('input[autocomplete="cc-exp-year"]'); const cvcByAC = root.querySelector( 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]' ); const nameByAC = root.querySelector('input[autocomplete="cc-name"]'); if (numberByAC) { return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC }; } const allInputs = Array.from(root.querySelectorAll('input')); const numberField = allInputs.find(i => /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder) ) ?? null; const cvcField = allInputs.find(i => /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder) ) ?? null; const expField = allInputs.find(i => /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder) ) ?? null; if (!numberField) return null; return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null }; } const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com']; export function hasPaymentProcessorIframe(): boolean { const iframes = Array.from(document.querySelectorAll('iframe')); return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host))); }