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)
Context
Section titled “Context”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.
Decision
Section titled “Decision”- 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: nolibgs, 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, noextract, and only thepngalpha,png16mandbboxdevices (plus the built-innullpage). 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%), sogslinks only libc/libm and reads no font or init file from disk. 14 MB stripped (arm64). The server never links it;internal/ghostscriptis pure Go (os/exec, no cgo) and runs it as a subprocess. - Off unless asked for, and only where it exists.
XI_FORMATS_EPS_ENABLED(defaultfalse).trueresolvesXI_FORMATS_EPS_GS_PATH(defaultgs) at startup and runsgs --versionthrough the sandbox; a missing or brokengsstops 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). - Probe in Go.
/info, the probe and the pixel limits usecodec.ParseEPSHeader: the DOS EPS binary header, then the DSC comments (%%HiResBoundingBoxover%%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 meetsXI_LIMITS_MAX_SRC_PIXELSlike any source. Probing never runs Ghostscript; detection and/infopixel analysis use the TIFF preview or answer 422. - 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) at72 × scaleDPI, capped byXI_FORMATS_EPS_MAX_DPI(600) and lowered until width × height fitsXI_LIMITS_MAX_SRC_PIXELS. The device is fixed to that page:-g<W>x<H> -dFIXEDMEDIA, so asetpagedeviceasking for a larger page is clipped, not rendered; the bounding box origin moves to every page’s origin with aBeginPageprocedure (Ghostscript’s own-dEPSCroponly applies to files labelled EPSF). Stacking several pages (pages) is not supported: the first page of the selection renders. - One subprocess per render, no files. The PostScript section (a DOS EPS header and its previews stripped) goes to
gson 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=memorykeeps banding off the disk;-sstdout=%stderrkeeps PostScriptprintout of the PNG; stderr is kept (4 KiB) for the log only, never for the client. Each render gets a private, emptyTMPDIR, removed afterwards. - 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 concurrentgsprocesses with ax/sync/semaphore, taken withTryAcquire: a full one fails fast with 429 (CLAUDE.md rule 5). - Isolation layers. Every render:
- runs with
-dSAFER -dBATCH -dNOPAUSE -dNOPROMPT -q, never-dNOSAFER, with an environment of onlyTMPDIR; - 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 withSIGKILL, andWaitreaps 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 inmain), which on its own thread: switches tonobodywhen the server runs as root (never the case in the images, which run as 65532); setsPR_SET_PDEATHSIG=SIGKILL(after the credential change, which clears it) and exits if the server is already gone; setsPR_SET_NO_NEW_PRIVS; setsRLIMIT_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) andRLIMIT_AS= 1 GiB + twice the page’s output bound (configurable, and dropped under CPU emulation: amendment WP-118 below); thenexecvesgs; - 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’RuntimeDefaultprofiles); so does a non-root server that is non-dumpable (below), since the parent can then no longer write the child’suid_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 compromisedgs) cannotptraceit or read its memory and/proc/<pid>/environ(XI_KEY,XI_SALT, storage credentials). Startup logseps rendering enabledonce with the Ghostscript version and each layer (sandbox_helper,process_group,parent_death_signal,no_new_privs,rlimits,uid,dropped_root,net_namespaceand why not,server_non_dumpable), and warnseps rendering without a network namespacewhen that layer fell back. Outside Linux (developer machines) only the process group and-dSAFERapply.
- runs with
- Licence gate.
check-licenses.sh --allow-gplacceptsghostscriptonly as exactlyAGPL-3.0-or-later(XI_GPL_COMPONENTS), only as thegsprogram (--ghostscript:gs --versionruns, the device list is exactlybbox nullpage png16m pngalpha,lddresolves);libgsin any library, AGPL for any other component and every other GPL/AGPL/unknown licence still fail, and the strict gate (slim, full) refusesgsoutright.LICENSE-GPL.mdstates the AGPL part; the image label isAGPL-3.0-or-later AND GPL-2.0-or-later AND MIT.
What is protected, and what is not
Section titled “What is protected, and what is not”Protected, by construction:
- The server process:
gsis 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, privateTMPDIR).
Not protected (residual risk the deployer accepts by enabling the feature):
- A
-dSAFERbypass 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. Keepingversions-gpl.shon the latest Ghostscript release is part of operating the feature.
AGPL implications
Section titled “AGPL implications”- 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) plusdocker/base/build-gpl-deps.sh(configuration), andshare/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.mdanddocs/CONFIG.mdsay this plainly; legal review for a given deployment stays the deployer’s, as for the patent note on H.264/HEVC.
Consequences
Section titled “Consequences”- 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
gsinitialisation). - 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/ghostscriptis the first decoder that runs as a subprocess: its tests use a fakegsscript (timeout, kill on cancel, oversized output, non-zero exit, fork refusal, overload, no leftover processes or files) everywhere, and the realgson the gpl builder.- No new Go module: the sandbox uses
os/execandsyscall(prctl, setrlimit), andgolang.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:
autonever fails startup. WithXI_FORMATS_METAFILE_ENABLED=auto, a converter that is present but fails its sandboxed--versioncheck is logged as the warningmetafile 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 explicittruestops startup.XI_FORMATS_EPS_ENABLEDhas noauto(it stays off by default, decision 2) andtruestill stops startup whengscannot run.- The address-space limit is configurable.
XI_SANDBOX_MAX_AS(default1GiB; 64MiB–1TiB) is the baseRLIMIT_AS, plus twice the expected output as before, capped at 16 GiB or the base if larger.0turnsRLIMIT_ASoff. - Emulation is detected, and relaxes only
RLIMIT_AS. WithXI_SANDBOX_DETECT_EMULATION=true(default),internal/sandboxreads/proc/cpuinfoand looks for the Rosettabinfmt_mischandler once per converter at startup: an amd64 process that sees aVirtualAppleCPU (Rosetta for Linux;binfmt_miscis 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 skipsRLIMIT_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.falsekeeps the limit everywhere (for a deployment that knows better than the heuristic). - Visible at startup.
eps rendering enabledandmetafile conversion enabledlogrlimit_as(the base, 0 = none),emulation(rosetta,qemuor empty) andrlimit_as_off(disabledoremulation: <name>) next to the other layers, and the warningsandbox without an address-space limitnames 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.