diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts
index 8cd958ac..009ae9e5 100644
--- a/artifacts/api-server/src/lib/sanitize.ts
+++ b/artifacts/api-server/src/lib/sanitize.ts
@@ -65,3 +65,29 @@ export function sanitizeRichTextOrNull(input: unknown): string | null {
const s = sanitizeRichText(input);
return s === "" ? null : s;
}
+
+/**
+ * Strip ALL HTML tags from a string and HTML-escape any stray `<`, `>`,
+ * `&`, or quote characters. Use for fields that the UI treats as plain
+ * text but that get interpolated into HTML by templates (e.g. the print
+ * page) — this prevents a payload like `` from
+ * surviving as executable markup if a future template forgets to escape.
+ *
+ * Empty / null / undefined input becomes "".
+ */
+export function sanitizePlainText(input: unknown): string {
+ if (input === null || input === undefined) return "";
+ const s = typeof input === "string" ? input : String(input);
+ if (s.length === 0) return "";
+ return sanitizeHtml(s, { allowedTags: [], allowedAttributes: {} });
+}
+
+/**
+ * Same as {@link sanitizePlainText} but preserves null for fields that
+ * are nullable in the database (e.g. attendee.title).
+ */
+export function sanitizePlainTextOrNull(input: unknown): string | null {
+ if (input === null || input === undefined) return null;
+ const s = sanitizePlainText(input);
+ return s === "" ? null : s;
+}
diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts
index 9598a31a..2a869c76 100644
--- a/artifacts/api-server/src/routes/executive-meetings.ts
+++ b/artifacts/api-server/src/routes/executive-meetings.ts
@@ -32,7 +32,7 @@ import {
requireExecutiveAccess,
getEffectiveRoleIds,
} from "../middlewares/auth";
-import { sanitizeRichText } from "../lib/sanitize";
+import { sanitizePlainTextOrNull, sanitizeRichText } from "../lib/sanitize";
import { emitExecutiveMeetingsDayChanged } from "../lib/realtime";
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
import { ObjectStorageService } from "../lib/objectStorage";
@@ -668,7 +668,10 @@ router.post(
attendees.map((a, idx) => ({
meetingId: meeting.id,
name: sanitizeRichText(a.name),
- title: a.title ?? null,
+ // title is plain text in the UI, but it gets interpolated
+ // into HTML by templates like the print page, so strip
+ // any sneaky markup at the API boundary.
+ title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
@@ -786,7 +789,7 @@ router.patch(
data.attendees.map((a, idx) => ({
meetingId: id,
name: sanitizeRichText(a.name),
- title: a.title ?? null,
+ title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
@@ -885,7 +888,7 @@ router.put(
data.attendees.map((a, idx) => ({
meetingId: id,
name: sanitizeRichText(a.name),
- title: a.title ?? null,
+ title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
@@ -959,7 +962,7 @@ router.post(
// re-running keeps a defense-in-depth boundary if anything was
// imported via raw SQL.
name: sanitizeRichText(a.name),
- title: a.title,
+ title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
@@ -1197,7 +1200,12 @@ async function applyApprovedRequest(
// the rich-text attendee.name column.
const name = sanitizeRichText(rawName.slice(0, 5000));
if (!name) break;
- const title = typeof details.title === "string" ? details.title.slice(0, 200) : null;
+ // Same plain-text sanitization as the direct attendee write paths
+ // so an approved request cannot smuggle CFO';
+ // POST creates the meeting with a malicious title on one attendee.
+ const create = await api(adminCookie, "POST", "/api/executive-meetings", {
+ titleAr: "تنظيف العنوان",
+ titleEn: "Title sanitization",
+ meetingDate: today,
+ attendees: [
+ {
+ name: "Attendee POST",
+ title: malicious,
+ attendanceType: "internal",
+ sortOrder: 0,
+ },
+ ],
+ });
+ assert.equal(create.status, 201);
+ const meeting = await create.json();
+ created.meetingIds.push(meeting.id);
+
+ const postedTitle = meeting.attendees[0].title;
+ assert.ok(postedTitle, "title must round-trip");
+ assert.ok(!/