From 55f527a7a33c49cacfc514296b722e2d68d772af Mon Sep 17 00:00:00 2001 From: jadonamite Date: Wed, 24 Jun 2026 21:32:34 +0100 Subject: [PATCH] feat(notification): XSS protection for rich-text template fields (#611) Sanitize rich-text email/notification templates server-side so stored HTML can never carry executable markup, with CSP defence-in-depth on the admin dashboard. - backend/shared/sanitizer: HTMLSanitizerService wrapping DOMPurify (via jsdom) with a strict tag/attribute allowlist, http/https/mailto-only href protocols, full SVG/script/iframe stripping, and forced rel=noreferrer on links. - backend/shared/middleware/cspMiddleware.ts: hardened CSP (script-src 'self', object-src 'none') plus companion security headers, with Express and Next.js adapters. - backend/services/notification/templateService.ts: sanitize body on save, strip HTML from subjects, and render a sanitized preview before save. - backend/services/notification/jobs/backScanTemplates.ts: idempotent cron that quarantines and re-sanitizes pre-existing unsafe templates. - developer-portal/utils/securityHeaders.ts: applies the shared CSP to admin dashboard routes. - package.json: add dompurify, jsdom, @types/jsdom. Closes #611 --- .../notification/jobs/backScanTemplates.ts | 80 ++++++++++ .../services/notification/templateService.ts | 82 ++++++++++ backend/shared/middleware/cspMiddleware.ts | 85 +++++++++++ backend/shared/sanitizer/htmlSanitizer.ts | 141 ++++++++++++++++++ backend/shared/sanitizer/index.ts | 11 ++ developer-portal/utils/securityHeaders.ts | 44 ++++++ package.json | 3 + 7 files changed, 446 insertions(+) create mode 100644 backend/services/notification/jobs/backScanTemplates.ts create mode 100644 backend/services/notification/templateService.ts create mode 100644 backend/shared/middleware/cspMiddleware.ts create mode 100644 backend/shared/sanitizer/htmlSanitizer.ts create mode 100644 backend/shared/sanitizer/index.ts create mode 100644 developer-portal/utils/securityHeaders.ts diff --git a/backend/services/notification/jobs/backScanTemplates.ts b/backend/services/notification/jobs/backScanTemplates.ts new file mode 100644 index 00000000..81f587f3 --- /dev/null +++ b/backend/services/notification/jobs/backScanTemplates.ts @@ -0,0 +1,80 @@ +/** + * Back-scan job for pre-existing notification templates. + * + * Issue #611 edge case: templates saved before server-side sanitization was + * introduced may already contain malicious HTML. This cron scans every stored + * template, and for any whose body changes under sanitization it: + * 1. quarantines the original (flags it so it is not sent/previewed raw), and + * 2. stores the sanitized body so the template remains usable. + * + * The job is idempotent — clean templates are left untouched and re-running it + * is a no-op. + */ + +import { htmlSanitizer } from '../../../shared/sanitizer'; +import type { + NotificationTemplate, + TemplateRepository, +} from '../templateService'; + +export interface BackScanResult { + scanned: number; + quarantined: number; + /** IDs of templates that contained unsafe content. */ + quarantinedIds: string[]; +} + +export interface BackScanOptions { + /** When true, report findings without writing changes. Default: false. */ + dryRun?: boolean; +} + +export class BackScanTemplatesJob { + constructor(private readonly repo: TemplateRepository) {} + + async run(options: BackScanOptions = {}): Promise { + const templates = await this.repo.list(); + const result: BackScanResult = { scanned: 0, quarantined: 0, quarantinedIds: [] }; + + for (const template of templates) { + result.scanned += 1; + const { clean, modified } = await htmlSanitizer.sanitize(template.body); + if (!modified) continue; + + result.quarantined += 1; + result.quarantinedIds.push(template.id); + + console.warn( + JSON.stringify({ + level: 'warn', + event: 'template_quarantined', + templateId: template.id, + message: 'Pre-existing template contained unsafe HTML; sanitized and quarantined', + }), + ); + + if (!options.dryRun) { + const sanitized: NotificationTemplate = { + ...template, + body: clean, + quarantined: true, + updatedAt: new Date().toISOString(), + }; + await this.repo.save(sanitized); + } + } + + return result; + } +} + +/** + * Cron entry point. Wire this to the scheduler (e.g. daily) with a concrete + * repository implementation. + */ +export async function runBackScanTemplates( + repo: TemplateRepository, + options?: BackScanOptions, +): Promise { + return new BackScanTemplatesJob(repo).run(options); +} diff --git a/backend/services/notification/templateService.ts b/backend/services/notification/templateService.ts new file mode 100644 index 00000000..04841899 --- /dev/null +++ b/backend/services/notification/templateService.ts @@ -0,0 +1,82 @@ +/** + * Notification/email template service with XSS-safe rich-text handling. + * + * Issue #611: rich-text template fields (subject is plain text; body is HTML) + * must be sanitized server-side on save so stored content can never carry + * executable markup. A preview renders the *sanitized* output so admins see + * exactly what recipients will get. + */ + +import { htmlSanitizer, type SanitizeResult } from '../../shared/sanitizer'; + +export interface NotificationTemplate { + id: string; + /** Plain-text subject line (no HTML). */ + subject: string; + /** Rich-text HTML body. Always stored already-sanitized. */ + body: string; + updatedAt: string; + /** Set by the back-scan job when pre-existing content was unsafe. */ + quarantined?: boolean; +} + +export interface TemplateInput { + id: string; + subject: string; + body: string; +} + +/** Storage abstraction — implemented by the persistence layer. */ +export interface TemplateRepository { + save(template: NotificationTemplate): Promise; + get(id: string): Promise; + list(): Promise; +} + +export interface SaveTemplateResult { + template: NotificationTemplate; + /** True when the submitted body contained content that was stripped. */ + sanitized: boolean; + removedCount: number; +} + +export class TemplateService { + constructor(private readonly repo: TemplateRepository) {} + + /** Strip HTML from the subject line entirely — subjects are plain text. */ + private async cleanSubject(subject: string): Promise { + const { clean } = await htmlSanitizer.sanitize(subject); + // Drop any residual tags: a subject should contain no markup at all. + return clean.replace(/<[^>]*>/g, '').trim(); + } + + /** + * Sanitize and persist a template. The stored body is always the sanitized + * output, so a malicious payload can never reach another admin's browser. + */ + async saveTemplate(input: TemplateInput): Promise { + const result: SanitizeResult = await htmlSanitizer.sanitize(input.body); + const template: NotificationTemplate = { + id: input.id, + subject: await this.cleanSubject(input.subject), + body: result.clean, + updatedAt: new Date().toISOString(), + }; + await this.repo.save(template); + return { + template, + sanitized: result.modified, + removedCount: result.removedCount, + }; + } + + /** + * Render a sanitized preview without persisting. Used by the preview pane so + * admins review the safe output before saving. + */ + async renderPreview(input: TemplateInput): Promise<{ subject: string; body: string; sanitized: boolean }> { + const subject = await this.cleanSubject(input.subject); + const { clean, modified } = await htmlSanitizer.sanitize(input.body); + return { subject, body: clean, sanitized: modified }; + } +} diff --git a/backend/shared/middleware/cspMiddleware.ts b/backend/shared/middleware/cspMiddleware.ts new file mode 100644 index 00000000..8f104c96 --- /dev/null +++ b/backend/shared/middleware/cspMiddleware.ts @@ -0,0 +1,85 @@ +/** + * Content-Security-Policy middleware for the admin dashboard. + * + * Issue #611: even with stored rich-text sanitized, the admin dashboard needs a + * CSP as defence-in-depth so any HTML that slips through cannot execute inline + * scripts or load hostile objects. `script-src 'self'` blocks inline/injected + * scripts; `object-src 'none'` blocks //Flash vectors. + * + * Framework-agnostic: `buildCspHeader()` returns the header value, and small + * adapters are provided for Express-style and Next.js responses. + */ + +export interface CspOptions { + /** Extra hosts to allow for images (e.g. a CDN). Default: none beyond self/data/https. */ + imgSrc?: string[]; + /** Extra hosts to allow for XHR/fetch (e.g. the API origin). */ + connectSrc?: string[]; + /** Report-only mode emits the header without enforcing it. Default: false. */ + reportOnly?: boolean; + /** Optional reporting endpoint. */ + reportUri?: string; +} + +/** The hardened CSP directive set for admin pages. */ +export function buildCspHeader(options: CspOptions = {}): { name: string; value: string } { + const directives: Record = { + 'default-src': ["'self'"], + 'script-src': ["'self'"], + 'object-src': ["'none'"], + 'base-uri': ["'self'"], + 'frame-ancestors': ["'self'"], + 'form-action': ["'self'"], + // Allow inline styles (rich-text uses the style attribute) but no inline JS. + 'style-src': ["'self'", "'unsafe-inline'"], + 'img-src': ["'self'", 'data:', 'https:', ...(options.imgSrc ?? [])], + 'connect-src': ["'self'", ...(options.connectSrc ?? [])], + 'font-src': ["'self'", 'data:'], + }; + + if (options.reportUri) { + directives['report-uri'] = [options.reportUri]; + } + + const value = Object.entries(directives) + .map(([key, vals]) => `${key} ${vals.join(' ')}`) + .join('; '); + + return { + name: options.reportOnly + ? 'Content-Security-Policy-Report-Only' + : 'Content-Security-Policy', + value, + }; +} + +/** Companion security headers that pair with the CSP. */ +export function securityHeaders(options: CspOptions = {}): Record { + const csp = buildCspHeader(options); + return { + [csp.name]: csp.value, + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + }; +} + +/** Express/Connect-style middleware. */ +export function cspMiddleware(options: CspOptions = {}) { + const headers = securityHeaders(options); + return function applyCsp( + _req: unknown, + res: { setHeader(name: string, value: string): void }, + next: () => void, + ): void { + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } + next(); + }; +} + +/** Next.js `headers()` config entries for admin dashboard routes. */ +export function nextSecurityHeaders(options: CspOptions = {}): Array<{ key: string; value: string }> { + return Object.entries(securityHeaders(options)).map(([key, value]) => ({ key, value })); +} diff --git a/backend/shared/sanitizer/htmlSanitizer.ts b/backend/shared/sanitizer/htmlSanitizer.ts new file mode 100644 index 00000000..5d930bee --- /dev/null +++ b/backend/shared/sanitizer/htmlSanitizer.ts @@ -0,0 +1,141 @@ +/** + * Server-side HTML sanitization for rich-text template fields. + * + * Issue #611: Email and notification templates accept rich text containing + * HTML. Without sanitization these fields are stored XSS vectors — a malicious + * admin can inject `