← 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;
}