detect.ts

9.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.).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;1314// Input types that are never text-entry fields and must never get autofill15// icons. submit/button/reset/image are interactive controls — putting the16// icon over them blocks the click. The rest are structurally incapable of17// holding a username/password.18const SKIP_INPUT_TYPES = new Set([19  "submit", "button", "reset", "image",20  "checkbox", "radio", "hidden", "file",21  "range", "color", "date", "time",22  "datetime-local", "month", "week",23]);2425// Some sites render submit buttons as <input> with no type attribute (defaults to "text")26// and a value like "Login" or "Submit". Catch those before injecting icons.27function looksLikeSubmitButton(input: HTMLInputElement): boolean {28  if (input.type === "password") return false;29  const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();30  if (!val) return false;31  return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));32}3334function findLabelText(field: HTMLInputElement): string {35  const ariaLabel = field.getAttribute("aria-label") ?? "";36  if (ariaLabel) return ariaLabel.toLowerCase();37  const id = field.id;38  if (id) {39    const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);40    if (label) return label.textContent?.toLowerCase() ?? "";41  }42  const wrappingLabel = field.closest("label");43  return wrappingLabel?.textContent?.toLowerCase() ?? "";44}4546export function querySelectorAllDeep<T extends Element>(47  root: Document | ShadowRoot | Element,48  selector: string49): T[] {50  const results: T[] = [];51  const walk = (node: Document | ShadowRoot | Element) => {52    if (node instanceof Element) {53      results.push(...(node.querySelectorAll<T>(selector)));54      const shadowRoot = node.shadowRoot;55      if (shadowRoot) {56        shadowRoot.querySelectorAll<T>("*").forEach(walk);57      }58    }59  };60  root.querySelectorAll<T>("*").forEach(walk);61  return results;62}6364export function getDeepActiveInput(): HTMLInputElement | null {65  let current: Document | Element | null = document.activeElement;66  while (current) {67    if (current instanceof HTMLInputElement) return current;68    if (current instanceof Element && current.shadowRoot) {69      current = current.shadowRoot.activeElement;70    } else {71      break;72    }73  }74  return null;75}7677export function isTOTPField(input: HTMLInputElement): boolean {78  if (!isVisible(input)) return false;79  const autocomplete = input.autocomplete.toLowerCase();80  const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();81  if (82    autocomplete === "one-time-code" ||83    /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||84    /\b(verification|authenticator|security)\s+code\b/.test(fieldText)85  ) {86    return true;87  }8889  const labelText = findLabelText(input);90  return (91    /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||92    /\b(verification|authenticator|security)\s+code\b/.test(labelText)93  );94}9596export function isLoginForm(fields: DetectedField[]): boolean {97  const passwords = fields.filter((f) => f.type === "password");98  // Two or more password fields means signup/change-password, not login99  if (passwords.length >= 2) return false;100  const hasPassword = passwords.length > 0;101  const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");102  if (hasPassword && hasUsernameOrEmail) return true;103  if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {104    return true;105  }106  return false;107}108109export function isSignupForm(fields: DetectedField[]): boolean {110  const passwords = fields.filter((f) => f.type === "password");111  if (passwords.length >= 2) return true;112  if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;113  if (114    passwords.some((f) => {115      const id = (f.el.id + f.el.name).toLowerCase();116      return /new|confirm/i.test(id);117    })118  ) {119    return true;120  }121  return false;122}123124export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {125  const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));126  const fields: DetectedField[] = [];127128  for (const el of inputs) {129    if (!isVisible(el)) continue;130    const type = el.type.toLowerCase();131    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets132    if (SKIP_INPUT_TYPES.has(type)) continue;133    // Catch <input> with no type attribute whose value text identifies it as a submit button134    if (looksLikeSubmitButton(el)) continue;135    const ac = el.autocomplete;136    const name = (el.name + el.id + el.placeholder).toLowerCase();137138    if (ac === "current-password" || ac === "new-password" || type === "password") {139      fields.push({ type: "password", el });140      continue;141    }142143    if (ac === "username" || ac === "email" || type === "email") {144      fields.push({ type: "username", el });145      continue;146    }147148    if (USERNAME_PATTERNS.test(name)) {149      fields.push({ type: "username", el });150      continue;151    }152153    const labelText = findLabelText(el);154    if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {155      fields.push({ type: "username", el });156    }157  }158159  // Fallback: if we have a password field but no username/email, look for160  // the first visible text input that appears before the password in DOM order.161  // This catches sites with unconventional field names (e.g. name="identifier").162  const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");163  const pwField = fields.find((f) => f.type === "password");164  if (!hasUsername && pwField) {165    const allInputs = Array.from(inputs);166    const pwIndex = allInputs.indexOf(pwField.el);167    for (let i = 0; i < pwIndex; i++) {168      const candidate = allInputs[i];169      const candidateType = candidate.type.toLowerCase();170      if (171        (candidateType === "text" || candidateType === "email" || candidateType === "") &&172        isVisible(candidate) &&173        !SKIP_INPUT_TYPES.has(candidateType) &&174        !fields.some((f) => f.el === candidate)175      ) {176        fields.push({ type: "username", el: candidate });177        break; // only take the first one178      }179    }180  }181182  return fields;183}184185export function isVisible(el: HTMLElement): boolean {186  const s = getComputedStyle(el);187  const r = el.getBoundingClientRect();188  return (189    s.display !== "none" &&190    s.visibility !== "hidden" &&191    parseFloat(s.opacity) > 0 &&192    r.width > 0 &&193    r.height > 0194  );195}196197export function getPageDomain(): string {198  return extractDomain(location.hostname) ?? location.hostname;199}200201export interface CardFormFields {202  number: HTMLInputElement | null;203  expMonth: HTMLInputElement | null;204  expYear: HTMLInputElement | null;205  expiryCombo: HTMLInputElement | null;206  cvc: HTMLInputElement | null;207  name: HTMLInputElement | null;208}209210export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {211  const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');212  const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');213  const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');214  const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');215  const cvcByAC = root.querySelector<HTMLInputElement>(216    'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'217  );218  const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');219220  if (numberByAC) {221    return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };222  }223224  const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));225  const numberField = allInputs.find(i =>226    /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)227  ) ?? null;228  const cvcField = allInputs.find(i =>229    /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)230  ) ?? null;231  const expField = allInputs.find(i =>232    /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)233  ) ?? null;234235  if (!numberField) return null;236  return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };237}238239const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];240241export function hasPaymentProcessorIframe(): boolean {242  const iframes = Array.from(document.querySelectorAll('iframe'));243  return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));244}