← back to Grant

lib/sanitize.ts

54 lines

/**
 * lib/sanitize.ts — proposal body_html allowlist sanitizer for Grant.
 *
 * 2026-05-06 (tick 48): extracted from app/api/grants/[id]/proposals/route.ts
 * to mirror Patty's lib/sanitize.ts pattern. Single source of truth for the
 * allowlist; future routes that store user-supplied HTML can import this.
 *
 * Sanitization happens at the storage boundary (POST/PATCH /api/grants/
 * [id]/proposals) so the database itself is the trust line. Render-side
 * does not currently dangerouslySetInnerHTML body_html anywhere — but if
 * that changes, the same `sanitizeProposalBody()` should be applied as
 * defense-in-depth (cheap, idempotent).
 */

import sanitizeHtmlLib, { type IOptions } from 'sanitize-html';

export const PROPOSAL_HTML_OPTS: IOptions = {
  // Tags suitable for grant-proposal body content (LOI / full / one-pager).
  // No <img>, no <iframe>, no <script>, no <style>.
  allowedTags: [
    'p', 'strong', 'em', 'b', 'i', 'u',
    'ul', 'ol', 'li', 'br',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'blockquote', 'a',
  ],
  allowedAttributes: { a: ['href', 'title', 'target', 'rel'] },
  allowedSchemes: ['http', 'https', 'mailto'],
  disallowedTagsMode: 'discard',
  // 2026-05-30 (audit P1-5): force rel="noopener noreferrer" on every anchor
  // so a target="_blank" link can never hand the opener a window reference
  // (tab-napping) if body_html is ever rendered via dangerouslySetInnerHTML.
  transformTags: {
    a: (tagName, attribs) => ({
      tagName,
      attribs: { ...attribs, rel: 'noopener noreferrer' },
    }),
  },
};

export function sanitizeProposalBody(html: string): string {
  if (typeof html !== 'string') return '';
  return sanitizeHtmlLib(html, PROPOSAL_HTML_OPTS);
}

/**
 * Hard size cap for body_html — 200 KB. See Patty's lib/sanitize.ts for
 * the same rationale (cost amplification + DoS guard).
 */
export const MAX_BODY_HTML_BYTES = 200_000;

export function bodyHtmlTooLarge(html: unknown): boolean {
  return typeof html !== 'string' || html.length > MAX_BODY_HTML_BYTES;
}