detect.ts

6.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}