policy.ts

5.8 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;1213// Characters that plausibly appear in a site's forbidden-character list.14// Restricted to ASCII punctuation a user could actually type. Anything else15// (letters, digits, whitespace) is ignored so descriptive prose like16// "cannot contain the users first name, last name" does not leak the comma.17const FORBIDDEN_CHAR_SET = new Set([18  "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*",19  "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'",20]);2122export function extractNearbyText(el: HTMLInputElement | null): string {23  if (!el) return "";24  const parts: string[] = [];2526  // aria-describedby may be a space-separated list of IDs27  const describedBy = el.getAttribute("aria-describedby");28  if (describedBy) {29    for (const id of describedBy.trim().split(/\s+/)) {30      const desc = el.ownerDocument.getElementById(id);31      if (desc?.textContent) parts.push(desc.textContent);32    }33  }3435  // Field-level attributes that sometimes carry requirements36  if (el.title) parts.push(el.title);37  if (el.placeholder) parts.push(el.placeholder);38  const ariaLabel = el.getAttribute("aria-label");39  if (ariaLabel) parts.push(ariaLabel);4041  const HINT_SELECTOR =42    "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint";4344  let node: Element | null = el;45  for (let i = 0; i < 5; i++) {46    node = node?.parentElement ?? null;47    if (!node) break;48    const hints = node.querySelectorAll(HINT_SELECTOR);49    if (hints.length > 0) {50      parts.push(Array.from(hints).map((h) => h.textContent).join(" "));51      break;52    }53  }5455  return parts.filter(Boolean).join(" ");56}5758export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {59  const policy: PasswordPolicy = {};6061  const passwordField = doc.querySelector<HTMLInputElement>(62    'input[autocomplete="new-password"], input[type="password"]'63  );64  const maxLen = passwordField?.maxLength ?? 0;65  if (maxLen > 0) policy.maxLength = maxLen;66  if (passwordField?.pattern) {67    const pat = passwordField.pattern;68    // .{N,M} — range; .{N,} — min only; .{N} — exact69    const rangeMatch = pat.match(/\{(\d+),(\d+)\}/);70    const minOnlyMatch = pat.match(/\{(\d+),\}/);71    const exactMatch = pat.match(/\{(\d+)\}/);72    if (rangeMatch) {73      policy.minLength = parseInt(rangeMatch[1]!);74      policy.maxLength = parseInt(rangeMatch[2]!);75    } else if (minOnlyMatch) {76      policy.minLength = parseInt(minOnlyMatch[1]!);77    } else if (exactMatch) {78      policy.minLength = parseInt(exactMatch[1]!);79      policy.maxLength = parseInt(exactMatch[1]!);80    }81  }8283  const text = extractNearbyText(passwordField);8485  // "8-20 characters", "8–20 characters", "8 - 20 chars" — most common real-world format86  const dashRangeMatch = text.match(/(\d+)\s*[-–—]\s*(\d+)\s*char/i);87  // "between 8 and 20", "between 8-20"88  const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i);89  const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i);90  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);91  const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);92  // dash range takes priority as the most unambiguous format93  if (dashRangeMatch && !policy.minLength) policy.minLength = parseInt(dashRangeMatch[1]!);94  if (dashRangeMatch && !policy.maxLength) policy.maxLength = parseInt(dashRangeMatch[2]!);95  if (betweenMatch && !policy.minLength) policy.minLength = parseInt(betweenMatch[1]!);96  if (betweenMatch && !policy.maxLength) policy.maxLength = parseInt(betweenMatch[2]!);97  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);98  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);99  if (exactMatch2 && !policy.minLength) policy.minLength = parseInt(exactMatch2[1]!);100  if (exactMatch2 && !policy.maxLength) policy.maxLength = parseInt(exactMatch2[1]!);101102  if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;103  if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;104  if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;105  if (/number|digit/i.test(text)) policy.requiresNumbers = true;106107  // Forbidden characters — scan a ~220-char window after the first negation108  // keyword and collect single-symbol tokens bounded by whitespace, commas,109  // backticks, or quotes. This tolerates the phrasings actually seen in the110  // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code,111  // comma lists). Descriptive prose ("cannot contain the user's email")112  // does not contribute symbols because the only punctuation it includes113  // (comma, period, apostrophe) is boundary, not content.114  const negation = text.match(115    /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i116  );117  if (negation) {118    const window = negation[0].slice(0, NEGATION_WINDOW);119    const seen = new Set<string>();120    const tokenRegex = /(?:^|[\s`"',.([])([!-/:-@[-`{-~])(?=[\s`"',.)\]]|$)/g;121    let m: RegExpExecArray | null;122    // biome-ignore lint/suspicious/noAssignInExpressions: standard RegExp.exec loop idiom123    while ((m = tokenRegex.exec(window)) !== null) {124      const ch = m[1]!;125      if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);126    }127    if (seen.size > 0) policy.forbiddenChars = [...seen].join("");128  }129130  return policy;131}