← back to Patty
lib/sanitize.ts
67 lines
/**
* lib/sanitize.ts — petition body_html allowlist sanitizer.
*
* 2026-05-05 (architect-reviewer P1 + tick 27): replaces the handwritten
* regex-based sanitizeHtml() in app/petitions/[slug]/page.tsx (which only
* stripped <script>/<iframe>/on* and was bypassable via SVG, data: URIs,
* mathml, malformed nested tags, etc.).
*
* Sanitization happens at TWO boundaries:
* 1. POST/PATCH /api/petitions — sanitize before INSERT/UPDATE so the
* database itself is the trust boundary
* 2. Render — sanitize again as defense-in-depth (cheap, idempotent)
*
* Why a SHARED module: keeps the allowlist in one place. Drift between
* "what we accept" and "what we render" is exactly the gap that lets
* stored XSS slip through.
*/
import sanitizeHtmlLib, { type IOptions } from 'sanitize-html';
export const PETITION_HTML_OPTS: IOptions = {
// Tags suitable for petition body content. Includes basic typography,
// lists, headings, blockquote, links, and hr. NO <img> (would let users
// beacon out to attacker domains), NO <iframe>, NO <script>, NO <style>.
allowedTags: [
'p', 'br', 'hr',
'strong', 'em', 'b', 'i', 'u', 's', 'mark',
'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'pre', 'code',
'a',
],
allowedAttributes: {
a: ['href', 'title', 'target', 'rel'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesAppliedToAttributes: ['href'],
// Force every <a> to open externally with rel=noopener,noreferrer
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
target: '_blank',
rel: 'noopener noreferrer',
},
}),
},
disallowedTagsMode: 'discard',
};
export function sanitizePetitionBody(html: string): string {
if (typeof html !== 'string') return '';
return sanitizeHtmlLib(html, PETITION_HTML_OPTS);
}
/**
* Hard size cap for body_html — 200 KB is a generous limit for a petition
* (~50,000 words of plain text, far more for HTML overhead). Anything
* larger is suspicious (cost amplification, DoS via huge prompt).
*/
export const MAX_BODY_HTML_BYTES = 200_000;
export function bodyHtmlTooLarge(html: unknown): boolean {
return typeof html !== 'string' || html.length > MAX_BODY_HTML_BYTES;
}