/** * TASK-0107 §4 — the form-intro markdown sanitizer. * * Spec: allow `p / strong / em / ul / ol / a[href^=http] / br` — NO * scripts, NO iframes, NO attribute injection. No shared sanitizer exists * in the repo (checked `packages/editor` + `packages/ui` first — the * editor's `clipboard.ts` is block conversion, not an HTML sanitizer; the * decision is recorded in the Phase-10 task file per AGENTS rule 29). * * Strategy: (1) HTML-escape the ENTIRE input first (every `<` becomes * `<` — no raw tag can ever reach the output), (2) parse the escaped * text into a strict block grammar (paragraph / ul / ol), (3) apply a * whitelist of inline transforms (strong, em, http(s)-only links). The * only HTML the function can emit is the literal strings it inserts. */ const MAX_INPUT = 10_000; // defense in depth (config schema caps at 2000) function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** http(s) URLs only — the single allowed attribute value in the output. */ function safeUrl(raw: string): string | null { const trimmed = raw.trim(); if (!/^(https?:\/\/)/i.test(trimmed)) return null; // Control chars / whitespace are already impossible (escaped input, // and `\s` is excluded by the link-capture regex) — this is belt & braces. if (/[\u0000-\u001f]/.test(trimmed)) return null; return trimmed; } /** Inline transforms over ALREADY-ESCAPED text. Order: bold → em → links. */ function inline(s: string): string { let out = s; // **bold** / __bold__ out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); out = out.replace(/__([^_]+)__/g, "$1"); // *em* / _em_ (single, not part of ** — the bold pass already consumed those) out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); out = out.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2"); // [label](url) — http(s) only; anything else is left as literal text. out = out.replace(/\[([^\]]+)\]\(([^()\s]+)\)/g, (_m, label: string, url: string) => { const safe = safeUrl(url); if (!safe) return `${label}(${url})`; return `${label}`; }); return out; } /** * Render a form-intro markdown string to the ALLOWED HTML subset. * Never throws (bad input degrades to escaped plain text). */ export function renderIntroMarkdown(md: string): string { if (typeof md !== "string" || md.length === 0) return ""; const source = escapeHtml(md.slice(0, MAX_INPUT)); const lines = source.split(/\r?\n/); const blocks: string[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.trim() === "") { i += 1; continue; } const ul = /^\s*[-*]\s+/.test(line); const ol = /^\s*\d+[.)]\s+/.test(line); if (ul || ol) { const items: string[] = []; const re = ul ? /^\s*[-*]\s+(.*)$/ : /^\s*\d+[.)]\s+(.*)$/; while (i < lines.length) { const m = lines[i].match(re); if (!m) break; items.push(`
${para.join("
")}