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