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(
- !/