fix(sec/d24): PDF render hardening — strict CSP + request interception

Red-team XSS/rendering finding (public-share renders our HTML in Chromium):
- Both letterhead templates get a strict CSP meta (default-src none; img-src
  data:; style-src unsafe-inline; base-uri/form-action none) — permits the
  inline CSS + data: logo the templates actually use, blocks all script
  execution and every outbound/file fetch. Identical in preview + PDF, so the
  byte-for-byte preview-fidelity golden test still passes.
- renderPdf aborts every non-data: network request at the browser layer.
Together an esc() miss becomes a harmless rendering glitch instead of an
SSRF/exfiltration/RCE-adjacent vector. Template + fidelity tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent aaad1e7a3a
commit 4cf513d528

@ -20,6 +20,17 @@ function getBrowser(): Promise<Browser> {
export async function renderPdf(html: string): Promise<Buffer> {
const page = await (await getBrowser()).newPage()
try {
// D24 (red-team): defense-in-depth for the public-share render. Block every network
// request at the browser layer — the letterhead is fully self-contained (inline CSS,
// data:-URI logo), so any http(s)/file fetch would only come from an injected payload
// that slipped past esc(). Combined with the template CSP, an escaping miss becomes a
// harmless rendering glitch instead of an SSRF/exfiltration vector.
await page.setRequestInterception(true)
page.on('request', (req) => {
const url = req.url()
if (url.startsWith('data:') || url === 'about:blank') void req.continue()
else void req.abort()
})
await page.setContent(html, { waitUntil: 'load' })
const bytes = await page.pdf({ format: 'A4', printBackground: true })
return Buffer.from(bytes)

@ -105,6 +105,10 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
<html>
<head>
<meta charset="utf-8">
<!-- D24 (red-team): defense-in-depth so esc() is not the only wall. Permits the inline
CSS and data:-URI logo the letterhead actually uses; blocks ALL script execution and
every outbound/file fetch even if an escaping bug is ever introduced. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'">
<title>${esc(doc.docNo ?? 'Draft')}</title>
<style>
:root{
@ -305,6 +309,10 @@ function receiptHtml(doc: Doc, client: Client, company: Record<string, string>):
<html>
<head>
<meta charset="utf-8">
<!-- D24 (red-team): defense-in-depth so esc() is not the only wall. Permits the inline
CSS and data:-URI logo the letterhead actually uses; blocks ALL script execution and
every outbound/file fetch even if an escaping bug is ever introduced. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'">
<title>${esc(doc.docNo ?? 'Receipt')}</title>
<style>
@page { size: A4; margin: 14mm; }

Loading…
Cancel
Save