detect.ts

12.7 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  if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();4445  let node: Element | null = field;46  for (let depth = 0; node && depth < 3; depth++) {47    const previous = node.previousElementSibling;48    const text = previous?.textContent?.trim().toLowerCase() ?? "";49    if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {50      return text;51    }52    node = node.parentElement;53  }54  return "";55}5657export function querySelectorAllDeep<T extends Element>(58  root: Document | ShadowRoot | Element,59  selector: string60): T[] {61  const results: T[] = [];62  const walk = (node: Document | ShadowRoot | Element) => {63    if (node instanceof Element) {64      results.push(...(node.querySelectorAll<T>(selector)));65      const shadowRoot = node.shadowRoot;66      if (shadowRoot) {67        shadowRoot.querySelectorAll<T>("*").forEach(walk);68      }69    }70  };71  root.querySelectorAll<T>("*").forEach(walk);72  return results;73}7475export function getDeepActiveInput(): HTMLInputElement | null {76  let current: Document | Element | null = document.activeElement;77  while (current) {78    if (current instanceof HTMLInputElement) return current;79    if (current instanceof Element && current.shadowRoot) {80      current = current.shadowRoot.activeElement;81    } else {82      break;83    }84  }85  return null;86}8788export function isTOTPField(input: HTMLInputElement): boolean {89  if (!isVisible(input)) return false;90  // Single-character inputs are individual OTP digit boxes — treat the form's91  // full-code input (if any) as the TOTP field, not each individual digit box.92  if (input.maxLength === 1) return false;93  const autocomplete = input.autocomplete.toLowerCase();94  const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();95  if (96    autocomplete === "one-time-code" ||97    /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||98    /\b(verification|authenticator|security)\s+code\b/.test(fieldText)99  ) {100    return true;101  }102103  const labelText = findLabelText(input);104  return (105    /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||106    /\b(verification|authenticator|security)\s+code\b/.test(labelText)107  );108}109110// AUTH_SIGNALS_RE: submit buttons / form actions / headings that indicate auth intent.111// ANTI_AUTH_RE: signals that contradict auth (newsletter, registration with name fields, etc.).112const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;113const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;114115// Email-first / identifier-first step: user enters email or username on this page;116// the password prompt comes on the next step. Requires at least one auth-intent117// signal (URL path, form action, or submit button text) to avoid false-positives118// on newsletter and search forms that also have a single email field.119export function isEmailFirstForm(fields: DetectedField[]): boolean {120  if (!fields.some(f => f.type === "username" || f.type === "email")) return false;121  if (fields.some(f => f.type === "password")) return false;122123  // Strong URL signal: /login, /signin, /auth, /sso, /account, etc.124  if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;125126  // Check submit buttons for auth intent.127  const buttons = Array.from(document.querySelectorAll<HTMLElement>(128    'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'129  ));130  const hasAuthButton = buttons.some(b => {131    const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";132    return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);133  });134  if (!hasAuthButton) return false;135136  // Reject if the same form has name/phone/address fields — that's registration, not login.137  const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;138  const form = identifierEl.closest("form") ?? document.body;139  const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {140    if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;141    if (!isVisible(el)) return false;142    return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i143      .test(el.name + el.id + el.placeholder);144  });145  return !hasRegistrationFields;146}147148// Password-step: the email/username was entered on a previous page; this page149// shows only a single password field. Also catches sites that render username150// and password on separate screens without autocomplete hints.151export function isPasswordStepForm(fields: DetectedField[]): boolean {152  const passwords = fields.filter(f => f.type === "password");153  return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");154}155156// Single entry point used by every gate in index.ts. Covers all four patterns:157// traditional login, signup, email-first step, and password-only step.158export function isAnyCredentialForm(fields: DetectedField[]): boolean {159  return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);160}161162export function isLoginForm(fields: DetectedField[]): boolean {163  const passwords = fields.filter((f) => f.type === "password");164  // Two or more password fields means signup/change-password, not login165  if (passwords.length >= 2) return false;166  const hasPassword = passwords.length > 0;167  const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");168  if (hasPassword && hasUsernameOrEmail) return true;169  if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {170    return true;171  }172  return false;173}174175export function isSignupForm(fields: DetectedField[]): boolean {176  const passwords = fields.filter((f) => f.type === "password");177  if (passwords.length >= 2) return true;178  if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;179  if (180    passwords.some((f) => {181      const id = (f.el.id + f.el.name).toLowerCase();182      return /new|confirm/i.test(id);183    })184  ) {185    return true;186  }187  return false;188}189190export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {191  const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));192  const fields: DetectedField[] = [];193194  for (const el of inputs) {195    if (!isVisible(el)) continue;196    const type = el.type.toLowerCase();197    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets198    if (SKIP_INPUT_TYPES.has(type)) continue;199    // Single-character inputs are individual OTP digit boxes — never credential fields200    if (el.maxLength === 1) continue;201    // Catch <input> with no type attribute whose value text identifies it as a submit button202    if (looksLikeSubmitButton(el)) continue;203    const ac = el.autocomplete;204    const name = (el.name + el.id + el.placeholder).toLowerCase();205206    if (ac === "current-password" || ac === "new-password" || type === "password") {207      fields.push({ type: "password", el });208      continue;209    }210211    if (ac === "username" || ac === "email" || type === "email") {212      fields.push({ type: "username", el });213      continue;214    }215216    if (USERNAME_PATTERNS.test(name)) {217      fields.push({ type: "username", el });218      continue;219    }220221    const labelText = findLabelText(el);222    if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {223      fields.push({ type: "username", el });224    }225  }226227  // Fallback: if we have a password field but no username/email, look for228  // the first visible text input that appears before the password in DOM order.229  // This catches sites with unconventional field names (e.g. name="identifier").230  const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");231  const pwField = fields.find((f) => f.type === "password");232  if (!hasUsername && pwField) {233    const allInputs = Array.from(inputs);234    const pwIndex = allInputs.indexOf(pwField.el);235    for (let i = 0; i < pwIndex; i++) {236      const candidate = allInputs[i];237      const candidateType = candidate.type.toLowerCase();238      if (239        (candidateType === "text" || candidateType === "email" || candidateType === "") &&240        isVisible(candidate) &&241        !SKIP_INPUT_TYPES.has(candidateType) &&242        !fields.some((f) => f.el === candidate)243      ) {244        fields.push({ type: "username", el: candidate });245        break; // only take the first one246      }247    }248  }249250  return fields;251}252253export function isVisible(el: HTMLElement): boolean {254  const s = getComputedStyle(el);255  const r = el.getBoundingClientRect();256  return (257    s.display !== "none" &&258    s.visibility !== "hidden" &&259    parseFloat(s.opacity) > 0 &&260    r.width > 0 &&261    r.height > 0262  );263}264265export function getPageDomain(): string {266  return extractDomain(location.hostname) ?? location.hostname;267}268269export interface CardFormFields {270  number: HTMLInputElement | null;271  expMonth: HTMLInputElement | null;272  expYear: HTMLInputElement | null;273  expiryCombo: HTMLInputElement | null;274  cvc: HTMLInputElement | null;275  name: HTMLInputElement | null;276}277278export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {279  const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');280  const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');281  const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');282  const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');283  const cvcByAC = root.querySelector<HTMLInputElement>(284    'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'285  );286  const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');287288  if (numberByAC) {289    return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };290  }291292  const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));293  const numberField = allInputs.find(i =>294    /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)295  ) ?? null;296  const cvcField = allInputs.find(i =>297    /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)298  ) ?? null;299  const expField = allInputs.find(i =>300    /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)301  ) ?? null;302303  if (!numberField) return null;304  return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };305}306307const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];308309export function hasPaymentProcessorIframe(): boolean {310  const iframes = Array.from(document.querySelectorAll('iframe'));311  return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));312}