diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts index 422b97c6..ddc859f5 100644 --- a/artifacts/api-server/src/lib/sanitize.ts +++ b/artifacts/api-server/src/lib/sanitize.ts @@ -92,55 +92,26 @@ export function sanitizePlainTextOrNull(input: unknown): string | null { return s === "" ? null : s; } -// Tag bodies that must be discarded along with the tag itself — script -// contents, CSS, embedded objects, etc. would otherwise leak into the -// stored "plain text" as readable source code. const DANGEROUS_TAG_BODY_RE = /<(script|style|noscript|iframe|object|embed|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi; const HTML_COMMENT_RE = //g; const HTML_CDATA_RE = //g; const HTML_DOCTYPE_RE = /]*>/gi; const HTML_PROCINST_RE = /<\?[\s\S]*?\?>/g; -// Matches any opening or closing HTML tag (``, ``). A bare -// `<` followed by whitespace, a digit, or another `<` does NOT match — -// so legitimate text like "5 < 10" is preserved. const HTML_TAG_RE = /<\/?[a-zA-Z][^>]*>/g; /** - * Strip ALL HTML tags but PRESERVE stray special characters (`&`, `<`, - * `>`, quotes) as their literal plain-text form. Use for free-form - * fields that are NOT interpolated into HTML — URLs, location strings, - * and note bodies — where entity-encoding would corrupt valid input - * (e.g. `?a=1&b=2` → `?a=1&b=2`). - * - * Implementation note: this is intentionally a regex-based stripper - * (NOT sanitize-html + entity decode). sanitize-html unconditionally - * HTML-escapes its output, so the only way to reverse that is to - * decode entities — but a decode pass also turns attacker-supplied - * `<script>` into a literal `` → "" (tag AND inner JS body dropped) - * - `` / `` → "" (body dropped) - * - `bold hi` → "bold hi" (inline tag dropped, text kept) - * - `<script>…</script>` → unchanged (no entity decode) - * - `https://x.test?a=1&b=2#frag` → unchanged - * - `5 < 10 and 20 > 5` → unchanged - * - * Empty / null / undefined input becomes "". + * Strip HTML tags but preserve stray `&`/`<`/`>`/quote characters + * literally (no entity encoding). Use for URLs and free-form text + * where entity-encoding would corrupt valid input (e.g. `?a=1&b=2`). + * Entities in the input are NOT decoded, so `<script>` stays + * inert. Empty/null/undefined → "". */ export function stripTagsToPlainText(input: unknown): string { if (input === null || input === undefined) return ""; const s = typeof input === "string" ? input : String(input); if (s.length === 0) return ""; - // Pass 1: drop tags whose body is not human-readable text along with - // their content, so e.g. `` doesn't leak the - // literal string `alert(1)` into the DB. let out = s.replace(DANGEROUS_TAG_BODY_RE, ""); - // Pass 2: strip remaining HTML constructs (comments, CDATA, DOCTYPE, - // processing instructions, and any leftover open/close tags). out = out .replace(HTML_COMMENT_RE, "") .replace(HTML_CDATA_RE, "") @@ -150,10 +121,6 @@ export function stripTagsToPlainText(input: unknown): string { return out; } -/** - * Same as {@link stripTagsToPlainText} but preserves null for nullable - * DB columns (location, meetingUrl, notes). - */ export function stripTagsToPlainTextOrNull(input: unknown): string | null { if (input === null || input === undefined) return null; const s = stripTagsToPlainText(input); diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 92c01be1..abea9450 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -418,23 +418,15 @@ test("Meetings: POST /duplicate clones a meeting onto another date", async () => assert.ok(dayBody.meetings.some((m) => m.id === newRow.id)); }); -// --- Plain-text sanitization for location / meetingUrl / notes (#189) ---- -// Mirrors the existing attendee-title sanitization so a meeting payload -// cannot smuggle Conference Room A', - // URL with query params + fragment + ampersand — must round-trip - // unchanged after tag removal so `meet?a=1&b=2` doesn't get stored - // as `meet?a=1&b=2`. meetingUrl: 'https://example.com/meet?a=1&b=2#room', - // Notes with stray `<` (math-style) plus a real script tag — the - // script must be stripped, the `5 < 10` text must survive intact. notes: 'bold note: 5 < 10 & ok ', attendees: [], }); @@ -442,48 +434,17 @@ test("Sanitization: POST strips HTML from location, meetingUrl, and notes", asyn const meeting = await create.json(); created.meetingIds.push(meeting.id); - assert.ok( - !/