detect.ts

16.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;1314// Personal name fields (display name, first/last name) — not credential fields.15// Applied to prevent the fallback from picking up name inputs on signup forms.16const NAME_FIELD_PATTERNS = /\b(first[\s_-]?name|last[\s_-]?name|full[\s_-]?name|display[\s_-]?name|real[\s_-]?name|given[\s_-]?name|family[\s_-]?name|middle[\s_-]?name)\b|^(name|fname|lname|fullname|displayname|realname)$/i;1718// Input types that are never text-entry fields and must never get autofill19// icons. submit/button/reset/image are interactive controls — putting the20// icon over them blocks the click. The rest are structurally incapable of21// holding a username/password.22const SKIP_INPUT_TYPES = new Set([23  "submit", "button", "reset", "image",24  "checkbox", "radio", "hidden", "file",25  "range", "color", "date", "time",26  "datetime-local", "month", "week",27]);2829// Some sites render submit buttons as <input> with no type attribute (defaults to "text")30// and a value like "Login" or "Submit". Catch those before injecting icons.31function looksLikeSubmitButton(input: HTMLInputElement): boolean {32  if (input.type === "password") return false;33  const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();34  if (!val) return false;35  return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));36}3738function findLabelText(field: HTMLInputElement): string {39  const ariaLabel = field.getAttribute("aria-label") ?? "";40  if (ariaLabel) return ariaLabel.toLowerCase();41  const id = field.id;42  if (id) {43    // PCH-01-025: ids can contain CSS-special chars (quotes, brackets, leading44    // digits). Escape, and defensively swallow a malformed-selector throw so a45    // single weird id can't abort field detection for the whole page.46    try {47      const label = document.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(id)}"]`);48      if (label) return label.textContent?.toLowerCase() ?? "";49    } catch {50      /* malformed id — skip label-by-id lookup, continue field detection */51    }52  }53  const wrappingLabel = field.closest("label");54  if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();5556  let node: Element | null = field;57  for (let depth = 0; node && depth < 3; depth++) {58    const previous = node.previousElementSibling;59    const text = previous?.textContent?.trim().toLowerCase() ?? "";60    if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {61      return text;62    }63    node = node.parentElement;64  }65  return "";66}6768export function querySelectorAllDeep<T extends Element>(69  root: Document | ShadowRoot | Element,70  selector: string71): T[] {72  const results: T[] = [];73  const walk = (node: Document | ShadowRoot | Element) => {74    if (node instanceof Element) {75      results.push(...(node.querySelectorAll<T>(selector)));76      const shadowRoot = node.shadowRoot;77      if (shadowRoot) {78        shadowRoot.querySelectorAll<T>("*").forEach(walk);79      }80    }81  };82  root.querySelectorAll<T>("*").forEach(walk);83  return results;84}8586export function getDeepActiveInput(): HTMLInputElement | null {87  let current: Document | Element | null = document.activeElement;88  while (current) {89    if (current instanceof HTMLInputElement) return current;90    if (current instanceof Element && current.shadowRoot) {91      current = current.shadowRoot.activeElement;92    } else {93      break;94    }95  }96  return null;97}9899// True for a single-character OTP digit box. Reads the maxlength ATTRIBUTE, not100// the .maxLength property: for type="number"/"tel" inputs (the usual way to get101// a numeric keypad for 2FA codes) the maxlength attribute is IGNORED — browsers102// report .maxLength as a large default (524288) even when the author wrote103// maxlength="1". Reading the attribute reliably captures the real intent, so a104// numeric 2FA box group is recognised as split-OTP instead of each box being105// injected with its own autofill icon.106function isSingleDigitBox(el: HTMLInputElement): boolean {107  return el.getAttribute("maxlength") === "1";108}109110export function isTOTPField(input: HTMLInputElement): boolean {111  if (!isVisible(input)) return false;112  // Single-character inputs are individual OTP digit boxes — treat the form's113  // full-code input (if any) as the TOTP field, not each individual digit box.114  if (isSingleDigitBox(input)) return false;115  const autocomplete = input.autocomplete.toLowerCase();116  const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();117  if (118    autocomplete === "one-time-code" ||119    /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||120    /\b(verification|authenticator|security)\s+code\b/.test(fieldText)121  ) {122    return true;123  }124125  const labelText = findLabelText(input);126  return (127    /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||128    /\b(verification|authenticator|security)\s+code\b/.test(labelText)129  );130}131132// AUTH_SIGNALS_RE: submit buttons / form actions / headings that indicate auth intent.133// ANTI_AUTH_RE: signals that contradict auth (newsletter, registration with name fields, etc.).134const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;135const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;136137// Email-first / identifier-first step: user enters email or username on this page;138// the password prompt comes on the next step. Requires at least one auth-intent139// signal (URL path, form action, or submit button text) to avoid false-positives140// on newsletter and search forms that also have a single email field.141export function isEmailFirstForm(fields: DetectedField[]): boolean {142  if (!fields.some(f => f.type === "username" || f.type === "email")) return false;143  if (fields.some(f => f.type === "password")) return false;144145  // Strong URL signal: /login, /signin, /auth, /sso, /account, etc.146  if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;147148  // Check submit buttons for auth intent.149  const buttons = Array.from(document.querySelectorAll<HTMLElement>(150    'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'151  ));152  const hasAuthButton = buttons.some(b => {153    const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";154    return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);155  });156  if (!hasAuthButton) return false;157158  // Reject if the same form has name/phone/address fields — that's registration, not login.159  const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;160  const form = identifierEl.closest("form") ?? document.body;161  const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {162    if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;163    if (!isVisible(el)) return false;164    return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i165      .test(el.name + el.id + el.placeholder);166  });167  return !hasRegistrationFields;168}169170// Password-step: the email/username was entered on a previous page; this page171// shows only a single password field. Also catches sites that render username172// and password on separate screens without autocomplete hints.173export function isPasswordStepForm(fields: DetectedField[]): boolean {174  const passwords = fields.filter(f => f.type === "password");175  return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");176}177178// Single entry point used by every gate in index.ts. Covers all four patterns:179// traditional login, signup, email-first step, and password-only step.180export function isAnyCredentialForm(fields: DetectedField[]): boolean {181  return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);182}183184export function isLoginForm(fields: DetectedField[]): boolean {185  const passwords = fields.filter((f) => f.type === "password");186  // Two or more password fields means signup/change-password, not login187  if (passwords.length >= 2) return false;188  const hasPassword = passwords.length > 0;189  const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");190  if (hasPassword && hasUsernameOrEmail) return true;191  if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {192    return true;193  }194  return false;195}196197export function isSignupForm(fields: DetectedField[]): boolean {198  const passwords = fields.filter((f) => f.type === "password");199  if (passwords.length >= 2) return true;200  if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;201  if (202    passwords.some((f) => {203      const id = (f.el.id + f.el.name).toLowerCase();204      return /new|confirm/i.test(id);205    })206  ) {207    return true;208  }209  return false;210}211212export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {213  const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));214  const fields: DetectedField[] = [];215216  for (const el of inputs) {217    if (!isVisible(el)) continue;218    const type = el.type.toLowerCase();219    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets220    if (SKIP_INPUT_TYPES.has(type)) continue;221    // Single-character inputs are individual OTP digit boxes — never credential fields222    if (isSingleDigitBox(el)) continue;223    // Catch <input> with no type attribute whose value text identifies it as a submit button224    if (looksLikeSubmitButton(el)) continue;225    const ac = el.autocomplete;226    const name = (el.name + el.id + el.placeholder).toLowerCase();227228    if (ac === "current-password" || ac === "new-password" || type === "password") {229      fields.push({ type: "password", el });230      continue;231    }232233    if (ac === "username") {234      fields.push({ type: "username", el });235      continue;236    }237238    // Explicit email autocomplete or type — classified as "email" so interaction.ts239    // can track it separately from the username field on signup forms.240    if (ac === "email" || type === "email") {241      fields.push({ type: "email", el });242      continue;243    }244245    if (USERNAME_PATTERNS.test(name)) {246      fields.push({ type: "username", el });247      continue;248    }249250    const labelText = findLabelText(el);251    if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {252      fields.push({ type: "username", el });253    }254  }255256  // Fallback: if we have a password field but no username/email, look for257  // the first visible text input that appears before the password in DOM order.258  // This catches sites with unconventional field names (e.g. name="identifier").259  const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");260  const pwField = fields.find((f) => f.type === "password");261  if (!hasUsername && pwField) {262    const allInputs = Array.from(inputs);263    const pwIndex = allInputs.indexOf(pwField.el);264    for (let i = 0; i < pwIndex; i++) {265      const candidate = allInputs[i];266      const candidateType = candidate.type.toLowerCase();267      const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase();268      const candidateLabel = findLabelText(candidate);269      if (270        (candidateType === "text" || candidateType === "email" || candidateType === "") &&271        isVisible(candidate) &&272        !SKIP_INPUT_TYPES.has(candidateType) &&273        !fields.some((f) => f.el === candidate) &&274        !NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel)275      ) {276        fields.push({ type: "username", el: candidate });277        break; // only take the first one278      }279    }280  }281282  return fields;283}284285// Returns the ordered list of single-character inputs that form a multi-digit286// OTP entry group, or null if `el` is not part of one. Anchors the search on287// the nearest <form>; falls back to document.body for formless SPA pages.288// Groups of fewer than 4 or more than 10 boxes are ignored — those ranges289// are outside every practical OTP code length.290export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null {291  if (!isSingleDigitBox(el)) return null;292  const t = el.type.toLowerCase();293  if (!["text", "number", "tel", ""].includes(t)) return null;294  if (!isVisible(el)) return null;295296  const searchRoot: Element = el.closest("form") ?? document.body;297  const digitBoxes = Array.from(searchRoot.querySelectorAll<HTMLInputElement>("input")).filter(input => {298    if (!isSingleDigitBox(input)) return false;299    if (!isVisible(input)) return false;300    const it = input.type.toLowerCase();301    return ["text", "number", "tel", ""].includes(it);302  });303304  if (digitBoxes.length < 4 || digitBoxes.length > 10) return null;305  if (!digitBoxes.includes(el)) return null;306  return digitBoxes;307}308309// Returns the ordered OTP-signaling inputs sharing `field`'s form when there are310// 4–10 of them — the split-digit-box pattern used by sites whose individual311// boxes lack maxlength="1" (so isSingleDigitBox / findOTPDigitBoxGroup miss them312// and each box would otherwise get its own injected icon). isTOTPField already313// rejects maxlength=1 boxes, so this never overlaps with findOTPDigitBoxGroup;314// a lone one-time-code field has only one peer and returns null.315export function findOtpSignalingGroup(field: HTMLInputElement): HTMLInputElement[] | null {316  if (!isVisible(field)) return null;317  if (!isTOTPField(field)) return null;318  const root: Element = field.form ?? document.body;319  const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input")).filter(isTOTPField);320  if (peers.length < 4 || peers.length > 10) return null;321  return peers.includes(field) ? peers : null;322}323324export function isVisible(el: HTMLElement): boolean {325  const s = getComputedStyle(el);326  const r = el.getBoundingClientRect();327  return (328    s.display !== "none" &&329    s.visibility !== "hidden" &&330    parseFloat(s.opacity) > 0 &&331    r.width > 0 &&332    r.height > 0333  );334}335336export function getPageDomain(): string {337  return extractDomain(location.hostname) ?? location.hostname;338}339340export interface CardFormFields {341  number: HTMLInputElement | null;342  expMonth: HTMLInputElement | null;343  expYear: HTMLInputElement | null;344  expiryCombo: HTMLInputElement | null;345  cvc: HTMLInputElement | null;346  name: HTMLInputElement | null;347}348349export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {350  const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');351  const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');352  const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');353  const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');354  const cvcByAC = root.querySelector<HTMLInputElement>(355    'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'356  );357  const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');358359  if (numberByAC) {360    return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };361  }362363  const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));364  const numberField = allInputs.find(i =>365    /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)366  ) ?? null;367  const cvcField = allInputs.find(i =>368    /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)369  ) ?? null;370  const expField = allInputs.find(i =>371    /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)372  ) ?? null;373374  if (!numberField) return null;375  return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };376}377378const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];379380export function hasPaymentProcessorIframe(): boolean {381  const iframes = Array.from(document.querySelectorAll('iframe'));382  return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));383}