Pulp Engine v0.8.0
Release date: 2026-03-19
Highlights
- Defense-in-depth security hardening across the PDF and HTML renderer pipeline.
- Font import URLs are now validated (https: only, private hosts blocked) — no bypass path.
- Custom CSS is sanitised in safe mode by default; trusted mode requires explicit opt-in.
- Image
srcURLs are checked for SSRF risk (private/internal hosts blocked). - Puppeteer request interception blocks private-network fetches as a browser-layer backstop.
- Chrome sandbox is now enabled by default; container deployments must set
PULP_ENGINE_DISABLE_SANDBOX=true.
Security hardening
fontImports — URL validation
renderConfig.fontImports entries are now validated unconditionally before being injected
as @import url(...) rules. A font URL is accepted only if:
- Protocol is
https:(http:, data:, file:, blob: are rejected) - Hostname does not match any private/internal address range (loopback, RFC 1918, link-local / AWS metadata endpoint 169.254.x, ULA IPv6)
Rejected URLs are dropped with a console.warn and never reach the <style> block.
There is no bypass path — the check is unconditional regardless of trust mode.
customCss — trust model
renderConfig.customCss is now subject to a two-mode trust model:
-
Safe mode (default):
@importrules andurl()content are stripped before injection. This prevents outbound CSS fetches regardless of the CSS content. No configuration is required — safe mode is active wheneverallowUnsafeCustomCssis not explicitly set. -
Trusted mode: Pass
allowUnsafeCustomCss: trueas the third argument toHtmlRenderer.render(). The CSS is injected verbatim. Use only whencustomCssoriginates from a verified administrative source (e.g. a back-office template editor accessible only to trusted operators).
The new HtmlRenderTrustOptions interface is exported from @pulp-engine/html-renderer:
import { HtmlRenderer, type HtmlRenderTrustOptions } from '@pulp-engine/html-renderer'
// Safe mode (default — no third argument needed)
renderer.render(template, data)
// Trusted mode — admin CSS, verified source only
renderer.render(template, data, { allowUnsafeCustomCss: true })
Image src — SSRF prevention
Image node src templates are now validated after Handlebars resolution. HTTP/HTTPS URLs
pointing to private/internal hosts (same pattern set as fontImports) are rejected and
replaced with an empty src="", preventing the browser from fetching internal network
resources during render.
- Relative paths (no protocol) are always allowed.
data:URIs are always allowed (inline data, no network fetch).http:/https:URLs to non-private hosts are allowed.http:/https:URLs to private/internal hosts are blocked.javascript:,vbscript:,file:, and malformed URLs are blocked.
Puppeteer request interception
A browser-layer request guard is now installed on every Puppeteer page before
page.setContent() is called. page.setRequestInterception(true) is enabled and a
page.on('request', ...) handler blocks any request whose URL resolves to a
private/internal host (same pattern set as above).
This provides a complementary backstop layer that blocks at the browser network level, even if an SSRF-bearing URL somehow bypasses input-layer validation (e.g. via DNS rebinding). Data URIs are never blocked.
The guard is implemented in packages/pdf-renderer/src/network-guard.ts
(isBlockedRenderUrl) and applied in packages/pdf-renderer/src/renderer.ts
(installRequestGuard).
Chrome sandbox — enabled by default
Previously, --no-sandbox and --disable-setuid-sandbox were always passed to Puppeteer.
These flags are now conditional — they are only added when PULP_ENGINE_DISABLE_SANDBOX=true
is set in the environment.
--disable-dev-shm-usageand--disable-gpuare always passed (unchanged).- macOS, Windows, and Linux hosts with kernel sandbox support: no change needed — sandbox is on by default and these platforms support it.
- Container environments (Docker, Kubernetes without elevated capabilities): Must set
PULP_ENGINE_DISABLE_SANDBOX=truein.env. Chrome will fail to launch without it.
The sandbox logic is implemented in getChromiumArgs(env) (exported from
packages/pdf-renderer/src/browser-pool.ts) and is fully unit-tested.
SSRF limitation note
All hostname-pattern blocking (fontImports, image src, Puppeteer request interception) matches literal hostnames in the URL string. It does not perform DNS resolution. DNS rebinding attacks — where a public domain resolves to a private IP at render time — are not fully prevented. The input-layer checks and browser-layer interception together substantially reduce browser reachability without claiming to eliminate every SSRF path.
New files
| File | Purpose |
|---|---|
packages/pdf-renderer/src/network-guard.ts | isBlockedRenderUrl(url) — pure helper for private-host blocking |
packages/pdf-renderer/src/network-guard.test.ts | 26 unit tests for all blocked categories and allowed cases |
packages/pdf-renderer/src/browser-pool.test.ts | 6 unit tests for getChromiumArgs sandbox flag logic |
Modified files
| File | Change |
|---|---|
packages/html-renderer/src/renderer.ts | isSafeFontUrl, sanitiseCssForSafeMode, HtmlRenderTrustOptions, allowUnsafeCustomCss opt-in |
packages/html-renderer/src/index.ts | Exports HtmlRenderTrustOptions |
packages/html-renderer/src/node-renderers/image.ts | isSafeImageSrc with private-host blocking |
packages/pdf-renderer/src/browser-pool.ts | getChromiumArgs(env) exported; sandbox flags now conditional |
packages/pdf-renderer/src/renderer.ts | installRequestGuard(page) called before setContent in both render paths |
packages/html-renderer/src/node-renderers/renderers.test.ts | Tests for fontImports validation, customCss trust model, image SSRF |
packages/pdf-renderer/src/renderer.test.ts | Tests for request interception handler |
Test coverage
packages/pdf-renderer
| File | Tests | Scope |
|---|---|---|
network-guard.test.ts | 26 | All private CIDR ranges, IPv6 (including bracket-wrapped), data: URIs, malformed URLs, public hosts |
browser-pool.test.ts | 6 | Sandbox absent by default, absent for “false”/“1”, present for “true”, always-present flags |
renderer.test.ts (additions) | 7 | setRequestInterception called before setContent, handler registered, block/allow by URL |
packages/html-renderer
| Describe block | Tests | Scope |
|---|---|---|
| Security — fontImports URL validation | 8 | Allows public https, drops http/data:/private-IP/loopback/localhost/169.254, mixed list |
| Security — customCss trust model | 6 | Safe mode strips @import and url(), preserves non-fetch CSS; trusted mode passes verbatim |
| Security — image SSRF prevention | 5 | Blocks 192.168.x, localhost, 169.254.x; allows public https and data: URI |
Behaviour changes / migration notes
Container deployments — action required
If you run Pulp Engine in Docker, Kubernetes, or any environment where the Chrome sandbox is unavailable:
Add PULP_ENGINE_DISABLE_SANDBOX=true to your .env before upgrading.
Without this, Chrome will fail to launch and all PDF render requests will return 500 errors.
HtmlRenderer.render() — new optional third parameter
The method signature is now:
render(
template: TemplateDefinition,
data: Record<string, unknown>,
trustOptions?: HtmlRenderTrustOptions,
): string
The third parameter is optional and defaults to safe mode. Existing callers with no
third argument are unaffected. The only breaking case is if a caller was relying on raw
customCss @import or url() fetches during render — those are now stripped in safe
mode. Pass { allowUnsafeCustomCss: true } to restore the previous verbatim behaviour,
but only when the CSS source is verified as admin-only.