/** * 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; const MAX_POLICY_TEXT_CHARS = 4_096; const MAX_POLICY_PART_CHARS = 1_024; const MAX_DESCRIBED_IDS = 16; const MAX_HINTS = 32; const MAX_SCANNED_NODES = 256; // 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 parts: string[] = []; let remaining = MAX_POLICY_TEXT_CHARS; const append = (text: string | null | undefined) => { const separator = parts.length > 0 ? 1 : 0; const length = Math.min(MAX_POLICY_PART_CHARS, remaining - separator); if (!text || length <= 0) return; parts.push(text.slice(0, length)); remaining -= Math.min(text.length, length) + separator; }; const boundedText = (root: Element): string => { const text: string[] = []; let chars = 0; let visited = 0; const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ALL); let current = walker.nextNode(); while (visited < MAX_SCANNED_NODES && chars < MAX_POLICY_PART_CHARS && current) { visited += 1; if (current.nodeType === Node.TEXT_NODE && current.nodeValue) { const value = current.nodeValue.slice(0, MAX_POLICY_PART_CHARS - chars); text.push(value); chars += value.length; } current = walker.nextNode(); } return text.join(" "); }; // aria-describedby may be a space-separated list of IDs const describedBy = el.getAttribute("aria-describedby"); if (describedBy) { for (const id of describedBy.trim().split(/\s+/).slice(0, MAX_DESCRIBED_IDS)) { const desc = el.ownerDocument.getElementById(id); if (desc) append(boundedText(desc)); } } // Field-level attributes that sometimes carry requirements append(el.title); append(el.placeholder); const ariaLabel = el.getAttribute("aria-label"); append(ariaLabel); const HINT_SELECTOR = "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint"; let node: Element | null = el; for (let i = 0; i < 5; i++) { node = node?.parentElement ?? null; if (!node) break; const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT); let current = walker.nextNode(); let visited = 0; let hints = 0; while (visited < MAX_SCANNED_NODES && hints < MAX_HINTS && current) { visited += 1; if (current instanceof Element && current.matches(HINT_SELECTOR)) { append(boundedText(current)); hints += 1; } current = walker.nextNode(); } if (hints > 0) { break; } } return parts.join(" "); } 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 = Number.parseInt(rangeMatch[1]!, 10); policy.maxLength = Number.parseInt(rangeMatch[2]!, 10); } else if (minOnlyMatch) { policy.minLength = Number.parseInt(minOnlyMatch[1]!, 10); } else if (exactMatch) { policy.minLength = Number.parseInt(exactMatch[1]!, 10); policy.maxLength = Number.parseInt(exactMatch[1]!, 10); } } const text = extractNearbyText(passwordField); // "8-20 characters", "8–20 characters", "8 - 20 chars" — most common real-world format const dashRangeMatch = text.match(/(\d+)\s*[-–—]\s*(\d+)\s*char/i); // "between 8 and 20", "between 8-20" const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i); 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 exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i); // dash range takes priority as the most unambiguous format if (dashRangeMatch && !policy.minLength) policy.minLength = Number.parseInt(dashRangeMatch[1]!, 10); if (dashRangeMatch && !policy.maxLength) policy.maxLength = Number.parseInt(dashRangeMatch[2]!, 10); if (betweenMatch && !policy.minLength) policy.minLength = Number.parseInt(betweenMatch[1]!, 10); if (betweenMatch && !policy.maxLength) policy.maxLength = Number.parseInt(betweenMatch[2]!, 10); if (minMatch && !policy.minLength) policy.minLength = Number.parseInt(minMatch[1]!, 10); if (maxMatch && !policy.maxLength) policy.maxLength = Number.parseInt(maxMatch[1]!, 10); if (exactMatch2 && !policy.minLength) policy.minLength = Number.parseInt(exactMatch2[1]!, 10); if (exactMatch2 && !policy.maxLength) policy.maxLength = Number.parseInt(exactMatch2[1]!, 10); 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; // biome-ignore lint/suspicious/noAssignInExpressions: standard RegExp.exec loop idiom 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; }