/** * policy.ts — page-scraping helpers for password requirements. * * Content-side only; no chrome.* API access. Called by both inject.ts * (for the inline create form) and index.ts (for the popup's smart * GET_SITE_PASSWORD_POLICY request). */ import type { PasswordPolicy } from "../shared/types"; const NEGATION_WINDOW = 220; // Characters that plausibly appear in a site's forbidden-character list. // Restricted to ASCII punctuation a user could actually type. Anything else // (letters, digits, whitespace) is ignored so descriptive prose like // "cannot contain the users first name, last name" does not leak the comma. const FORBIDDEN_CHAR_SET = new Set([ "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*", "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'", ]); export function extractNearbyText(el: HTMLInputElement | null): string { if (!el) return ""; const describedBy = el.getAttribute("aria-describedby"); if (describedBy) { const desc = el.ownerDocument.getElementById(describedBy); if (desc) return desc.textContent ?? ""; } let node: Element | null = el; for (let i = 0; i < 5; i++) { node = node?.parentElement ?? null; if (!node) break; const hints = node.querySelectorAll( "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint" ); if (hints.length > 0) { return Array.from(hints).map((h) => h.textContent).join(" "); } } return ""; } export function detectPasswordPolicy(doc: Document = document): PasswordPolicy { const policy: PasswordPolicy = {}; const passwordField = doc.querySelector( 'input[autocomplete="new-password"], input[type="password"]' ); const maxLen = passwordField?.maxLength ?? 0; if (maxLen > 0) policy.maxLength = maxLen; if (passwordField?.pattern) { const pat = passwordField.pattern; // .{N,M} — range; .{N,} — min only; .{N} — exact const rangeMatch = pat.match(/\{(\d+),(\d+)\}/); const minOnlyMatch = pat.match(/\{(\d+),\}/); const exactMatch = pat.match(/\{(\d+)\}/); if (rangeMatch) { policy.minLength = parseInt(rangeMatch[1]!); policy.maxLength = parseInt(rangeMatch[2]!); } else if (minOnlyMatch) { policy.minLength = parseInt(minOnlyMatch[1]!); } else if (exactMatch) { policy.minLength = parseInt(exactMatch[1]!); policy.maxLength = parseInt(exactMatch[1]!); } } const text = extractNearbyText(passwordField); const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i); 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); const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i); const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i); if (betweenMatch && !policy.minLength) policy.minLength = parseInt(betweenMatch[1]!); if (betweenMatch && !policy.maxLength) policy.maxLength = parseInt(betweenMatch[2]!); if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!); if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!); if (exactMatch2 && !policy.minLength) policy.minLength = parseInt(exactMatch2[1]!); if (exactMatch2 && !policy.maxLength) policy.maxLength = parseInt(exactMatch2[1]!); if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true; if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true; if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true; if (/number|digit/i.test(text)) policy.requiresNumbers = true; // Forbidden characters — scan a ~220-char window after the first negation // keyword and collect single-symbol tokens bounded by whitespace, commas, // backticks, or quotes. This tolerates the phrasings actually seen in the // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code, // comma lists). Descriptive prose ("cannot contain the user's email") // does not contribute symbols because the only punctuation it includes // (comma, period, apostrophe) is boundary, not content. const negation = text.match( /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i ); if (negation) { const window = negation[0].slice(0, NEGATION_WINDOW); const seen = new Set(); const tokenRegex = /(?:^|[\s`"',.(\[])([!-/:-@\[-`{-~])(?=[\s`"',.)\]]|$)/g; let m: RegExpExecArray | null; while ((m = tokenRegex.exec(window)) !== null) { const ch = m[1]!; if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch); } if (seen.size > 0) policy.forbiddenChars = [...seen].join(""); } return policy; }