Pulp Engine Document Rendering
Get started

Pulp Engine — Render Isolation Threat Model

For security teams evaluating Pulp Engine. Documents what each RENDER_MODE actually isolates, what survives an API compromise, and where the residual risks live. Companion to the operator-facing setup material in deployment-guide.md § Render Isolation Mode.


1. Scope

This document covers the four values of RENDER_MODE shipped from v0.46.0 onward: in-process, child-process (default), container, and socket. It is written for security reviewers and procurement; it is not the operator setup guide.

Two things this document is not:

  • It is not a hostile-multi-tenant sandboxing claim. Pulp Engine is designed for trusted internal teams. Strong kernel boundaries (gVisor or equivalent), the Chromium setuid sandbox, and audit-grade logging of every render input would be additional prerequisites for a hostile-tenant posture, and Pulp Engine ships none of them by default.
  • It is not the tenant-data isolation contract. That is a row-level data boundary documented separately in tenant-isolation-guarantees.md. Render isolation modes isolate renders, not tenants.

2. What “render isolation” means here

RENDER_MODE controls where Puppeteer runs and which trust domain holds the Docker socket. Of the four modes, only socket separates the API process from Docker daemon authority. The others vary in how strongly the worker is sandboxed but all leave Docker in the API’s hands (container) or run the worker inside the API’s own process tree (child-process, in-process).

ModeDocker socket holderWorker networkWorker capabilitiesWorker filesystemWorker chosen byWhat survives an API compromise
in-processn/a (no Docker)API namespacefullAPI rootfsthe API itselfnothing — Chromium runs in-process
child-process (default)n/a (no Docker)API namespacefullAPI rootfspersistent forkAPI memory, but worker env is allowlisted (no DB/S3/API-key secrets in the worker)
containerAPI process--network none--cap-drop ALL--read-only + tmpfs /tmpAPI issues docker runa compromised API can still issue arbitrary docker run (the per-render worker is locked down, but the daemon itself is reachable)
socketrender-controller process (separate UID)--network none--cap-drop ALL--read-only + tmpfs /tmpcontroller, not APIAPI cannot reach the daemon at all; the framed socket protocol is the entire surface available to a compromised API

The central correction this document owes its readers: container and socket are not equivalent. They give the same per-render hardening, but only socket is privilege-separated.


3. child-process (default)

Each render is dispatched to a persistent forked Node process via process.fork() (packages/pdf-renderer/src/child-process-dispatcher.ts). The worker inherits a minimal allowlisted workerEnv — not the API’s full environment.

The allowlist (from child-process-dispatcher.ts:96–122) forwards exactly:

  • PULP_ENGINE_DISABLE_SANDBOX (only when set to true) — controls Chromium’s --no-sandbox flag.
  • PUPPETEER_EXECUTABLE_PATH — selects the same Chrome binary the parent probed at startup.
  • PUPPETEER_CACHE_DIR — Puppeteer’s browser cache location.
  • RENDER_PREVIEW_RESERVED_SLOTS — preview-slot tuning.
  • PULP_LICENCE_KEY (when present) — evaluation-watermark check runs in the worker.

The class-level comment in the dispatcher still describes the boundary as env: {}. The implementation builds the allowlist above; the security claim (“no API or storage secrets cross the boundary”) is unchanged, but precision matters in a security review — none of DATABASE_URL, API_KEY_*, S3_SECRET_ACCESS_KEY, EDITOR_TOKEN_SECRET, or any other credential reaches the worker.

Trust boundary. A Node process.fork() boundary. Different process, different memory space, but the same OS user, the same network namespace, and the same filesystem.

Blast radius if Chromium is exploited. The worker’s own environment is not a direct secrets pivot, but the API process is co-resident on the same node — credentials in API memory and connections out of the API (database, S3, OIDC IdP) are within reach of attacks that escape the worker process. This mode is appropriate for environments where Docker is unavailable or operationally unwelcome; it is not the strongest posture Pulp Engine offers.


4. container

Each render is dispatched to a fresh ephemeral Docker container. The dispatcher hardcodes the security flags (packages/pdf-renderer/src/container-render-dispatcher.ts:121–132):

  • --network none — no IP connectivity from the worker.
  • --read-only rootfs with --tmpfs /tmp:rw,noexec,nosuid,size=256m.
  • --cap-drop ALL.
  • --security-opt no-new-privileges.
  • --memory and --cpus bounded (defaults 512m / 1, configurable).
  • --env allowlist: PULP_ENGINE_DISABLE_SANDBOX=true, NODE_ENV=production, and PULP_LICENCE_KEY when set. No API/DB/S3 secrets.
  • --rm — container removed after each render.

This is stronger per-render isolation than child-process. The worker has no network and no host filesystem; a Chromium RCE does not pivot to the host without a kernel/Docker-engine container escape.

It is not privilege separation. The API process holds the Docker socket and issues every docker run. A compromised API can:

  • Spawn its own privileged containers, bypassing the hardcoded flags above.
  • Mount the host filesystem.
  • Reach any other workload sharing the daemon.

This residual risk is documented inline in the dispatcher’s own comments and is the reason socket mode exists. For deployments where the API and Docker daemon are not in the same trust domain, prefer socket.


The API process holds no Docker socket. A separate render-controller process owns the daemon connection. The API connects to the controller over a Unix domain socket and submits render requests through a narrow framed protocol; the controller is the only process that ever calls docker run.

Identity boundary (durable sources, not tied to any one compose example):

  • API process runs as UID 1001 (Dockerfile:195–200).
  • Controller process runs as UID 1002 (Dockerfile.controller:116–123).
  • The controller creates the socket with mode 0660 — see chmodSync(config.socketPath, 0o660) in render-controller.ts:444. Group membership is the access boundary; outside that group, the socket is unreachable.

Flag enforcement is not negotiable from the API. The controller’s docker run flag list is hardcoded at render-controller.ts:281–289:

docker run --rm
  --network none
  --read-only
  --tmpfs /tmp:rw,noexec,nosuid,size=256m
  --memory <cfg>  --cpus <cfg>
  --cap-drop ALL
  --security-opt no-new-privileges
  [--security-opt seccomp=<profile> if RENDER_CONTAINER_SECCOMP_PROFILE is set]
  --env PULP_ENGINE_DISABLE_SANDBOX=true
  --env NODE_ENV=production

The API cannot influence those flags, cannot inject additional volume mounts, and cannot ask for extra capabilities. The framed-socket protocol (packages/pdf-renderer/src/stream-frame.ts) is the entire surface a compromised API has access to.

What this buys. Even a fully compromised API cannot:

  • Issue arbitrary docker run.
  • Mount the host filesystem into a worker.
  • Bypass --network none or --cap-drop ALL.
  • Reach any container outside the worker pool.

The only operation a compromised API can perform is “submit a render request”, and the controller validates the request shape before spawning a worker.

What this does not buy. The controller process itself still holds Docker socket authority. A container-escape RCE reaching the controller’s UID would regain daemon access. The controller’s surface is small (a socket listener and docker run), but it is not zero — see § 7.

compose.container.yaml is the canonical reference deployment. docker-compose.benchmark.socket.yml is one example deployment shape used for benchmarking; treat it as illustrative, not canonical.


6. Defense-in-depth around rendering

These controls run inside the renderer regardless of which RENDER_MODE is selected, but their relevance varies by mode.

DNS-rebinding / SSRF guard

packages/pdf-renderer/src/network-guard.ts provides isBlockedRenderUrl, isBlockedIpAddress, and resolveHostnameIps. Loopback (127.0.0.0/8, ::1), RFC 1918 private ranges (10/8, 172.16/12, 192.168/16), link-local + cloud-metadata (169.254/16, IPv6 fe80::/10), IPv4-mapped IPv6 (::ffff:…), and ULA IPv6 (fc00::/7) are unconditionally blocked. Hostnames are resolved and every resolved IP is checked against the same blocklist before any request is allowed — DNS rebinding is mitigated by checking the IP at the moment of the request, not just the hostname.

Remote-resource policy (BLOCK_REMOTE_RESOURCES / ALLOWED_REMOTE_ORIGINS)

The policy is wired from API config in apps/api/src/routes/render/render.ts:172–186 and enforced at two layers:

  1. Browser layer. installRequestGuard in packages/pdf-renderer/src/renderer.ts:149–266 intercepts every Puppeteer network request. With blockRemoteResources: true, all http/https fetches are blocked unless their origin is in allowedRemoteOrigins; data: URIs remain permitted.
  2. HTML layer. packages/html-renderer/src/renderer.ts applies the same allowlist when assembling/inlining HTML resources.

Naming both layers matters: a reviewer who only sees the HTML-layer block might conclude the policy depends on perfect upstream sanitization. The browser-layer guard is the authoritative net.

Mode-sensitive caveat

In container and socket modes, the worker container is started with --network none (hardcoded — see container-render-dispatcher.ts:122–123 and render-controller.ts:282–283). The kernel-level no-network boundary wins; the request guard’s allowlist becomes effectively moot because no DNS resolution and no outbound connection is possible.

BLOCK_REMOTE_RESOURCES and ALLOWED_REMOTE_ORIGINS are therefore primarily relevant to child-process and in-process modes, where the worker shares the API’s network namespace. Operators running container/socket should still set the policy for defense-in-depth (a future regression that loosens --network none would be caught), but should not assume it is the load-bearing control in those modes.


7. Honest assessment & limitations

These apply regardless of RENDER_MODE:

  • Controller process still holds the Docker socket. Socket mode separates the API from daemon authority; it does not make the controller invulnerable. A container-escape RCE reaching the controller’s UID would regain Docker access. The controller’s surface is intentionally small (no HTTP server, no auth, no storage — a socket listener and docker run), which is the point, but reviewers should size the controller’s blast radius accordingly.
  • Chromium sandbox is disabled (PULP_ENGINE_DISABLE_SANDBOX=true) because the setuid sandbox is unavailable inside containers. The kernel namespace boundary is the primary sandbox in container/socket modes; in child-process/in-process, the sandbox toggle is the operator’s call.
  • No custom seccomp profile ships by default. Operators supply one via RENDER_CONTAINER_SECCOMP_PROFILE. The flag wiring is in place (render-controller.ts:292–294); the profile is not.
  • No user-namespace remapping is configured by default. Pulp Engine does not configure the Docker daemon’s userns-remap. Operators who want it must enable it on the daemon itself.
  • No gVisor / runsc support is built in. Hostile-multi-tenant deployments would want a stronger kernel boundary; this is out of scope for v1.
  • Pulp Engine is designed for trusted internal teams. RENDER_MODE=socket materially reduces the blast radius of an API compromise but does not constitute hostile multi-tenant isolation.

8. Operator checklist

For each render mode, the production checklist:

child-process (or in-process)

  • Set BLOCK_REMOTE_RESOURCES=true. Configure ALLOWED_REMOTE_ORIGINS for the small set of legitimate remote font/image origins your templates need.
  • Set REQUIRE_HTTPS=true as the baseline. If the API is deployed behind a TLS-terminating reverse proxy or load balancer, also set TRUST_PROXY=true — without it, REQUIRE_HTTPS rejects every forwarded request and the API is unreachable. See deployment-guide.md for the canonical wording.
  • Keep Chromium up to date. Rebuild the API image on Chromium CVE disclosures.

container

  • Everything above, plus:
  • Acknowledge that the API process holds the Docker socket. If the API and the Docker daemon are not in the same trust domain, switch to socket.
  • Set RENDER_CONTAINER_MEMORY_LIMIT / RENDER_CONTAINER_CPU_LIMIT to match your worker capacity plan.

socket

  • Everything above, plus:
  • Run the controller as a separate process under a different UID (the shipped Dockerfile.controller uses UID 1002; the API image uses UID 1001). Mount /var/run/docker.sock only on the controller, never on the API.
  • Use compose.container.yaml (or an equivalent topology) with condition: service_healthy on the controller so the API does not start before the socket is live.
  • Supply a custom seccomp profile via RENDER_CONTAINER_SECCOMP_PROFILE and bind-mount the file into the controller container.
  • Consider enabling Docker’s userns-remap on the daemon for an additional UID boundary on the worker.
  • Keep the controller image up to date alongside the API image — patches to the controller’s small surface (Docker CLI, framed-protocol parser) ship with the regular release cadence.