detect.ts

5.3 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]);1920function findLabelText(field: HTMLInputElement): string {21  const ariaLabel = field.getAttribute("aria-label") ?? "";22  if (ariaLabel) return ariaLabel.toLowerCase();23  const id = field.id;24  if (id) {25    const label = document.querySelector<HTMLLabelElement>(`label[for="${id}"]`);26    if (label) return label.textContent?.toLowerCase() ?? "";27  }28  const wrappingLabel = field.closest("label");29  return wrappingLabel?.textContent?.toLowerCase() ?? "";30}3132export function querySelectorAllDeep<T extends Element>(33  root: Document | ShadowRoot | Element,34  selector: string35): T[] {36  const results: T[] = [];37  const walk = (node: Document | ShadowRoot | Element) => {38    if (node instanceof Element) {39      results.push(...(node.querySelectorAll<T>(selector)));40      const shadowRoot = node.shadowRoot;41      if (shadowRoot) {42        shadowRoot.querySelectorAll<T>("*").forEach(walk);43      }44    }45  };46  root.querySelectorAll<T>("*").forEach(walk);47  return results;48}4950export function getDeepActiveInput(): HTMLInputElement | null {51  let current: Document | Element | null = document.activeElement;52  while (current) {53    if (current instanceof HTMLInputElement) return current;54    if (current instanceof Element && current.shadowRoot) {55      current = current.shadowRoot.activeElement;56    } else {57      break;58    }59  }60  return null;61}6263export function isLoginForm(fields: DetectedField[]): boolean {64  const hasPassword = fields.some((f) => f.type === "password");65  const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");66  if (hasPassword && hasUsernameOrEmail) return true;67  const singlePassword = fields.filter((f) => f.type === "password");68  if (singlePassword.length === 1 && singlePassword[0].el.autocomplete === "current-password") {69    return true;70  }71  return false;72}7374export function isSignupForm(fields: DetectedField[]): boolean {75  const passwords = fields.filter((f) => f.type === "password");76  if (passwords.length >= 2) return true;77  if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;78  if (79    passwords.some((f) => {80      const id = (f.el.id + f.el.name).toLowerCase();81      return /new|confirm/i.test(id);82    })83  ) {84    return true;85  }86  return false;87}8889export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {90  const inputs = Array.from(querySelectorAllDeep<HTMLInputElement>(root, "input"));91  const fields: DetectedField[] = [];9293  for (const el of inputs) {94    if (!isVisible(el)) continue;95    const type = el.type.toLowerCase();96    // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets97    if (SKIP_INPUT_TYPES.has(type)) continue;98    const ac = el.autocomplete;99    const name = (el.name + el.id + el.placeholder).toLowerCase();100101    if (ac === "current-password" || ac === "new-password" || type === "password") {102      fields.push({ type: "password", el });103      continue;104    }105106    if (ac === "username" || ac === "email" || type === "email") {107      fields.push({ type: "username", el });108      continue;109    }110111    if (USERNAME_PATTERNS.test(name)) {112      fields.push({ type: "username", el });113      continue;114    }115116    const labelText = findLabelText(el);117    if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {118      fields.push({ type: "username", el });119    }120  }121122  // Fallback: if we have a password field but no username/email, look for123  // the first visible text input that appears before the password in DOM order.124  // This catches sites with unconventional field names (e.g. name="identifier").125  const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");126  const pwField = fields.find((f) => f.type === "password");127  if (!hasUsername && pwField) {128    const allInputs = Array.from(inputs);129    const pwIndex = allInputs.indexOf(pwField.el);130    for (let i = 0; i < pwIndex; i++) {131      const candidate = allInputs[i];132      const candidateType = candidate.type.toLowerCase();133      if (134        (candidateType === "text" || candidateType === "email" || candidateType === "") &&135        isVisible(candidate) &&136        !SKIP_INPUT_TYPES.has(candidateType) &&137        !fields.some((f) => f.el === candidate)138      ) {139        fields.push({ type: "username", el: candidate });140        break; // only take the first one141      }142    }143  }144145  return fields;146}147148export function isVisible(el: HTMLElement): boolean {149  const s = getComputedStyle(el);150  const r = el.getBoundingClientRect();151  return (152    s.display !== "none" &&153    s.visibility !== "hidden" &&154    parseFloat(s.opacity) > 0 &&155    r.width > 0 &&156    r.height > 0157  );158}159160export function getPageDomain(): string {161  return extractDomain(location.hostname) ?? location.hostname;162}