policy.ts

3.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 < 3; i++) {31    node = node?.parentElement ?? null;32    if (!node) break;33    const hints = node.querySelectorAll(34      "p, span, li, small, .hint, .requirement, .password-rule"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    const minMatch = pat.match(/\{(\d+),/);54    const maxMatch = pat.match(/,(\d+)\}/);55    if (minMatch) policy.minLength = parseInt(minMatch[1]!);56    if (maxMatch) policy.maxLength = parseInt(maxMatch[1]!);57  }5859  const text = extractNearbyText(passwordField);6061  const minMatch = text.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);62  const maxMatch = text.match(/(?:at most|maximum|max|up to|cannot exceed|must not exceed|no more than)[^\d]*(\d+)/i);63  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);64  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);6566  if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;67  if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;68  if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;69  if (/number|digit/i.test(text)) policy.requiresNumbers = true;7071  // Forbidden characters — scan a ~220-char window after the first negation72  // keyword and collect single-symbol tokens bounded by whitespace, commas,73  // backticks, or quotes. This tolerates the phrasings actually seen in the74  // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code,75  // comma lists). Descriptive prose ("cannot contain the user's email")76  // does not contribute symbols because the only punctuation it includes77  // (comma, period, apostrophe) is boundary, not content.78  const negation = text.match(79    /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i80  );81  if (negation) {82    const window = negation[0].slice(0, NEGATION_WINDOW);83    const seen = new Set<string>();84    const tokenRegex = /(?:^|[\s`"',.(\[])([!-/:-@\[-`{-~])(?=[\s`"',.)\]]|$)/g;85    let m: RegExpExecArray | null;86    while ((m = tokenRegex.exec(window)) !== null) {87      const ch = m[1]!;88      if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);89    }90    if (seen.size > 0) policy.forbiddenChars = [...seen].join("");91  }9293  return policy;94}