From 49ff7f91b416b993721674b608df0102227e172a Mon Sep 17 00:00:00 2001 From: Yusuf Tomilola <52901501+yusuftomilola@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:06:57 +0100 Subject: [PATCH 1/6] fix(xss): add DOMPurify sanitize utility with strict allowlist --- frontend/lib/utils/sanitize.ts | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 frontend/lib/utils/sanitize.ts diff --git a/frontend/lib/utils/sanitize.ts b/frontend/lib/utils/sanitize.ts new file mode 100644 index 0000000..c0869c1 --- /dev/null +++ b/frontend/lib/utils/sanitize.ts @@ -0,0 +1,68 @@ +/** + * sanitize.ts + * + * Wraps DOMPurify with a strict allowlist. + * - No Hello') // → 'Hello' + * sanitize('Bold') // → 'Bold' + * sanitize('x') // → 'x' + */ +export function sanitize(input: string): string { + if (typeof window === "undefined") { + // Server-side: DOMPurify requires a DOM. Strip all tags as a safe fallback. + return input.replace(/<[^>]*>/g, ""); + } + + return DOMPurify.sanitize(input, { + ALLOWED_TAGS, + ALLOWED_ATTR, + // Block javascript: and data: URIs in any attribute value + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, + // Prevent DOM clobbering + SANITIZE_DOM: true, + }); +} + +/** + * Strip ALL HTML tags — use this when you only need plain text output. + * + * @example + * stripTags('Hello world') // → 'Hello world' + */ +export function stripTags(input: string): string { + if (typeof window === "undefined") { + return input.replace(/<[^>]*>/g, ""); + } + return DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }); +} \ No newline at end of file From d1e4efdfb884694253b05a288c9d7163c416203e Mon Sep 17 00:00:00 2001 From: Yusuf Tomilola <52901501+yusuftomilola@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:07:00 +0100 Subject: [PATCH 2/6] test(xss): add unit tests for sanitize() and stripTags() --- frontend/lib/utils/sanitize.test.ts | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 frontend/lib/utils/sanitize.test.ts diff --git a/frontend/lib/utils/sanitize.test.ts b/frontend/lib/utils/sanitize.test.ts new file mode 100644 index 0000000..893ef0f --- /dev/null +++ b/frontend/lib/utils/sanitize.test.ts @@ -0,0 +1,63 @@ +/** + * sanitize.test.ts + * + * Unit tests for the sanitize() and stripTags() utilities. + * Tests run in jsdom (Next.js / Jest default) which provides window + DOMPurify DOM. + */ + +import { sanitize, stripTags } from "./sanitize"; + +describe("sanitize()", () => { + it("passes plain text through unchanged", () => { + expect(sanitize("Hello, world!")).toBe("Hello, world!"); + }); + + it("strips Safe text"); + expect(result).not.toContain("">'); + expect(result).not.toContain("data:"); + }); + + it("allows safe inline formatting tags", () => { + const input = "Bold and italic and
"; + const result = sanitize(input); + expect(result).toContain("Bold"); + expect(result).toContain("italic"); + }); + + it("handles empty string without throwing", () => { + expect(sanitize("")).toBe(""); + }); + + it("handles deeply nested XSS attempt", () => { + const result = sanitize('

text

'); + expect(result).not.toContain("onmouseover"); + }); +}); + +describe("stripTags()", () => { + it("removes all HTML tags leaving plain text", () => { + expect(stripTags("Hello world")).toBe("Hello world"); + }); + + it("removes script tags", () => { + expect(stripTags("text")).not.toContain("