detect.ts

8.0 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 autofill icons13const 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]);1920// Some sites render submit buttons as <input> with no type attribute (defaults to "text")21// and a value like "Login" or "Submit". Catch those before injecting icons.22function 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    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets106    if (SKIP_INPUT_TYPES.has(type)) continue;107    // Catch <input> with no type attribute whose value text identifies it as a submit button108    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  // Fallback: if we have a password field but no username/email, look for134  // the first visible text input that appears before the password in DOM order.135  // This catches sites with unconventional field names (e.g. name="identifier").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; // only take the first one152      }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}