/** * HTML sanitization utilities — CodeQL-safe, ZERO regex. * * Previous versions used multi-character regex patterns for stripping, which * CodeQL flagged as "Incomplete multi-character sanitization" (alerts #16-#20). * The recommended fix is to either: * (a) rewrite the regex to match single characters, OR * (b) use a well-tested sanitization library. * * This module takes approach (a) to the extreme: it uses NO regex at all. * All scanning is done via manual character-by-character iteration with * `String.prototype.indexOf` / `startsWith` for exact substring matching. * This eliminates the entire class of regex-bypass vulnerabilities. * * The stripper: * 1. Scans the input character by character * 2. Drops HTML comments () entirely * 3. Unwraps CDATA sections () — inner content preserved * 4. Drops — returns position after closing tag. */ function skipBlockTag(input: string, i: number, closeTag: string): { output: string; nextIndex: number } { const tagEnd = input.indexOf('>', i); if (tagEnd === -1) return { output: '', nextIndex: input.length }; const closeIdx = indexOfIgnoreCase(input, closeTag, tagEnd + 1); if (closeIdx === -1) return { output: '', nextIndex: input.length }; const afterClose = input.indexOf('>', closeIdx); return { output: '', nextIndex: afterClose === -1 ? input.length : afterClose + 1 }; } /** Process HTML entity (&...;) — decode or output literal '&'. */ function processEntity(input: string, i: number): { output: string; nextIndex: number } { const semi = input.indexOf(';', i); if (semi !== -1 && semi - i >= 2 && semi - i <= 33) { const entity = input.slice(i, semi + 1); const decoded = decodeEntity(entity); if (decoded !== null) { return { output: decoded, nextIndex: semi + 1 }; } } return { output: '&', nextIndex: i + 1 }; } /** * Collapse runs of ASCII whitespace (space, tab, CR, LF, FF, VT) into a single * space, then trim leading/trailing whitespace. Non-breaking spaces (U+00A0) * are preserved as intentional content. * * Manual implementation — no regex used. */ function collapseWhitespace(input: string): string { const wsChars = new Set([' ', '\t', '\r', '\n', '\f', '\v']); const len = input.length; let out = ''; let inWs = false; let start = 0; let end = input.length; // Find first non-ws char while (start < len && wsChars.has(input[start])) start++; // Find last non-ws char while (end > start && wsChars.has(input[end - 1])) end--; for (let i = start; i < end; i++) { const c = input[i]; if (wsChars.has(c)) { if (!inWs) { out += ' '; inWs = true; } // else: skip — we're in a whitespace run, only emit one space } else { out += c; inWs = false; } } return out; } // --------------------------------------------------------------------------- // Pure string helpers (no regex used for security-sensitive scanning) // --------------------------------------------------------------------------- function startsWith(str: string, prefix: string, position: number): boolean { return str.startsWith(prefix, position); } function startsWithIgnoreCase(str: string, prefix: string, position: number): boolean { if (position + prefix.length > str.length) return false; for (let j = 0; j < prefix.length; j++) { const a = str[position + j]; const b = prefix[j]; if (a === b) continue; if (a.toLowerCase() === b.toLowerCase()) continue; return false; } return true; } function indexOfIgnoreCase(str: string, needle: string, fromIndex: number): number { const lower = str.toLowerCase(); const lowerNeedle = needle.toLowerCase(); return lower.indexOf(lowerNeedle, fromIndex); } /** * Truncate text to `maxLen` characters, appending an ellipsis if truncated. * Counts visible characters (after entity decoding), not bytes. */ export function truncateText(text: string, maxLen: number): string { if (text.length <= maxLen) return text; if (maxLen <= 1) return text.slice(0, maxLen); return text.slice(0, maxLen - 1) + '…'; }