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