policy.ts

4.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 describedBy = el.getAttribute("aria-describedby");25  if (describedBy) {26    const desc = el.ownerDocument.getElementById(describedBy);27    if (desc) return desc.textContent ?? "";28  }29  let node: Element | null = el;30  for (let i = 0; i < 5; i++) {31    node = node?.parentElement ?? null;32    if (!node) break;33    const hints = node.querySelectorAll(34      "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint"35    );36    if (hints.length > 0) {37      return Array.from(hints).map((h) => h.textContent).join(" ");38    }39  }40  return "";41}4243export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {44  const policy: PasswordPolicy = {};4546  const passwordField = doc.querySelector<HTMLInputElement>(47    'input[autocomplete="new-password"], input[type="password"]'48  );49  const maxLen = passwordField?.maxLength ?? 0;50  if (maxLen > 0) policy.maxLength = maxLen;51  if (passwordField?.pattern) {52    const pat = passwordField.pattern;53    // .{N,M} — range; .{N,} — min only; .{N} — exact54    const rangeMatch = pat.match(/\{(\d+),(\d+)\}/);55    const minOnlyMatch = pat.match(/\{(\d+),\}/);56    const exactMatch = pat.match(/\{(\d+)\}/);57    if (rangeMatch) {58      policy.minLength = parseInt(rangeMatch[1]!);59      policy.maxLength = parseInt(rangeMatch[2]!);60    } else if (minOnlyMatch) {61      policy.minLength = parseInt(minOnlyMatch[1]!);62    } else if (exactMatch) {63      policy.minLength = parseInt(exactMatch[1]!);64      policy.maxLength = parseInt(exactMatch[1]!);65    }66  }6768  const text = extractNearbyText(passwordField);6970  const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i);71  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);72  const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i);73  const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);74  if (betweenMatch && !policy.minLength) policy.minLength = parseInt(betweenMatch[1]!);75  if (betweenMatch && !policy.maxLength) policy.maxLength = parseInt(betweenMatch[2]!);76  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);77  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);78  if (exactMatch2 && !policy.minLength) policy.minLength = parseInt(exactMatch2[1]!);79  if (exactMatch2 && !policy.maxLength) policy.maxLength = parseInt(exactMatch2[1]!);8081  if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;82  if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;83  if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;84  if (/number|digit/i.test(text)) policy.requiresNumbers = true;8586  // Forbidden characters — scan a ~220-char window after the first negation87  // keyword and collect single-symbol tokens bounded by whitespace, commas,88  // backticks, or quotes. This tolerates the phrasings actually seen in the89  // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code,90  // comma lists). Descriptive prose ("cannot contain the user's email")91  // does not contribute symbols because the only punctuation it includes92  // (comma, period, apostrophe) is boundary, not content.93  const negation = text.match(94    /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i95  );96  if (negation) {97    const window = negation[0].slice(0, NEGATION_WINDOW);98    const seen = new Set<string>();99    const tokenRegex = /(?:^|[\s`"',.(\[])([!-/:-@\[-`{-~])(?=[\s`"',.)\]]|$)/g;100    let m: RegExpExecArray | null;101    while ((m = tokenRegex.exec(window)) !== null) {102      const ch = m[1]!;103      if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);104    }105    if (seen.size > 0) policy.forbiddenChars = [...seen].join("");106  }107108  return policy;109}