feat(deps): isomorphic-dompurify for send-document preview hardening

Defense-in-depth XSS guard at the client-side preview boundary.
`renderEmailBody()` already escapes-then-allowlists on the server, but
mounting that output via dangerouslySetInnerHTML still exposes a single
point of failure: a server-side regression in the sanitizer would
silently produce a client-side XSS via the preview surface.

DOMPurify sanitizes one more time before injection, with the exact
allow-list `renderEmailBody` produces: <p>, <br>, <strong>, <em>,
<code>, <a> (with href/target/rel, https/mailto only). Anything broader
gets stripped at the DOM-injection boundary.

Wrapped in useMemo so the sanitize only runs when the preview HTML
changes — negligible perf, no per-render cost.

The hand-rolled markdown-email.ts pipeline stays as-is: its
escape-first-then-rule-replace architecture is correct and the
"don't add DOMPurify as a dep at the conversion layer" reasoning in
its header comment still holds. We add DOMPurify at the *consumer*
boundary (preview rendering) where the threat model is "what if the
server slips and emits unsafe HTML."

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 18:45:01 +02:00
parent ff0667ce52
commit 8416c5f3c3
3 changed files with 328 additions and 5 deletions

View File

@@ -19,6 +19,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import DOMPurify from 'isomorphic-dompurify';
import { Button } from '@/components/ui/button';
import {
@@ -155,7 +156,19 @@ export function SendDocumentDialog({
});
const unresolved = previewQuery.data?.data.unresolved ?? [];
const previewHtml = previewQuery.data?.data.html ?? '';
// Defense-in-depth: server-side `renderEmailBody()` already escapes
// and allow-lists tags, but inject through DOMPurify before mounting
// the preview into the DOM so a future server-side regression can't
// silently produce a client-side XSS via the preview surface.
const previewHtml = useMemo(
() =>
DOMPurify.sanitize(previewQuery.data?.data.html ?? '', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'a'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOWED_URI_REGEXP: /^https:|^mailto:/i,
}),
[previewQuery.data?.data.html],
);
const recipientResolved = Boolean(recipientForApi.clientId || recipientForApi.email);
const canPreview = recipientResolved;