Pulp Engine Document Rendering
Get started
Release v0.46.0

Pulp Engine v0.46.0 — Release Notes

This release introduces a three-phase render isolation architecture that progressively removes trust from the PDF rendering pipeline. The default production mode (child-process) runs Puppeteer in a forked child process with an empty environment so no API secrets are reachable from the renderer. An optional container mode runs each render in an ephemeral Docker container with --network none, read-only rootfs, and capability restrictions. A new socket mode (recommended for container deployments) interposes a dedicated controller process so the API holds no Docker socket authority — the controller exclusively owns daemon access and enforces hardcoded security flags regardless of API state.


What changed

1. RENDER_MODE=child-process — default production isolation (Phase 1)

Puppeteer now runs in a persistent child process forked with env: {} rather than in the API process itself. This is the new default (RENDER_MODE=child-process).

Isolation properties:

  • No environment variables are inherited from the API process (no DATABASE_URL, API_KEY_*, S3_SECRET_ACCESS_KEY, etc. are reachable from the renderer).
  • Assets are pre-inlined as base64 data URIs before dispatch via assetResolver; the child process never makes loopback requests to the asset server.
  • A single long-lived worker (packages/pdf-renderer/src/worker.ts) handles sequential renders; a fresh worker is spawned automatically if the existing one crashes or times out.

No Docker or additional infrastructure is required. Replaces the previous in-process rendering path transparently — the POST /render API surface is unchanged.

New files:

  • packages/pdf-renderer/src/dispatcher.tsRenderDispatcher interface
  • packages/pdf-renderer/src/worker.ts — child process entry point (IPC via process.send)
  • packages/pdf-renderer/src/child-process-dispatcher.tsChildProcessRenderDispatcher
  • packages/pdf-renderer/src/child-process-dispatcher.test.ts

2. RENDER_MODE=container — ephemeral Docker container per render (Phase 2)

Each PDF render runs in a fresh Docker container that is destroyed after the render completes. JSON is passed over stdin/stdout; no network path, no volume mounts.

Hardcoded runtime flags applied by ContainerRenderDispatcher:

FlagEffect
--network noneNo network access from worker
--read-onlyRead-only root filesystem
--tmpfs /tmp:rw,noexec,nosuid,size=256mEphemeral writable /tmp; no binary execution
--cap-drop ALLNo Linux capabilities
--security-opt no-new-privilegesNo setuid escalation
--memory 512m --cpus 1Resource limits (configurable)
--rmContainer removed after each render

Only PULP_ENGINE_DISABLE_SANDBOX=true and NODE_ENV=production are passed as container env vars. API secrets are never forwarded. The worker image (Dockerfile.worker) is a minimal multi-stage build containing only the pdf-renderer package and its transitive runtime dependencies — no API server, no Prisma, no auth.

Required configuration:

RENDER_MODE=container
RENDER_CONTAINER_IMAGE=ghcr.io/OWNER/pulp-engine-worker:v0.46.0

New files:

  • packages/pdf-renderer/src/container-worker.ts — container entry point (JSON stdin/stdout)
  • packages/pdf-renderer/src/container-render-dispatcher.tsContainerRenderDispatcher
  • packages/pdf-renderer/src/container-render-dispatcher.test.ts
  • Dockerfile.worker — worker image (multi-stage, non-root UID 1001, no Chromium system libs bundled separately — Puppeteer cache copied from builder stage)
  • apps/worker/ — minimal workspace app used by pnpm deploy to produce the worker bundle

Residual risk at this phase: The API process holds Docker socket authority. A compromised API could bypass the hardcoded flags by invoking Docker directly.


3. RENDER_MODE=socket — privilege-separated controller (Phase 3)

A new render-controller process owns the Docker socket and listens on a Unix domain socket. The API connects to the controller over that socket and submits render requests. The API process holds no Docker socket authority. The controller enforces all security flags unconditionally — the client cannot influence them.

API process (no docker.sock) ──JSON/\n──► render-controller (has docker.sock)
SocketRenderDispatcher        ◄──JSON/\n── spawns docker run, hardcoded flags

What this achieves: Even if the API process is fully compromised, the attacker cannot invoke arbitrary Docker operations, mount the host filesystem, create privileged containers, or bypass the isolation flags. The controller binary is much smaller than the API (no HTTP server, no auth, no storage — socket listener + docker run).

Readiness retry: SocketRenderDispatcher retries ENOENT/ECONNREFUSED for up to 5 s (10 × 500 ms) on connect, tolerating the normal controller startup race on deploy.

Response size bound: The dispatcher caps incoming responses at 100 MB, preventing a malformed or compromised controller from forcing unbounded buffering in the API process.

Optional seccomp profile: Set RENDER_CONTAINER_SECCOMP_PROFILE on the controller to pass --security-opt seccomp=<path> to each worker container. The file must be present inside the controller container (bind-mount as needed).

Required configuration (API process):

RENDER_MODE=socket
RENDER_CONTROLLER_SOCKET=/run/render/render.sock

Required configuration (controller process):

RENDER_CONTAINER_IMAGE=ghcr.io/OWNER/pulp-engine-worker:v0.46.0
RENDER_CONTROLLER_SOCKET=/run/render/render.sock

New files:

  • packages/pdf-renderer/src/render-controller.ts — standalone controller (exported: validateImageName, loadConfig, handleConnection; startup guard via import.meta.url)
  • packages/pdf-renderer/src/socket-render-dispatcher.tsSocketRenderDispatcher
  • packages/pdf-renderer/src/render-controller.test.ts — 37 tests
  • packages/pdf-renderer/src/socket-render-dispatcher.test.ts — 15 tests
  • Dockerfile.controller — controller image: node:22-slim + docker-ce-cli only (no Chromium), non-root UID 1002
  • compose.container.yaml — privilege-separated deployment topology (see § 4 below)

4. compose.container.yaml — privilege-separated compose topology

A new compose file demonstrates the correct deployment for RENDER_MODE=socket:

render-controller:
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock  # ONLY this service
    - render-socket:/run/render

pulp-engine:
  # No /var/run/docker.sock mount
  volumes:
    - render-socket:/run/render:ro
  depends_on:
    render-controller:
      condition: service_healthy

The render-controller healthcheck (test -S /run/render/render.sock) ensures the API container does not start until the socket file exists. The socket volume uses driver_opts: type: tmpfs — no persistence needed.


5. TemplateRenderer dispatcher injection

packages/template-renderer/src/renderer.ts now accepts an optional RenderDispatcher in its constructor. If omitted, it falls back to a PdfRenderer instance for backward compatibility (in-process mode). The assetBaseUrl field on RenderRequest is deprecated — the correct path is assetResolver pre-inlining.


6. Config additions

apps/api/src/config.ts:

VariableNotes
RENDER_MODEEnum: in-process, child-process (default), container, socket
RENDER_CONTAINER_IMAGERequired when RENDER_MODE=container
RENDER_CONTROLLER_SOCKETRequired when RENDER_MODE=socket

7. Test coverage

  • pnpm --filter @pulp-engine/pdf-renderer test261 passed (net +104 vs v0.45.0). Covers: isolation flags on both ContainerRenderDispatcher and handleConnection, secret isolation on all dispatch paths, seccomp flag propagation, image name allowlist, startup validation, 120 s timeout + SIGKILL, response size bound, readiness retry budget, wire format.
  • pnpm --filter @pulp-engine/api test619 passed, 0 failed. Two previously-failing tests (render-preview.test.ts e-c/e-d) were fixed to mock ChildProcessRenderDispatcher in addition to PdfRenderer, as the default render path no longer uses PdfRenderer directly.

Validation

  • pnpm --filter @pulp-engine/pdf-renderer test — 261 passed, 0 failed
  • pnpm --filter @pulp-engine/api test — 619 passed, 0 failed
  • pnpm lint — 0 errors
  • pnpm run version:check — passed

Upgrade

No breaking changes. The default RENDER_MODE is child-process, which is a transparent drop-in for the previous in-process renderer. All env var names, API shapes, and database schemas are unchanged. No migrations required.

The TemplateRenderer constructor now accepts an optional RenderDispatcher. Callers passing no arguments are unaffected. The assetBaseUrl field on RenderRequest is deprecated but still accepted; it is silently ignored when a dispatcher is configured.

Residual risk (honest assessment):

  • RENDER_MODE=child-process (default): A Chromium/Puppeteer RCE in the child process can still reach the host filesystem and any resources accessible from the host network. It cannot access API secrets from the environment. This mode is appropriate for trusted-team deployments.
  • RENDER_MODE=container: The API process holds Docker socket authority. A compromised API can bypass the hardcoded flags. Appropriate for environments where the API is trusted but an extra containment layer is wanted.
  • RENDER_MODE=socket: The API no longer holds Docker socket authority. A compromised API cannot escalate to arbitrary Docker operations. The render-controller process still holds the socket — a container-escape RCE reaching the controller process restores daemon authority. Chromium sandbox is still disabled (required for containerisation). No gVisor, no user namespace remapping bundled.
  • All modes: Pulp Engine is not designed for hostile multi-tenant environments where arbitrary untrusted users author templates. These improvements materially strengthen the isolation for trusted-team deployments; they do not constitute hostile multi-tenant readiness.
docker pull ghcr.io/OWNER/pulp-engine:v0.46.0
docker pull ghcr.io/OWNER/pulp-engine-worker:v0.46.0
docker pull ghcr.io/OWNER/pulp-engine-controller:v0.46.0