You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
49 lines
2.1 KiB
TypeScript
49 lines
2.1 KiB
TypeScript
import puppeteer, { type Browser } from 'puppeteer'
|
|
|
|
/**
|
|
* HTML → PDF via puppeteer. The browser is a lazy module-level singleton:
|
|
* launch costs ~1s, so it starts on first render and is reused after.
|
|
* Guarding with a promise (not the Browser) makes concurrent first calls
|
|
* share one launch instead of racing two Chromes.
|
|
*/
|
|
let browserPromise: Promise<Browser> | null = null
|
|
|
|
function getBrowser(): Promise<Browser> {
|
|
// --no-sandbox + --disable-dev-shm-usage let Chromium launch inside a container
|
|
// (root user, small /dev/shm); both are inert on a normal dev machine. We only ever
|
|
// render our own self-contained letterhead HTML (no external fetches), so dropping
|
|
// the sandbox here is not exposing us to third-party page content.
|
|
browserPromise ??= puppeteer.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] })
|
|
return browserPromise
|
|
}
|
|
|
|
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)
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
}
|
|
|
|
/** For tests/scripts that must let the process exit; the server never calls it. */
|
|
export async function closePdfBrowser(): Promise<void> {
|
|
if (browserPromise === null) return
|
|
const b = await browserPromise
|
|
browserPromise = null
|
|
await b.close()
|
|
}
|