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).
| Mode | Docker socket holder | Worker network | Worker capabilities | Worker filesystem | Worker chosen by | What survives an API compromise |
|---|---|---|---|---|---|---|
in-process | n/a (no Docker) | API namespace | full | API rootfs | the API itself | nothing — Chromium runs in-process |
child-process (default) | n/a (no Docker) | API namespace | full | API rootfs | persistent fork | API memory, but worker env is allowlisted (no DB/S3/API-key secrets in the worker) |
container | API process | --network none | --cap-drop ALL | --read-only + tmpfs /tmp | API issues docker run | a compromised API can still issue arbitrary docker run (the per-render worker is locked down, but the daemon itself is reachable) |
socket | render-controller process (separate UID) | --network none | --cap-drop ALL | --read-only + tmpfs /tmp | controller, not API | API 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 totrue) — controls Chromium’s--no-sandboxflag.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-onlyrootfs with--tmpfs /tmp:rw,noexec,nosuid,size=256m.--cap-drop ALL.--security-opt no-new-privileges.--memoryand--cpusbounded (defaults512m/1, configurable).--envallowlist:PULP_ENGINE_DISABLE_SANDBOX=true,NODE_ENV=production, andPULP_LICENCE_KEYwhen 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.
5. socket (privilege-separated, recommended for regulated deployments)
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 noneor--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:
- Browser layer.
installRequestGuardinpackages/pdf-renderer/src/renderer.ts:149–266intercepts every Puppeteer network request. WithblockRemoteResources: true, allhttp/httpsfetches are blocked unless their origin is inallowedRemoteOrigins;data:URIs remain permitted. - HTML layer.
packages/html-renderer/src/renderer.tsapplies 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 incontainer/socketmodes; inchild-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=socketmaterially 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. ConfigureALLOWED_REMOTE_ORIGINSfor the small set of legitimate remote font/image origins your templates need. - Set
REQUIRE_HTTPS=trueas the baseline. If the API is deployed behind a TLS-terminating reverse proxy or load balancer, also setTRUST_PROXY=true— without it,REQUIRE_HTTPSrejects 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_LIMITto match your worker capacity plan.
socket
- Everything above, plus:
- Run the controller as a separate process under a different UID (the shipped
Dockerfile.controlleruses UID 1002; the API image uses UID 1001). Mount/var/run/docker.sockonly on the controller, never on the API. - Use
compose.container.yaml(or an equivalent topology) withcondition: service_healthyon the controller so the API does not start before the socket is live. - Supply a custom seccomp profile via
RENDER_CONTAINER_SECCOMP_PROFILEand bind-mount the file into the controller container. - Consider enabling Docker’s
userns-remapon 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.