1import { extractDomain } from "../shared/domain";23export interface DetectedField {4 type: "username" | "password" | "email";5 el: HTMLInputElement;6}789const USERNAME_PATTERNS = /\b(user(name)?|e?mail(box)?|login|account|identifier|signin|sign.?in|credential)\b/i;10const PASSWORD_PATTERNS = /pass|passwd|pwd/i;111213const SKIP_INPUT_TYPES = new Set([14 "submit", "button", "reset", "image",15 "checkbox", "radio", "hidden", "file",16 "range", "color", "date", "time",17 "datetime-local", "month", "week",18]);19202122function looksLikeSubmitButton(input: HTMLInputElement): boolean {23 if (input.type === "password") return false;24 const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();25 if (!val) return false;26 return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));27}2829function findLabelText(field: HTMLInputElement): string {30 const ariaLabel = field.getAttribute("aria-label") ?? "";31 if (ariaLabel) return ariaLabel.toLowerCase();32 const id = field.id;33 if (id) {34 const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);35 if (label) return label.textContent?.toLowerCase() ?? "";36 }37 const wrappingLabel = field.closest("label");38 return wrappingLabel?.textContent?.toLowerCase() ?? "";39}4041export function querySelectorAllDeep<T extends Element>(42 root: Document | ShadowRoot | Element,43 selector: string44): T[] {45 const results: T[] = [];46 const walk = (node: Document | ShadowRoot | Element) => {47 if (node instanceof Element) {48 results.push(...(node.querySelectorAll<T>(selector)));49 const shadowRoot = node.shadowRoot;50 if (shadowRoot) {51 shadowRoot.querySelectorAll<T>("*").forEach(walk);52 }53 }54 };55 root.querySelectorAll<T>("*").forEach(walk);56 return results;57}5859export function getDeepActiveInput(): HTMLInputElement | null {60 let current: Document | Element | null = document.activeElement;61 while (current) {62 if (current instanceof HTMLInputElement) return current;63 if (current instanceof Element && current.shadowRoot) {64 current = current.shadowRoot.activeElement;65 } else {66 break;67 }68 }69 return null;70}7172export function isLoginForm(fields: DetectedField[]): boolean {73 const hasPassword = fields.some((f) => f.type === "password");74 const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");75 if (hasPassword && hasUsernameOrEmail) return true;76 const singlePassword = fields.filter((f) => f.type === "password");77 if (singlePassword.length === 1 && singlePassword[0].el.autocomplete === "current-password") {78 return true;79 }80 return false;81}8283export function isSignupForm(fields: DetectedField[]): boolean {84 const passwords = fields.filter((f) => f.type === "password");85 if (passwords.length >= 2) return true;86 if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;87 if (88 passwords.some((f) => {89 const id = (f.el.id + f.el.name).toLowerCase();90 return /new|confirm/i.test(id);91 })92 ) {93 return true;94 }95 return false;96}9798export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {99 const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));100 const fields: DetectedField[] = [];101102 for (const el of inputs) {103 if (!isVisible(el)) continue;104 const type = el.type.toLowerCase();105 106 if (SKIP_INPUT_TYPES.has(type)) continue;107 108 if (looksLikeSubmitButton(el)) continue;109 const ac = el.autocomplete;110 const name = (el.name + el.id + el.placeholder).toLowerCase();111112 if (ac === "current-password" || ac === "new-password" || type === "password") {113 fields.push({ type: "password", el });114 continue;115 }116117 if (ac === "username" || ac === "email" || type === "email") {118 fields.push({ type: "username", el });119 continue;120 }121122 if (USERNAME_PATTERNS.test(name)) {123 fields.push({ type: "username", el });124 continue;125 }126127 const labelText = findLabelText(el);128 if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {129 fields.push({ type: "username", el });130 }131 }132133 134 135 136 const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");137 const pwField = fields.find((f) => f.type === "password");138 if (!hasUsername && pwField) {139 const allInputs = Array.from(inputs);140 const pwIndex = allInputs.indexOf(pwField.el);141 for (let i = 0; i < pwIndex; i++) {142 const candidate = allInputs[i];143 const candidateType = candidate.type.toLowerCase();144 if (145 (candidateType === "text" || candidateType === "email" || candidateType === "") &&146 isVisible(candidate) &&147 !SKIP_INPUT_TYPES.has(candidateType) &&148 !fields.some((f) => f.el === candidate)149 ) {150 fields.push({ type: "username", el: candidate });151 break; 152 }153 }154 }155156 return fields;157}158159export function isVisible(el: HTMLElement): boolean {160 const s = getComputedStyle(el);161 const r = el.getBoundingClientRect();162 return (163 s.display !== "none" &&164 s.visibility !== "hidden" &&165 parseFloat(s.opacity) > 0 &&166 r.width > 0 &&167 r.height > 0168 );169}170171export function getPageDomain(): string {172 return extractDomain(location.hostname) ?? location.hostname;173}174175export interface CardFormFields {176 number: HTMLInputElement | null;177 expMonth: HTMLInputElement | null;178 expYear: HTMLInputElement | null;179 expiryCombo: HTMLInputElement | null;180 cvc: HTMLInputElement | null;181 name: HTMLInputElement | null;182}183184export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {185 const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');186 const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');187 const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');188 const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');189 const cvcByAC = root.querySelector<HTMLInputElement>(190 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'191 );192 const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');193194 if (numberByAC) {195 return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };196 }197198 const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));199 const numberField = allInputs.find(i =>200 /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)201 ) ?? null;202 const cvcField = allInputs.find(i =>203 /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)204 ) ?? null;205 const expField = allInputs.find(i =>206 /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)207 ) ?? null;208209 if (!numberField) return null;210 return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };211}212213const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];214215export function hasPaymentProcessorIframe(): boolean {216 const iframes = Array.from(document.querySelectorAll('iframe'));217 return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));218}