123456789import type { PasswordPolicy } from "../shared/types";1011const NEGATION_WINDOW = 220;12const MAX_POLICY_TEXT_CHARS = 4_096;13const MAX_POLICY_PART_CHARS = 1_024;14const MAX_DESCRIBED_IDS = 16;15const MAX_HINTS = 32;16const MAX_SCANNED_NODES = 256;171819202122const FORBIDDEN_CHAR_SET = new Set([23 "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*",24 "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'",25]);2627export function extractNearbyText(el: HTMLInputElement | null): string {28 if (!el) return "";29 const parts: string[] = [];30 let remaining = MAX_POLICY_TEXT_CHARS;31 const append = (text: string | null | undefined) => {32 const separator = parts.length > 0 ? 1 : 0;33 const length = Math.min(MAX_POLICY_PART_CHARS, remaining - separator);34 if (!text || length <= 0) return;35 parts.push(text.slice(0, length));36 remaining -= Math.min(text.length, length) + separator;37 };38 const boundedText = (root: Element): string => {39 const text: string[] = [];40 let chars = 0;41 let visited = 0;42 const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ALL);43 let current = walker.nextNode();44 while (visited < MAX_SCANNED_NODES && chars < MAX_POLICY_PART_CHARS && current) {45 visited += 1;46 if (current.nodeType === Node.TEXT_NODE && current.nodeValue) {47 const value = current.nodeValue.slice(0, MAX_POLICY_PART_CHARS - chars);48 text.push(value);49 chars += value.length;50 }51 current = walker.nextNode();52 }53 return text.join(" ");54 };5556 57 const describedBy = el.getAttribute("aria-describedby");58 if (describedBy) {59 for (const id of describedBy.trim().split(/\s+/).slice(0, MAX_DESCRIBED_IDS)) {60 const desc = el.ownerDocument.getElementById(id);61 if (desc) append(boundedText(desc));62 }63 }6465 66 append(el.title);67 append(el.placeholder);68 const ariaLabel = el.getAttribute("aria-label");69 append(ariaLabel);7071 const HINT_SELECTOR =72 "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint";7374 let node: Element | null = el;75 for (let i = 0; i < 5; i++) {76 node = node?.parentElement ?? null;77 if (!node) break;78 const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);79 let current = walker.nextNode();80 let visited = 0;81 let hints = 0;82 while (visited < MAX_SCANNED_NODES && hints < MAX_HINTS && current) {83 visited += 1;84 if (current instanceof Element && current.matches(HINT_SELECTOR)) {85 append(boundedText(current));86 hints += 1;87 }88 current = walker.nextNode();89 }90 if (hints > 0) {91 break;92 }93 }9495 return parts.join(" ");96}9798export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {99 const policy: PasswordPolicy = {};100101 const passwordField = doc.querySelector<HTMLInputElement>(102 'input[autocomplete="new-password"], input[type="password"]'103 );104 const maxLen = passwordField?.maxLength ?? 0;105 if (maxLen > 0) policy.maxLength = maxLen;106 if (passwordField?.pattern) {107 const pat = passwordField.pattern;108 109 const rangeMatch = pat.match(/\{(\d+),(\d+)\}/);110 const minOnlyMatch = pat.match(/\{(\d+),\}/);111 const exactMatch = pat.match(/\{(\d+)\}/);112 if (rangeMatch) {113 policy.minLength = Number.parseInt(rangeMatch[1]!, 10);114 policy.maxLength = Number.parseInt(rangeMatch[2]!, 10);115 } else if (minOnlyMatch) {116 policy.minLength = Number.parseInt(minOnlyMatch[1]!, 10);117 } else if (exactMatch) {118 policy.minLength = Number.parseInt(exactMatch[1]!, 10);119 policy.maxLength = Number.parseInt(exactMatch[1]!, 10);120 }121 }122123 const text = extractNearbyText(passwordField);124125 126 const dashRangeMatch = text.match(/(\d+)\s*[-–—]\s*(\d+)\s*char/i);127 128 const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i);129 const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i);130 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);131 const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);132 133 if (dashRangeMatch && !policy.minLength) policy.minLength = Number.parseInt(dashRangeMatch[1]!, 10);134 if (dashRangeMatch && !policy.maxLength) policy.maxLength = Number.parseInt(dashRangeMatch[2]!, 10);135 if (betweenMatch && !policy.minLength) policy.minLength = Number.parseInt(betweenMatch[1]!, 10);136 if (betweenMatch && !policy.maxLength) policy.maxLength = Number.parseInt(betweenMatch[2]!, 10);137 if (minMatch && !policy.minLength) policy.minLength = Number.parseInt(minMatch[1]!, 10);138 if (maxMatch && !policy.maxLength) policy.maxLength = Number.parseInt(maxMatch[1]!, 10);139 if (exactMatch2 && !policy.minLength) policy.minLength = Number.parseInt(exactMatch2[1]!, 10);140 if (exactMatch2 && !policy.maxLength) policy.maxLength = Number.parseInt(exactMatch2[1]!, 10);141142 if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;143 if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;144 if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;145 if (/number|digit/i.test(text)) policy.requiresNumbers = true;146147 148 149 150 151 152 153 154 const negation = text.match(155 /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i156 );157 if (negation) {158 const window = negation[0].slice(0, NEGATION_WINDOW);159 const seen = new Set<string>();160 const tokenRegex = /(?:^|[\s`"',.([])([!-/:-@[-`{-~])(?=[\s`"',.)\]]|$)/g;161 let m: RegExpExecArray | null;162 163 while ((m = tokenRegex.exec(window)) !== null) {164 const ch = m[1]!;165 if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);166 }167 if (seen.size > 0) policy.forbiddenChars = [...seen].join("");168 }169170 return policy;171}