From d61ead16392ae79dd98902f611586025db32ceb8 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Thu, 30 Apr 2026 05:56:23 +0000 Subject: [PATCH] Sanitize attendee titles at the API boundary (task #130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: attendee.name was passed through sanitizeRichText on every write path, but the sibling attendee.title was treated as plain text and inserted verbatim. The print page and any future HTML template that interpolates a.title would have to remember to escape it. Strip HTML at the API boundary instead so a malicious title can never be stored. Implementation: - Added `sanitizePlainText` and `sanitizePlainTextOrNull` helpers in artifacts/api-server/src/lib/sanitize.ts. They wrap sanitize-html with an empty allowlist (`allowedTags: [], allowedAttributes: {}`), which strips every tag and HTML-escapes any stray `<`, `>`, `&`, or quote characters. The OrNull variant preserves null for nullable columns. - Applied `sanitizePlainTextOrNull(a.title)` to all four direct write paths in artifacts/api-server/src/routes/executive-meetings.ts: * POST /executive-meetings * PATCH /executive-meetings/:id (attendees branch) * PUT /executive-meetings/:id/attendees * POST /executive-meetings/:id/duplicate - Also patched the `add_attendee` apply branch (line ~1200) so an approved request cannot smuggle ` 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(!/