Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions backend/services/notification/jobs/backScanTemplates.ts
Original file line number Diff line number Diff line change
@@ -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<BackScanResult> {
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<BackScanResult> {
return new BackScanTemplatesJob(repo).run(options);
}
82 changes: 82 additions & 0 deletions backend/services/notification/templateService.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
get(id: string): Promise<NotificationTemplate | null>;
list(): Promise<NotificationTemplate[]>;
}

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<string> {
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<SaveTemplateResult> {
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 };
}
}
85 changes: 85 additions & 0 deletions backend/shared/middleware/cspMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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 <object>/<embed>/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<string, string[]> = {
'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<string, string> {
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 }));
}
141 changes: 141 additions & 0 deletions backend/shared/sanitizer/htmlSanitizer.ts
Original file line number Diff line number Diff line change
@@ -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 `<script>` (or `onerror`, `javascript:` URLs, hostile SVGs)
* that execute in other admins' browsers when a template is previewed or sent.
*
* This service wraps DOMPurify (running in a jsdom window so it works in Node)
* and enforces a strict allowlist:
* - Tags: p span a img table tr td th h1-h6 ul ol li strong em br hr
* - Attributes: href src alt style class target (rel=noreferrer forced)
* - Protocols: href only http:/https:/mailto: (javascript:/data:/vbscript: rejected)
* - SVG: all SVG tags/attributes stripped (common XSS vector)
*
* `dompurify` and `jsdom` are backend-only dependencies, dynamically imported
* so this module never lands in the React Native bundle and so environments
* without them fail loudly only when sanitization is actually invoked.
*/

// ── Allowlists ────────────────────────────────────────────────────────────────

export const ALLOWED_TAGS = [
'p', 'span', 'a', 'img', 'table', 'tr', 'td', 'th',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'strong', 'em', 'br', 'hr',
] as const;

export const ALLOWED_ATTR = ['href', 'src', 'alt', 'style', 'class', 'target'] as const;

/** Protocols permitted in `href`. Everything else (javascript:, data:, vbscript:) is rejected. */
export const ALLOWED_URI_PROTOCOLS = ['http', 'https', 'mailto'] as const;

// DOMPurify URI regexp: allow only the protocols above, plus relative/anchor URIs.
const ALLOWED_URI_REGEXP = /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;

export interface SanitizeResult {
/** The sanitized, safe-to-store HTML. */
clean: string;
/** True when sanitization removed or altered content (i.e. input was unsafe). */
modified: boolean;
/** Number of nodes/attributes DOMPurify removed. */
removedCount: number;
}

// ── DOMPurify factory (lazy, memoised) ────────────────────────────────────────

interface DOMPurifyInstance {
sanitize(dirty: string, config: Record<string, unknown>): string;
addHook(entry: string, cb: (node: unknown) => void): void;
removed: unknown[];
}

let _purify: DOMPurifyInstance | null = null;

async function getPurify(): Promise<DOMPurifyInstance> {
if (_purify) return _purify;

const { JSDOM } = (await import('jsdom')) as {
JSDOM: new (html: string) => { window: unknown };
};
const createDOMPurify = (await import('dompurify')).default as (
win: unknown,
) => DOMPurifyInstance;

const { window } = new JSDOM('');
const purify = createDOMPurify(window);

// Force rel="noreferrer noopener" on every link and constrain target.
purify.addHook('afterSanitizeAttributes', (node: unknown) => {
const el = node as {
tagName?: string;
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
};
if (el.tagName === 'A' && el.getAttribute('target')) {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noreferrer noopener');
}
});

_purify = purify;
return purify;
}

// ── Service ───────────────────────────────────────────────────────────────────

export class HTMLSanitizerService {
/**
* Sanitize a rich-text HTML string against the template allowlist.
* Returns the clean HTML plus whether anything was stripped.
*/
async sanitize(dirty: string): Promise<SanitizeResult> {
if (dirty == null || dirty === '') {
return { clean: '', modified: false, removedCount: 0 };
}

const purify = await getPurify();

const clean = purify.sanitize(dirty, {
ALLOWED_TAGS: [...ALLOWED_TAGS],
ALLOWED_ATTR: [...ALLOWED_ATTR],
ALLOWED_URI_REGEXP,
// Strip SVG and MathML entirely — both are common XSS vectors.
FORBID_TAGS: ['svg', 'math', 'script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'srcset', 'formaction'],
USE_PROFILES: { html: true },
// Keep text content of removed elements, but never their markup.
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
});

const removedCount = Array.isArray(purify.removed) ? purify.removed.length : 0;
// Normalise whitespace differences so a no-op sanitize isn't flagged as modified.
const modified = removedCount > 0 || normalize(clean) !== normalize(dirty);

return { clean, modified, removedCount };
}

/**
* Convenience: returns only the clean HTML.
*/
async clean(dirty: string): Promise<string> {
return (await this.sanitize(dirty)).clean;
}

/**
* Returns true if the input contains content that would be stripped — useful
* for the back-scan job's quarantine decision without mutating storage.
*/
async isUnsafe(html: string): Promise<boolean> {
return (await this.sanitize(html)).modified;
}
}

function normalize(html: string): string {
return html.replace(/\s+/g, ' ').trim();
}

/** Shared singleton instance. */
export const htmlSanitizer = new HTMLSanitizerService();
Loading