// PDFRenderer: the single shared HTML -> PDF entry point. // // Both reports (OPL-2598) and invoices (OPL-2602) render their HTML for // on-screen display first and only call render() on an explicit download // request — the PDF is produced lazily from the SAME HTML the user already saw, // so what prints matches what was on screen. // // SECURITY: the HTML is populated with user/org-controlled data (client names, // invoice line items, report text), so it is UNTRUSTED. To prevent server-side // SSRF/LFI via a malicious field (remote //fetch, or file:// reads): // - JavaScript is DISABLED by default (templates are pure HTML/CSS). // - Every subresource request is intercepted and ABORTED unless its scheme is // in the allow-list (data:/blob: only by default — exactly what our // templates use for embedded logos/backgrounds/fonts). // Nothing is fetched over the network during a render under the defaults. import type { Browser, BrowserContext } from "playwright-core"; import { type PDFRenderOptions, toPdfOptions } from "./options"; import { launchBrowser, type PdfRuntimeKind } from "./runtime"; /** URL schemes permitted for subresource loading by default. */ const DEFAULT_ALLOWED_URL_SCHEMES = ["data", "blob"] as const; export interface PDFRendererConfig { /** * Force a runtime instead of autodetecting from the environment. Leave unset * in app code; primarily for tests and explicit deployment overrides. */ runtime?: PdfRuntimeKind; } export interface PDFRenderSecurityOptions { /** * Enable JavaScript in the rendering context. Defaults to `false` — our print * templates are pure HTML/CSS. Only enable for a template that genuinely * needs it, and never for untrusted HTML. */ allowJavaScript?: boolean; /** * URL schemes a subresource request may use; everything else is aborted * before it leaves the process. Defaults to `["data", "blob"]`. Schemes are * matched case-insensitively against the URL's scheme without the trailing * colon (e.g. "https"). */ allowedUrlSchemes?: string[]; } export type PDFRenderArgs = PDFRenderOptions & PDFRenderSecurityOptions; export class PDFRenderer { private readonly runtime?: PdfRuntimeKind; constructor(config: PDFRendererConfig = {}) { this.runtime = config.runtime; } /** * Render a complete HTML document to a PDF byte array. The HTML should * include its own print CSS (see `buildPrintCss` / `PRINT_BASE_CSS`). * * The HTML is treated as untrusted: JavaScript is off and only data:/blob: * subresources load unless explicitly widened via the security options. */ async render(html: string, options: PDFRenderArgs = {}): Promise { const allowJavaScript = options.allowJavaScript ?? false; const allowedSchemes = new Set( (options.allowedUrlSchemes ?? DEFAULT_ALLOWED_URL_SCHEMES).map((scheme) => scheme.toLowerCase(), ), ); const browser: Browser = await launchBrowser(this.runtime); let context: BrowserContext | undefined; try { context = await browser.newContext({ javaScriptEnabled: allowJavaScript, }); // Block every subresource whose scheme is not allow-listed. A regexp // matcher (not a glob) is used so non-http schemes like file:// and ftp:// // are also intercepted. The inline document set via setContent() is not // itself a routed request. await context.route(/.*/, (route) => { const url = route.request().url(); const scheme = url .slice(0, Math.max(0, url.indexOf(":"))) .toLowerCase(); if (allowedSchemes.has(scheme)) { route.continue(); } else { route.abort("blockedbyclient"); } }); const page = await context.newPage(); // External requests are blocked, so only data:/blob: resources can // resolve — "load" fires without waiting on network that will never come. await page.setContent(html, { waitUntil: "load" }); // Ensure web fonts are applied before capture; otherwise the PDF embeds // system fallback glyphs. await page.evaluate(() => document.fonts.ready); await page.emulateMedia({ media: "print" }); const buffer = await page.pdf(toPdfOptions(options)); return new Uint8Array(buffer); } finally { // Close the context first (best-effort), then always close the browser so // a render error never leaks a Chromium process. if (context) { await context.close().catch(() => {}); } await browser.close(); } } } /** * Convenience one-shot render. Constructs a {@link PDFRenderer} and renders a * single document. For repeated renders, construct a {@link PDFRenderer} once. */ export async function renderPdf( html: string, options: PDFRenderArgs = {}, ): Promise { return new PDFRenderer().render(html, options); }