policy.ts

7.3 KB · raw

1/**2 * policy.ts — page-scraping helpers for password requirements.3 *4 * Content-side only; no chrome.* API access. Called by both inject.ts5 * (for the inline create form) and index.ts (for the popup's smart6 * GET_SITE_PASSWORD_POLICY request).7 */89import type { PasswordPolicy } from "../shared/types";1011const NEGATION_WINDOW = 220;12const MAX_POLICY_TEXT_CHARS = 4_096;13const MAX_POLICY_PART_CHARS = 1_024;14const MAX_DESCRIBED_IDS = 16;15const MAX_HINTS = 32;16const MAX_SCANNED_NODES = 256;1718// Characters that plausibly appear in a site's forbidden-character list.19// Restricted to ASCII punctuation a user could actually type. Anything else20// (letters, digits, whitespace) is ignored so descriptive prose like21// "cannot contain the users first name, last name" does not leak the comma.22const FORBIDDEN_CHAR_SET = new Set([23  "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*",24  "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'",25]);2627export function extractNearbyText(el: HTMLInputElement | null): string {28  if (!el) return "";29  const parts: string[] = [];30  let remaining = MAX_POLICY_TEXT_CHARS;31  const append = (text: string | null | undefined) => {32    const separator = parts.length > 0 ? 1 : 0;33    const length = Math.min(MAX_POLICY_PART_CHARS, remaining - separator);34    if (!text || length <= 0) return;35    parts.push(text.slice(0, length));36    remaining -= Math.min(text.length, length) + separator;37  };38  const boundedText = (root: Element): string => {39    const text: string[] = [];40    let chars = 0;41    let visited = 0;42    const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ALL);43    let current = walker.nextNode();44    while (visited < MAX_SCANNED_NODES && chars < MAX_POLICY_PART_CHARS && current) {45      visited += 1;46      if (current.nodeType === Node.TEXT_NODE && current.nodeValue) {47        const value = current.nodeValue.slice(0, MAX_POLICY_PART_CHARS - chars);48        text.push(value);49        chars += value.length;50      }51      current = walker.nextNode();52    }53    return text.join(" ");54  };5556  // aria-describedby may be a space-separated list of IDs57  const describedBy = el.getAttribute("aria-describedby");58  if (describedBy) {59    for (const id of describedBy.trim().split(/\s+/).slice(0, MAX_DESCRIBED_IDS)) {60      const desc = el.ownerDocument.getElementById(id);61      if (desc) append(boundedText(desc));62    }63  }6465  // Field-level attributes that sometimes carry requirements66  append(el.title);67  append(el.placeholder);68  const ariaLabel = el.getAttribute("aria-label");69  append(ariaLabel);7071  const HINT_SELECTOR =72    "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint";7374  let node: Element | null = el;75  for (let i = 0; i < 5; i++) {76    node = node?.parentElement ?? null;77    if (!node) break;78    const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);79    let current = walker.nextNode();80    let visited = 0;81    let hints = 0;82    while (visited < MAX_SCANNED_NODES && hints < MAX_HINTS && current) {83      visited += 1;84      if (current instanceof Element && current.matches(HINT_SELECTOR)) {85        append(boundedText(current));86        hints += 1;87      }88      current = walker.nextNode();89    }90    if (hints > 0) {91      break;92    }93  }9495  return parts.join(" ");96}9798export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {99  const policy: PasswordPolicy = {};100101  const passwordField = doc.querySelector<HTMLInputElement>(102    'input[autocomplete="new-password"], input[type="password"]'103  );104  const maxLen = passwordField?.maxLength ?? 0;105  if (maxLen > 0) policy.maxLength = maxLen;106  if (passwordField?.pattern) {107    const pat = passwordField.pattern;108    // .{N,M} — range; .{N,} — min only; .{N} — exact109    const rangeMatch = pat.match(/\{(\d+),(\d+)\}/);110    const minOnlyMatch = pat.match(/\{(\d+),\}/);111    const exactMatch = pat.match(/\{(\d+)\}/);112    if (rangeMatch) {113      policy.minLength = Number.parseInt(rangeMatch[1]!, 10);114      policy.maxLength = Number.parseInt(rangeMatch[2]!, 10);115    } else if (minOnlyMatch) {116      policy.minLength = Number.parseInt(minOnlyMatch[1]!, 10);117    } else if (exactMatch) {118      policy.minLength = Number.parseInt(exactMatch[1]!, 10);119      policy.maxLength = Number.parseInt(exactMatch[1]!, 10);120    }121  }122123  const text = extractNearbyText(passwordField);124125  // "8-20 characters", "8–20 characters", "8 - 20 chars" — most common real-world format126  const dashRangeMatch = text.match(/(\d+)\s*[-–—]\s*(\d+)\s*char/i);127  // "between 8 and 20", "between 8-20"128  const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i);129  const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i);130  const maxMatch = text.match(/(?:at most|maximum|max(?:imum)?|up to|cannot exceed|must not exceed|no more than|must be (?:no more than |at most )?)(?:\D*?)(\d+)/i);131  const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);132  // dash range takes priority as the most unambiguous format133  if (dashRangeMatch && !policy.minLength) policy.minLength = Number.parseInt(dashRangeMatch[1]!, 10);134  if (dashRangeMatch && !policy.maxLength) policy.maxLength = Number.parseInt(dashRangeMatch[2]!, 10);135  if (betweenMatch && !policy.minLength) policy.minLength = Number.parseInt(betweenMatch[1]!, 10);136  if (betweenMatch && !policy.maxLength) policy.maxLength = Number.parseInt(betweenMatch[2]!, 10);137  if (minMatch && !policy.minLength) policy.minLength = Number.parseInt(minMatch[1]!, 10);138  if (maxMatch && !policy.maxLength) policy.maxLength = Number.parseInt(maxMatch[1]!, 10);139  if (exactMatch2 && !policy.minLength) policy.minLength = Number.parseInt(exactMatch2[1]!, 10);140  if (exactMatch2 && !policy.maxLength) policy.maxLength = Number.parseInt(exactMatch2[1]!, 10);141142  if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;143  if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;144  if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;145  if (/number|digit/i.test(text)) policy.requiresNumbers = true;146147  // Forbidden characters — scan a ~220-char window after the first negation148  // keyword and collect single-symbol tokens bounded by whitespace, commas,149  // backticks, or quotes. This tolerates the phrasings actually seen in the150  // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code,151  // comma lists). Descriptive prose ("cannot contain the user's email")152  // does not contribute symbols because the only punctuation it includes153  // (comma, period, apostrophe) is boundary, not content.154  const negation = text.match(155    /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i156  );157  if (negation) {158    const window = negation[0].slice(0, NEGATION_WINDOW);159    const seen = new Set<string>();160    const tokenRegex = /(?:^|[\s`"',.([])([!-/:-@[-`{-~])(?=[\s`"',.)\]]|$)/g;161    let m: RegExpExecArray | null;162    // biome-ignore lint/suspicious/noAssignInExpressions: standard RegExp.exec loop idiom163    while ((m = tokenRegex.exec(window)) !== null) {164      const ch = m[1]!;165      if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);166    }167    if (seen.size > 0) policy.forbiddenChars = [...seen].join("");168  }169170  return policy;171}