Skip to content

ADR 0011: EPS/PostScript through Ghostscript in a sandboxed subprocess, gpl image only, off by default

  • Status: accepted (WP-99; owner decision 2026-09-27, final)
  • Date: 2026-09-27
  • Amends: ADR 0006 (its WP-97 amendment declined EPS; see the WP-99 amendment there)

EPS, PostScript and the PostScript-based Adobe Illustrator files (AI 8 and older) need a PostScript interpreter. The only practical one is Ghostscript, which is AGPL-3.0-or-later. WP-97 recorded why it stayed out (ADR 0006): linked into the server (ImageMagick --with-gslib), AGPL §13 would reach the whole process; run through ImageMagick’s gs delegate, a full PostScript interpreter would parse untrusted uploads inside the request-serving process, behind a -dSAFER sandbox with a long record of bypasses (CVE-2016-7977, CVE-2018-16509, CVE-2019-14811, CVE-2021-3781, CVE-2023-36664, CVE-2024-29510: file read/write and command execution from a crafted document).

The owner reviewed those implications and decided (2026-09-27, final): support EPS/PS/AI only in the gpl image, disabled by default (XI_FORMATS_EPS_ENABLED=false), with Ghostscript run out of process, isolated, rasterising only what is needed. The owner accepts that the gpl image carries AGPL-3.0 software for whoever deploys it with the feature on. slim and full do not change.

PDF-based Illustrator files (AI 9 and later, %PDF-) already sniff as PDF and are rendered by PDFium; they never reach Ghostscript.

  1. A program, never a library. The gpl base image builds Ghostscript 10.08.0 (versions-gpl.sh, SHA256-pinned, checked against the release SHA512SUMS) as the single program /opt/xtandard/bin/gs: no libgs, no X11, CUPS, dbus, GTK, fontconfig, libpaper, IJS, threads or contributed drivers, no PDF interpreter (and so no JBIG2/JPX decoders), no TIFF, no OCR, no extract, and only the pngalpha, png16m and bbox devices (plus the built-in nullpage). Its own FreeType, libjpeg, libpng, zlib, lcms2mt and brotli are linked statically and its resources and fonts (the URW base 35, Droid Sans Fallback, Adobe’s CMaps) are compiled in (%rom%), so gs links only libc/libm and reads no font or init file from disk. 14 MB stripped (arm64). The server never links it; internal/ghostscript is pure Go (os/exec, no cgo) and runs it as a subprocess.
  2. Off unless asked for, and only where it exists. XI_FORMATS_EPS_ENABLED (default false). true resolves XI_FORMATS_EPS_GS_PATH (default gs) at startup and runs gs --version through the sandbox; a missing or broken gs stops startup with a message naming the gpl image. Without it (every slim and full image, gpl by default), an EPS is recognised (codec.FormatEPS) and answers 422 “EPS needs the gpl image with XI_FORMATS_EPS_ENABLED=true”, except a DOS EPS binary file with an embedded TIFF preview, which is decoded from that preview in every flavour (cheap: the preview is a TIFF libvips already reads).
  3. Probe in Go. /info, the probe and the pixel limits use codec.ParseEPSHeader: the DOS EPS binary header, then the DSC comments (%%HiResBoundingBox over %%BoundingBox, (atend) values from the trailer, %%Pages, %%DocumentMedia, Letter when nothing is declared). Boxes that are not finite, positive and at most 40 000 pt per side are corrupt (422); the page at 72 DPI then meets XI_LIMITS_MAX_SRC_PIXELS like any source. Probing never runs Ghostscript; detection and /info pixel analysis use the TIFF preview or answer 422.
  4. Render only what is needed. The planner treats a rendered EPS as a vector measured at 72 DPI (like PDF): the load step asks for the requested page (page, 1-based to gs) at 72 × scale DPI, capped by XI_FORMATS_EPS_MAX_DPI (600) and lowered until width × height fits XI_LIMITS_MAX_SRC_PIXELS. The device is fixed to that page: -g<W>x<H> -dFIXEDMEDIA, so a setpagedevice asking for a larger page is clipped, not rendered; the bounding box origin moves to every page’s origin with a BeginPage procedure (Ghostscript’s own -dEPSCrop only applies to files labelled EPSF). Stacking several pages (pages) is not supported: the first page of the selection renders.
  5. One subprocess per render, no files. The PostScript section (a DOS EPS header and its previews stripped) goes to gs on stdin; the PNG comes back on stdout and is read straight into C memory (vips.NewBufferFromReader, then the normal pipeline), bounded by the page’s raw size plus PNG overhead (more is 422). -sBandListStorage=memory keeps banding off the disk; -sstdout=%stderr keeps PostScript print out of the PNG; stderr is kept (4 KiB) for the log only, never for the client. Each render gets a private, empty TMPDIR, removed afterwards.
  6. Admission. A render happens inside a request that already holds a worker slot and its memory reservation (internal/limiter); on top of that, XI_FORMATS_EPS_MAX_PROCS (2) bounds concurrent gs processes with a x/sync/semaphore, taken with TryAcquire: a full one fails fast with 429 (CLAUDE.md rule 5).
  7. Isolation layers. Every render:
    • runs with -dSAFER -dBATCH -dNOPAUSE -dNOPROMPT -q, never -dNOSAFER, with an environment of only TMPDIR;
    • leads its own process group (Setpgid); cancellation (client gone), XI_FORMATS_EPS_TIMEOUT (10 s, then 504), an oversized output or the end of the render kill the whole group with SIGKILL, and Wait reaps it before the request returns;
    • on Linux, starts as a re-exec of the server binary (xtandard-image __xi-gs-sandbox ..., ghostscript.RunSandboxHelper, called first thing in main), which on its own thread: switches to nobody when the server runs as root (never the case in the images, which run as 65532); sets PR_SET_PDEATHSIG=SIGKILL (after the credential change, which clears it) and exits if the server is already gone; sets PR_SET_NO_NEW_PRIVS; sets RLIMIT_CORE=0, RLIMIT_NOFILE=64, RLIMIT_FSIZE=0 (no file may grow), RLIMIT_CPU = timeout + 1 s (SIGXCPU, then SIGKILL), RLIMIT_NPROC=0 (no fork: the count is per user and the server’s threads already exceed it) and RLIMIT_AS = 1 GiB + twice the page’s output bound (configurable, and dropped under CPU emulation: amendment WP-118 below); then execves gs;
    • is attempted in a new user and network namespace (CLONE_NEWUSER|CLONE_NEWNET, identity-mapped), and falls back without one when the kernel or the container’s seccomp profile refuses it. Docker’s default profile does (and so do Kubernetes’ RuntimeDefault profiles); so does a non-root server that is non-dumpable (below), since the parent can then no longer write the child’s uid_map. The fallback is logged.
    • The server process itself is marked non-dumpable (PR_SET_DUMPABLE=0) when EPS rendering starts, so a process of the same user (a compromised gs) cannot ptrace it or read its memory and /proc/<pid>/environ (XI_KEY, XI_SALT, storage credentials). Startup logs eps rendering enabled once with the Ghostscript version and each layer (sandbox_helper, process_group, parent_death_signal, no_new_privs, rlimits, uid, dropped_root, net_namespace and why not, server_non_dumpable), and warns eps rendering without a network namespace when that layer fell back. Outside Linux (developer machines) only the process group and -dSAFER apply.
  8. Licence gate. check-licenses.sh --allow-gpl accepts ghostscript only as exactly AGPL-3.0-or-later (XI_GPL_COMPONENTS), only as the gs program (--ghostscript: gs --version runs, the device list is exactly bbox nullpage png16m pngalpha, ldd resolves); libgs in any library, AGPL for any other component and every other GPL/AGPL/unknown licence still fail, and the strict gate (slim, full) refuses gs outright. LICENSE-GPL.md states the AGPL part; the image label is AGPL-3.0-or-later AND GPL-2.0-or-later AND MIT.

Protected, by construction:

  • The server process: gs is a separate process with its own address space; a crash, a hang, a memory blow-up or an infinite loop costs one request (422 or 504) and one process slot, never the server. Tests (internal/ghostscript/real_test.go, internal/app/eps_test.go) run documents that read /etc/passwd, open %pipe%, loop forever, recurse without bound, exhaust memory and ask for a 100 000 pt page: each fails with 422/504 (the page one renders at its declared 100 pt box), no file content reaches the output or the error body, and no process or temporary file is left.
  • Resource exhaustion: CPU time, address space, file size, open files, processes and output size are all bounded per render, and concurrency by XI_FORMATS_EPS_MAX_PROCS.
  • The server’s secrets in memory and environment (non-dumpable server), privilege gain through set-uid programs (no-new-privileges), forking helpers and %pipe% (RLIMIT_NPROC as well as -dSAFER), persistence through the file system (RLIMIT_FSIZE, private TMPDIR).

Not protected (residual risk the deployer accepts by enabling the feature):

  • A -dSAFER bypass that reaches code execution runs as the server’s user in the server’s container. It can read every file that user can read (a mounted config file or key) and draw it into the image it returns, which is an exfiltration channel no sandbox around the output can close; it can signal the server (same user) and, without the network namespace (the Docker default), open network connections from the container. There is no seccomp filter and no mount namespace. Operators who need more should run the gpl image as a separate deployment for EPS only (no secrets mounted, network egress denied), or put a seccomp profile that allows user namespaces on it so the network namespace holds.
  • Ghostscript bugs below -dSAFER (memory corruption in the interpreter, font or image parsers) are the same class of risk; the rlimits and the process boundary contain their effect, they do not prevent it. Keeping versions-gpl.sh on the latest Ghostscript release is part of operating the feature.
  • Ghostscript is a separate program in the gpl image; the server only executes it. The xtandard-image source stays MIT and slim/full contain no AGPL code.
  • Whoever distributes the gpl image conveys Ghostscript object code: the corresponding source is the pinned upstream tarball (URL and SHA256 in SOURCES.md) plus docker/base/build-gpl-deps.sh (configuration), and share/licenses/ghostscript/ carries its licence, the AGPL text and the licences of the code compiled into it.
  • The image ships Ghostscript unmodified. AGPL §13’s network clause is about modified versions interacting with users over a network; it becomes the operator’s obligation the moment they patch Ghostscript and serve EPS with it. LICENSE-GPL.md and docs/CONFIG.md say this plainly; legal review for a given deployment stays the deployer’s, as for the patent note on H.264/HEVC.
  • EPS/PS/AI render in the gpl image when enabled: 50–100 ms per request on arm64 for the test documents and a 1.6 MB Photoshop EPS, much of it process start-up (the helper re-exec plus gs initialisation).
  • slim and full keep their contents and licence; the gpl runtime grows by 11.1 MB compressed (14.5 MB uncompressed), to 116.6 MB on arm64.
  • internal/ghostscript is the first decoder that runs as a subprocess: its tests use a fake gs script (timeout, kill on cancel, oversized output, non-zero exit, fork refusal, overload, no leftover processes or files) everywhere, and the real gs on the gpl builder.
  • No new Go module: the sandbox uses os/exec and syscall (prctl, setrlimit), and golang.org/x/sync/semaphore, already a dependency.

Amendment WP-118 (2026-09-29): address-space limit under emulation, auto never fails startup

Section titled “Amendment WP-118 (2026-09-29): address-space limit under emulation, auto never fails startup”

Context: the local v0.1.0-rc.1 release verified the amd64 images on an arm64 Mac, where they run under Rosetta for Linux (OrbStack). There, every sandboxed program (gs, xi-metafile2svg, even a shell script) died before its first instruction with rosetta error: mmap_anonymous_rw mmap failed and SIGTRAP: Rosetta maps its translator state inside the translated process, and the 1 GiB RLIMIT_AS does not leave it room. QEMU user mode has the same problem. The gpl image then failed TestEPS, and because XI_FORMATS_METAFILE_ENABLED defaults to auto and the converter is present in gpl, its failed self-check stopped the server (app-tiny in the acceptance suite).

Decisions:

  1. auto never fails startup. With XI_FORMATS_METAFILE_ENABLED=auto, a converter that is present but fails its sandboxed --version check is logged as the warning metafile conversion unavailable (with the reason) and the server starts without it: WMF/EMF answer the same 422 as in slim/full (“WMF and EMF input needs the gpl image (XI_FORMATS_METAFILE_ENABLED)”); the converter’s error text never reaches a client. Only an explicit true stops startup. XI_FORMATS_EPS_ENABLED has no auto (it stays off by default, decision 2) and true still stops startup when gs cannot run.
  2. The address-space limit is configurable. XI_SANDBOX_MAX_AS (default 1GiB; 64MiB–1TiB) is the base RLIMIT_AS, plus twice the expected output as before, capped at 16 GiB or the base if larger. 0 turns RLIMIT_AS off.
  3. Emulation is detected, and relaxes only RLIMIT_AS. With XI_SANDBOX_DETECT_EMULATION=true (default), internal/sandbox reads /proc/cpuinfo and looks for the Rosetta binfmt_misc handler once per converter at startup: an amd64 process that sees a VirtualApple CPU (Rosetta for Linux; binfmt_misc is rarely mounted in a container, the cpuinfo is what Rosetta reports) or the Rosetta handler, an x86 process that sees an ARM CPU, or an ARM process that sees an x86 CPU (QEMU user mode passes the host’s cpuinfo through) is emulated, and the helper then skips RLIMIT_AS. Every other layer stays: RLIMIT_CPU, RLIMIT_NPROC=0, RLIMIT_NOFILE, RLIMIT_FSIZE=0, RLIMIT_CORE=0, the wall-clock timeout, the process group, PR_SET_NO_NEW_PRIVS, the parent-death signal, the unprivileged user, the network namespace attempt, the non-dumpable server and the output bound. false keeps the limit everywhere (for a deployment that knows better than the heuristic).
  4. Visible at startup. eps rendering enabled and metafile conversion enabled log rlimit_as (the base, 0 = none), emulation (rosetta, qemu or empty) and rlimit_as_off (disabled or emulation: <name>) next to the other layers, and the warning sandbox without an address-space limit names the converter and the reason.

Trade-off: without RLIMIT_AS, a hostile document (the memory.eps test) can make one subprocess allocate up to what the container allows before -dSAFER’s own VM limits, its CPU-time limit or the timeout stop it. That is a denial-of-service risk to the container, not an isolation break; the process is still separate and killed as a group. Emulated deployments are development and test setups (production runs natively; the release gate is native amd64 and arm64), and a container memory limit (docker run --memory, a Kubernetes limit) bounds the worst case there. Operators who want the limit under emulation anyway set XI_SANDBOX_DETECT_EMULATION=false and accept that the converters do not run.

Verified: on the amd64 gpl builder under Rosetta (OrbStack, --platform linux/amd64), internal/ghostscript, internal/metafile and internal/sandbox fail on the previous main (every run SIGTRAPs with the mmap error) and pass with this change, as do the app’s TestEPS, TestEPSDisabled and TestMetafiles; TestMetafileAutoDegrades (a converter that fails its check: the server starts, warns, and answers 422 without leaking the error; true is fatal) and the limit selection tests run on every platform. No new module.