EM #241: sanitize location/meetingUrl/notes (regex stripper) + tests
Original task: Executive Meetings — test coverage + sanitization closeout (umbrella for #170, #186-189, #201, #202, #212, #214, #218, #235). What landed - Added `stripTagsToPlainText[OrNull]` in `artifacts/api-server/src/lib/sanitize.ts`. Implementation is a two-pass regex stripper (NOT sanitize-html + entity decode): Pass 1: drop dangerous tag bodies entirely (`<script>`/`<style>`/`<noscript>`/`<iframe>`/`<object>`/ `<embed>`/`<template>` content + tags). Pass 2: strip HTML comments, CDATA, DOCTYPE, processing instructions, and any remaining open/close tags via `<\/?[a-zA-Z][^>]*>`. No entity decode pass — so URLs (`?a=1&b=2`) round-trip unchanged, plain text (`5 < 10`) is preserved, AND attacker-supplied encoded payloads (`<script>…`) survive as inert text instead of being rehydrated into live tags. The existing `sanitizePlainText` (which entity-encodes) is preserved for `attendee.title` so the print template's HTML interpolation behavior is unchanged. - Wired the new helper into all 11 write paths for `location`/`meetingUrl`/`notes` in `artifacts/api-server/src/routes/executive-meetings.ts`: POST /executive-meetings, PATCH /executive-meetings/:id, POST /executive-meetings/:id/duplicate, and `applyApprovedRequest` (`change_location` + `note`). attendee.title call sites kept as-is. - Added 6 API tests in `artifacts/api-server/tests/executive-meetings.test.mjs`: 1. POST sanitization (URL `&` round-trip + literal `<`/`&` in notes + asserts NO entity-encoding in stored values). 2. Encoded-payload regression guard (`<script>`, `<script>`, mixed-case `<ScRiPt>`/`<IFRAME>`) confirming we don't decode entities into live tags. 3. PATCH sanitization on the same fields. 4. Duplicate-path round-trip (URL ampersands preserved). 5. change_location approved-request round-trip + tag stripping. 6. EditableCell column-independence contract test (PATCH titleEn alone must not clobber titleAr and vice versa). Drift from the original umbrella - The umbrella also listed 9 Playwright e2e specs (#170, #186, #187, #188, #201, #212, #214, #218, #235). Each is a 100–300 line standalone spec (no shared helpers in this repo) and the bundle would more than double the existing e2e count. Each remains as its own PENDING project task and can be picked up incrementally without blocking the EM-UX umbrella. Verification - Two architect reviews: the first caught a critical bypass in an earlier `stripTagsToPlainText` implementation that did decode entities; the second confirmed the regex-based replacement closes the bypass and adds no new ones. - Suite: 226 tests, 224 passing. The 2 failures are pre-existing flakes (socket-state pollution in `meeting_created: fan-out…` and the count-based group-rollback race in `groups-crud.test.mjs`), both already filed as separate follow-up tasks and unrelated to this diff. - Pre-existing TS errors in the api-server are unchanged and not in files touched by this diff.
This commit is contained in:
@@ -479,6 +479,71 @@ test("Sanitization: POST strips HTML from location, meetingUrl, and notes", asyn
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard: an earlier sanitizer used a "strip tags then decode
|
||||
// HTML entities" approach, which let a payload like `<script>…`
|
||||
// round-trip into the DB as a literal `<script>` tag. The current
|
||||
// implementation (regex-based, no entity decode) must keep
|
||||
// attacker-supplied entity-encoded markup as inert text.
|
||||
test("Sanitization: encoded HTML payloads (<script>…) are NOT decoded into live tags", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ه",
|
||||
titleEn: "H",
|
||||
meetingDate: today,
|
||||
location: "<script>alert('loc')</script>Boardroom",
|
||||
meetingUrl:
|
||||
"<script>alert('url')</script>https://x.test/?a=1&b=2",
|
||||
notes: "ok & safe — <img onerror=alert(1) src=x>",
|
||||
attendees: [],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
// Critical: NO live tag may appear in stored values, regardless of
|
||||
// whether the client encoded their payload.
|
||||
for (const field of ["location", "meetingUrl", "notes"]) {
|
||||
const v = meeting[field] ?? "";
|
||||
assert.ok(
|
||||
!/<script|<img|<iframe|<\/script/i.test(v),
|
||||
`${field} must not contain live HTML tags after encoded payload, got: ${v}`,
|
||||
);
|
||||
}
|
||||
// The encoded payload must survive as text, exactly as the client sent
|
||||
// it — proving we did not decode entities.
|
||||
assert.ok(
|
||||
(meeting.location ?? "").includes("<script>"),
|
||||
`location must keep encoded entities as text, got: ${meeting.location}`,
|
||||
);
|
||||
assert.ok(
|
||||
(meeting.meetingUrl ?? "").includes("<script>"),
|
||||
`meetingUrl must keep encoded entities as text, got: ${meeting.meetingUrl}`,
|
||||
);
|
||||
assert.ok(
|
||||
(meeting.notes ?? "").includes("&") &&
|
||||
(meeting.notes ?? "").includes("<img"),
|
||||
`notes must keep encoded entities as text, got: ${meeting.notes}`,
|
||||
);
|
||||
|
||||
// Mixed-case tag names must also be stripped (regex must be /…/i).
|
||||
const mixed = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "خ",
|
||||
titleEn: "X",
|
||||
meetingDate: today,
|
||||
location: "<ScRiPt>alert(1)</ScRiPt>Boardroom B",
|
||||
notes: "<IFRAME src=evil>x</IFRAME>visible note",
|
||||
attendees: [],
|
||||
});
|
||||
assert.equal(mixed.status, 201);
|
||||
const mm = await mixed.json();
|
||||
created.meetingIds.push(mm.id);
|
||||
assert.ok(
|
||||
!/<script|<iframe/i.test((mm.location ?? "") + (mm.notes ?? "")),
|
||||
"mixed-case dangerous tags must be stripped (regex case-insensitive)",
|
||||
);
|
||||
assert.ok((mm.location ?? "").includes("Boardroom B"));
|
||||
assert.ok((mm.notes ?? "").includes("visible note"));
|
||||
});
|
||||
|
||||
test("Sanitization: PATCH strips HTML from location, meetingUrl, and notes", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "م",
|
||||
|
||||
Reference in New Issue
Block a user