detect.ts

8.4 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.).9// No \b anchors — modern sites use camelCase like "userId", "loginEmail" where10// the trigger token is contiguous with other word chars and would not match11// at a word boundary.12const 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;1415// Input types that are never text-entry fields and must never get autofill16// icons. submit/button/reset/image are interactive controls — putting the17// icon over them blocks the click. The rest are structurally incapable of18// holding a username/password.19const 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]);2526// Some sites render submit buttons as <input> with no type attribute (defaults to "text")27// and a value like "Login" or "Submit". Catch those before injecting icons.28function 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  // Two or more password fields means signup/change-password, not login81  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    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets114    if (SKIP_INPUT_TYPES.has(type)) continue;115    // Catch <input> with no type attribute whose value text identifies it as a submit button116    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  // Fallback: if we have a password field but no username/email, look for142  // the first visible text input that appears before the password in DOM order.143  // This catches sites with unconventional field names (e.g. name="identifier").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; // only take the first one160      }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}