detect.ts

8.2 KB · raw

1import { extractDomain } from "../shared/domain";23export interface DetectedField {4  type: "username" | "password" | "email";5  el: HTMLInputElement;6}78// Conservative: "name" alone is too broad (matches "tag-name", "first-name", etc.)9const USERNAME_PATTERNS = /\b(user(name)?|e?mail(box)?|login|account|identifier|signin|sign.?in|credential)\b/i;10const PASSWORD_PATTERNS = /pass|passwd|pwd/i;1112// Input types that are never text-entry fields and must never get autofill13// icons. submit/button/reset/image are interactive controls — putting the14// icon over them blocks the click. The rest are structurally incapable of15// holding a username/password.16const SKIP_INPUT_TYPES = new Set([17  "submit", "button", "reset", "image",18  "checkbox", "radio", "hidden", "file",19  "range", "color", "date", "time",20  "datetime-local", "month", "week",21]);2223// Some sites render submit buttons as <input> with no type attribute (defaults to "text")24// and a value like "Login" or "Submit". Catch those before injecting icons.25function looksLikeSubmitButton(input: HTMLInputElement): boolean {26  if (input.type === "password") return false;27  const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();28  if (!val) return false;29  return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));30}3132function findLabelText(field: HTMLInputElement): string {33  const ariaLabel = field.getAttribute("aria-label") ?? "";34  if (ariaLabel) return ariaLabel.toLowerCase();35  const id = field.id;36  if (id) {37    const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);38    if (label) return label.textContent?.toLowerCase() ?? "";39  }40  const wrappingLabel = field.closest("label");41  return wrappingLabel?.textContent?.toLowerCase() ?? "";42}4344export function querySelectorAllDeep<T extends Element>(45  root: Document | ShadowRoot | Element,46  selector: string47): T[] {48  const results: T[] = [];49  const walk = (node: Document | ShadowRoot | Element) => {50    if (node instanceof Element) {51      results.push(...(node.querySelectorAll<T>(selector)));52      const shadowRoot = node.shadowRoot;53      if (shadowRoot) {54        shadowRoot.querySelectorAll<T>("*").forEach(walk);55      }56    }57  };58  root.querySelectorAll<T>("*").forEach(walk);59  return results;60}6162export function getDeepActiveInput(): HTMLInputElement | null {63  let current: Document | Element | null = document.activeElement;64  while (current) {65    if (current instanceof HTMLInputElement) return current;66    if (current instanceof Element && current.shadowRoot) {67      current = current.shadowRoot.activeElement;68    } else {69      break;70    }71  }72  return null;73}7475export function isLoginForm(fields: DetectedField[]): boolean {76  const hasPassword = fields.some((f) => f.type === "password");77  const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");78  if (hasPassword && hasUsernameOrEmail) return true;79  const singlePassword = fields.filter((f) => f.type === "password");80  if (singlePassword.length === 1 && singlePassword[0].el.autocomplete === "current-password") {81    return true;82  }83  return false;84}8586export function isSignupForm(fields: DetectedField[]): boolean {87  const passwords = fields.filter((f) => f.type === "password");88  if (passwords.length >= 2) return true;89  if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;90  if (91    passwords.some((f) => {92      const id = (f.el.id + f.el.name).toLowerCase();93      return /new|confirm/i.test(id);94    })95  ) {96    return true;97  }98  return false;99}100101export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {102  const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));103  const fields: DetectedField[] = [];104105  for (const el of inputs) {106    if (!isVisible(el)) continue;107    const type = el.type.toLowerCase();108    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets109    if (SKIP_INPUT_TYPES.has(type)) continue;110    // Catch <input> with no type attribute whose value text identifies it as a submit button111    if (looksLikeSubmitButton(el)) continue;112    const ac = el.autocomplete;113    const name = (el.name + el.id + el.placeholder).toLowerCase();114115    if (ac === "current-password" || ac === "new-password" || type === "password") {116      fields.push({ type: "password", el });117      continue;118    }119120    if (ac === "username" || ac === "email" || type === "email") {121      fields.push({ type: "username", el });122      continue;123    }124125    if (USERNAME_PATTERNS.test(name)) {126      fields.push({ type: "username", el });127      continue;128    }129130    const labelText = findLabelText(el);131    if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {132      fields.push({ type: "username", el });133    }134  }135136  // Fallback: if we have a password field but no username/email, look for137  // the first visible text input that appears before the password in DOM order.138  // This catches sites with unconventional field names (e.g. name="identifier").139  const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");140  const pwField = fields.find((f) => f.type === "password");141  if (!hasUsername && pwField) {142    const allInputs = Array.from(inputs);143    const pwIndex = allInputs.indexOf(pwField.el);144    for (let i = 0; i < pwIndex; i++) {145      const candidate = allInputs[i];146      const candidateType = candidate.type.toLowerCase();147      if (148        (candidateType === "text" || candidateType === "email" || candidateType === "") &&149        isVisible(candidate) &&150        !SKIP_INPUT_TYPES.has(candidateType) &&151        !fields.some((f) => f.el === candidate)152      ) {153        fields.push({ type: "username", el: candidate });154        break; // only take the first one155      }156    }157  }158159  return fields;160}161162export function isVisible(el: HTMLElement): boolean {163  const s = getComputedStyle(el);164  const r = el.getBoundingClientRect();165  return (166    s.display !== "none" &&167    s.visibility !== "hidden" &&168    parseFloat(s.opacity) > 0 &&169    r.width > 0 &&170    r.height > 0171  );172}173174export function getPageDomain(): string {175  return extractDomain(location.hostname) ?? location.hostname;176}177178export interface CardFormFields {179  number: HTMLInputElement | null;180  expMonth: HTMLInputElement | null;181  expYear: HTMLInputElement | null;182  expiryCombo: HTMLInputElement | null;183  cvc: HTMLInputElement | null;184  name: HTMLInputElement | null;185}186187export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {188  const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');189  const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');190  const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');191  const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');192  const cvcByAC = root.querySelector<HTMLInputElement>(193    'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'194  );195  const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');196197  if (numberByAC) {198    return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };199  }200201  const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));202  const numberField = allInputs.find(i =>203    /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)204  ) ?? null;205  const cvcField = allInputs.find(i =>206    /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)207  ) ?? null;208  const expField = allInputs.find(i =>209    /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)210  ) ?? null;211212  if (!numberField) return null;213  return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };214}215216const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];217218export function hasPaymentProcessorIframe(): boolean {219  const iframes = Array.from(document.querySelectorAll('iframe'));220  return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));221}