From 4cf513d528ebc6eaabcc2437d9ef13fc8aa4c786 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sat, 18 Jul 2026 00:33:08 +0530 Subject: [PATCH] =?UTF-8?q?fix(sec/d24):=20PDF=20render=20hardening=20?= =?UTF-8?q?=E2=80=94=20strict=20CSP=20+=20request=20interception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq/src/pdf.ts | 11 +++++++++++ apps/hq/src/templates.ts | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/hq/src/pdf.ts b/apps/hq/src/pdf.ts index 5f210a6..76b9ae5 100644 --- a/apps/hq/src/pdf.ts +++ b/apps/hq/src/pdf.ts @@ -20,6 +20,17 @@ function getBrowser(): Promise { export async function renderPdf(html: string): Promise { 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) diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 65cd124..c504049 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -105,6 +105,10 @@ export function documentHtml(doc: Doc, client: Client, company: Record + + ${esc(doc.docNo ?? 'Draft')}