Treat a document conversion as running hostile input through a large C or C++ program. Before any parser sees the file, take its type from the bytes; for ZIP-based formats, count the bytes inflation actually produces, cap the members and reject unsafe names, DTDs and macros. Convert each document in a fresh container with --network=none, a read-only root, a size-limited tmpfs, a non-root user, --cap-drop=ALL, no-new-privileges, the default seccomp profile and limits on memory, CPU, processes and file size. Kill the container, not just the client, at a deadline, and accept only output in the format you asked for. When people will open the result, re-render the pages to pixels.
- Office files are ZIP archives of XML, so zip bombs, path tricks and XML entities all apply; PDFs add scripts and decades of parser memory bugs.
- Count inflated bytes while reading each member: ASVS 5.0 V5.2.3 asks for an uncompressed-size and file-count check for files such as DOCX and ODT before they are uncompressed.
- Refuse every DTD in the package's XML: ECMA-376 forbids them in package markup, and the DOCX and ODT files LibreOffice writes have none outside ODF macro libraries.
- No network in the converter turns linked images, remote frames and external entities into failed lookups instead of server-side requests.
- At the deadline,
docker killthe container by name: in our tests, killing thedocker runclient left the container running. - Ghostscript has run with
-dSAFERby default since 9.50, and SAFER has still been bypassed: never hand it PostScript, and patch quickly. - Pixel re-rendering, as Dangerzone does, removes active content, but also text, links and forms unless you add OCR.
| Setting | Value | Note | Source |
|---|---|---|---|
| Converter network | --network=none | Only a loopback interface exists, so linked resources, cloud metadata endpoints and internal services are unreachable. | Docker: none network driver |
| Converter filesystem | --read-only plus --tmpfs /tmp:rw,nosuid,nodev,noexec,size=256m | Nothing from the host is mounted: the document arrives on stdin and the result leaves on stdout. | Docker: docker container run reference |
| Converter identity and privileges | Numeric non-root user, --cap-drop=ALL, --security-opt=no-new-privileges, Docker's default seccomp profile | Never --privileged or seccomp=unconfined. | Docker: seccomp security profiles |
| Converter resources | --memory=1g --memory-swap=1g --cpus=1 --pids-limit=128 --ulimit fsize=134217728 | Equal memory and swap values mean no swap. fsize is in bytes, unlike the shell's ulimit -f. tmpfs contents count against the memory limit. | Docker: docker container run reference |
| Deadline | 60 seconds per document, then docker kill <name> | Size it from your largest legitimate document; a 17 KB page bomb kept LibreOffice busy for more than two minutes. | — |
| Package checks | At most 1,000 members and 100 MiB inflated, counted while reading; no DTDs; no macros | Apache POI's ZipSecureFile also defaults to 1,000 entries. | OWASP ASVS 5.0.0 V5.2.3; ECMA-376 Part 2 |
| LibreOffice | soffice --headless --convert-to pdf --infilter=<filter> with -env:UserInstallation on the tmpfs | Forcing the import filter stops type detection from routing the bytes to a different import filter. | LibreOffice help: start parameters |
| Ghostscript | -dSAFER written out (the default since 9.50), and PDF input only | SAFER restricts file access and devices; it does not sandbox the interpreter. | Ghostscript documentation: -dSAFER |
| PDF.js in the browser | 4.2.67 or later; isEvalSupported: false on releases before 5.7.284 | 5.7.284 removed the option along with the last use of eval. | GHSA-wgrm-67xf-hhpq; pdf.js pull request 21005 |
| ImageMagick | A policy.xml that denies the coders you do not use, including PS, EPS, PDF and XPS | Check the active policy with magick identify -list policy. | ImageMagick: security policy |
- Send a DOCX whose member inflates past your budget and confirm it is rejected before any container starts:
docker ps -ashows nothing new. - Send a DOCX with
<!DOCTYPEinword/document.xml, and the same part saved as UTF-16, and confirm preflight rejects both. - Run a connection attempt with the converter's exact flags and confirm it fails, then confirm the same command succeeds with networking on, so the check can fail.
- Convert a document that outlasts the deadline and confirm
docker ps -a --filter name=<job>is empty afterwards. - During a conversion, confirm with
docker inspectthatHostConfig.ReadonlyRootfsis true,NetworkModeisnone,CapDropisALLandSecurityOptincludesno-new-privileges. - Compare the converter image's LibreOffice and Ghostscript package versions with the vendor and distribution advisories at least monthly.
- Applies to
- Python (preflight) 3.11.17, 3.12.15, 3.13.16, 3.14.8 and later, for the zipfile fix CVE-2026-15310
- LibreOffice 26.2.5 and later, 26.8, or a distribution build with the fixes backported
- Ghostscript 10.08.0, or a distribution build with the fixes backported
- PDF.js 4.2.67 and later
- Apache Tika 3.2.2 and later
Services that convert, preview, index or extract text from user-supplied DOCX, XLSX, PPTX, ODF and PDF files with LibreOffice, Ghostscript, ImageMagick, Apache Tika or PDF.js. The isolation flags are Docker's; Kubernetes and other runtimes have equivalents.
- Part of
- Workflows: Fix a finding, Secure a feature
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Name what a DOCX, XLSX, ODT or PDF can make a parser do, with an advisory for each.
- Check a package before parsing it: real type, member names, inflated bytes, DTDs and macros.
- Run each conversion in a container with no network, a read-only root and hard limits, and kill it at a deadline.
- Choose between format conversion with output checks and re-rendering to pixels.
Before you start
Backend and platform engineers who build previews, search indexing or PDF export from user files, and AppSec engineers who review those pipelines.
- Know how an upload pipeline quarantines files before it publishes them.
- Run containers with Docker or Kubernetes.
- For the project: Python 3.11 or later, and Docker for the live tests.
Refresh first: Design a safer file-upload pipeline
Go to the guided practiceOne conversion, one disposable container
- Receive into quarantineThe upload is stored privately under a server-generated name; nothing is published yet.
- Check the packageThe worker takes the type from the bytes and, for ZIP formats, counts members and inflated bytes and refuses unsafe names, DTDs and macros, before any parser runs.
- Convert in a fresh containerThe document goes in on stdin to a container with no network, a read-only root, no capabilities and limits on memory, CPU, processes and file size.
- Stop at the deadlineIf the converter is still running at the deadline, the worker kills the container by name, then the client.
- Check what comes backOutput that is not the requested format, or is too large, is discarded; only checked output is stored for publishing.
A DOCX, XLSX or PPTX file is a ZIP package of XML parts, defined by the Open Packaging Conventions in ECMA-376 Part 2, and ODT and ODS files are ZIP packages too. A PDF is a graph of objects and compressed streams that can carry JavaScript, embedded files and font programs. Converting either means feeding attacker-controlled bytes to LibreOffice, Ghostscript, Apache Tika or a PDF library: millions of lines of C, C++ and Java that read dozens of formats. Every row below has a published advisory.
The parser does not have to be taken over for a conversion to leak data. A converter that follows a link makes a request from inside your network, the server-side request forgery described in /domains/appsec/secure-features/ssrf-egress-boundaries/, and one that reads a local file into the output hands it to the uploader. LibreOffice's recent fixes put such links under its link-update control; a converter with no network and nothing secret on its filesystem takes most of the value out of the ones not yet found.
| Threat | How it arrives | Published example | Control |
|---|---|---|---|
| Macros | VBA projects in .docm and .xlsm, Basic libraries in ODF, scripts bound to links and clicks | LibreOffice CVE-2024-3044: a script bound to a graphic's click ran without the macro check, fixed in 7.6.7 and 24.2.3 | Reject documents that carry macros; keep the converter current |
| XML entities | A DTD in document.xml, content.xml or another XML part, or XFA data inside a PDF | Apache Tika CVE-2025-54988 and CVE-2025-66516: XXE through crafted XFA in a PDF, fixed in 3.2.2 | Refuse every DTD; parse XML with entities off |
| Zip bombs | A member that inflates about 1,000 to 1, or overlapping members that reuse one compressed block | David Fifield's 42 kB zip expands to 5.5 GB; renamed to .docx, his 10 MB variant made LibreOffice 6.1.5.2 create and delete temporary files of about 4 GB while it detected the file type | Count inflated bytes and members; limit file size and memory in the container |
| Path tricks | ../ member names, embedded font names used as paths, two members with one name | LibreOffice CVE-2024-12425: embedded font names could write a .ttf file to any location the user could write, fixed in 24.8.4; Apache POI CVE-2025-31672: duplicate entry names, fixed in 5.4.0 | Reject unsafe and duplicate names; give the converter nothing writable but a tmpfs |
| Remote fetches | Linked images, floating frames, external data ranges, linked media and remote templates | LibreOffice CVE-2023-2255 (floating frames loaded remote documents without a prompt), CVE-2024-12426 and CVE-2026-63278 (URLs that leaked environment variables) and CVE-2026-63267 (a Calc csv link read local files or fetched a URL on load) | No network in the container; no secrets in its environment or filesystem |
| Parser bugs | Malformed records that corrupt memory in any of dozens of import filters | LibreOffice CVE-2026-6047, a heap overflow in DOCX text box import, fixed in 25.8.7 and 26.2.3; Ghostscript CVE-2024-29510, a format string bug that bypassed SAFER, fixed in 10.03.1 | Patch quickly, force the import filter and assume the parser can be taken over |
| PDF scripts | Document actions, form scripts and font programs | PDF.js CVE-2024-4367: a missing type check in font handling let a PDF run JavaScript in the viewer's context, before 4.2.67 | Keep viewers current, turn off form scripting you do not need and re-render to pixels |
Decide which formats the feature needs and recognize them from the bytes: %PDF- at the start for PDF, a ZIP whose first member is a stored mimetype file for ODF, and a ZIP with [Content_Types].xml and word/document.xml or xl/workbook.xml for OOXML. Refuse everything else, including legacy binary .doc files, RTF, HTML and PostScript, which Ghostscript runs as a program. The extension and the claimed media type decide nothing.
For ZIP formats, read each member through the decompressor and count the bytes it actually produces against a per-member and a total budget, so a bomb stops at the limit instead of at the disk. In Python that count holds only on an interpreter with the CVE-2026-15310 fix (3.11.17, 3.12.15, 3.13.16, 3.14.8 or later): before it, zipfile could pre-allocate memory from an attacker-controlled size for bzip2, LZMA and Zstandard members before returning the first chunk, and the fix does not cover third-party decompressors patched into zipfile. If the formats you accept need only stored and deflated members, refusing other compress_type values removes that path on any version. Reject absolute names, .. segments, backslashes, control characters, duplicates and encrypted members. Parse every XML part with Expat and refuse any document type declaration. ECMA-376 Part 2 says DTD declarations shall not be used in the package's own markup (content types, relationships, core properties) and, in its 4th edition, that implementers shall treat them as an error (requirement M1.18). Document parts written by LibreOffice have none either, and LibreOffice 25.2 refused to load DOCX files that declared entities. A byte search for <!DOCTYPE is not enough, because the standard allows UTF-16 XML.
The code below is the preflight of the project's reference worker, with its sniff() and _check_name() helpers left out. Its tests reject a 66 KB DOCX that inflates to 64 MiB, stopping at a 16 MiB budget; three DTD payloads, one of them in a UTF-16 part; a ../ member; 1,200 members; and an ODT with a Basic library. They accept the DOCX and ODT files LibreOffice itself writes. Refusing macros also refuses ODF documents with Basic libraries, which LibreOffice still writes with a DOCTYPE.
# worker.py (excerpt): checks that run before any parser sees the upload
import io
import xml.parsers.expat
import zipfile
def _xml_checker(name: str):
"""An Expat parser that refuses any DTD, so no entity, internal or external, is declared."""
parser = xml.parsers.expat.ParserCreate()
def refuse(*_):
raise Rejected(f"{name}: DTDs are not accepted")
parser.StartDoctypeDeclHandler = refuse
parser.EntityDeclHandler = refuse
parser.SetParamEntityParsing(xml.parsers.expat.XML_PARAM_ENTITY_PARSING_NEVER)
return parser
def preflight(data: bytes, limits: Limits = Limits()) -> str:
"""Reject what no converter should see; return the document kind."""
if len(data) > limits.max_input_bytes:
raise Rejected("input too large")
kind = sniff(data)
if kind == "pdf":
return kind # the PDF itself is parsed only inside the sandbox
expanded = 0
seen = set()
try:
with zipfile.ZipFile(io.BytesIO(data)) as package:
members = package.infolist()
if len(members) > limits.max_members:
raise Rejected("too many members")
for member in members:
name = member.filename
_check_name(name)
if name in seen:
raise Rejected(f"duplicate member {name!r}")
seen.add(name)
if member.flag_bits & 0x1:
raise Rejected("encrypted members are not accepted")
if name.lower().endswith("vbaproject.bin") or name.startswith(("Basic/", "Scripts/")):
raise Rejected("documents with macros are not accepted")
checker = _xml_checker(name) if name.endswith((".xml", ".rels")) else None
size = 0
with package.open(member) as stream:
# Count the bytes that inflate actually produces; never trust header sizes.
while chunk := stream.read(64 * 1024):
size += len(chunk)
expanded += len(chunk)
if size > limits.max_member_bytes or expanded > limits.max_expanded_bytes:
raise Rejected("expanded size over the limit (zip bomb?)")
if checker:
checker.Parse(chunk, False)
if checker:
checker.Parse(b"", True)
except (zipfile.BadZipFile, xml.parsers.expat.ExpatError, NotImplementedError) as error:
raise Rejected(f"damaged package: {error}") from None
return kindAssume the parser will be taken over, and decide what it can reach when it is. Start a fresh container for each document, so nothing one document does can affect the next, and pass the document on stdin and the result on stdout, so nothing from the host is mounted. The command below is what the project's sandbox_argv() builds; each flag closes a path that the threats above use.
A container shares the host kernel, so a kernel exploit escapes it. For hostile input from many tenants, run the same container under gVisor with --runtime=runsc, as Dangerzone does, or in a microVM, and keep conversion hosts free of credentials, routes to internal services and access to the Docker socket. In Kubernetes the same intent is a Job per document with activeDeadlineSeconds, automountServiceAccountToken: false, a NetworkPolicy that denies egress (enforced only if your network plugin implements it), readOnlyRootFilesystem: true, runAsNonRoot with a numeric runAsUser, capabilities.drop: [ALL], seccompProfile.type: RuntimeDefault, memory and CPU limits, an emptyDir with a sizeLimit, and runtimeClassName for a sandboxed runtime where the cluster offers one.
--network=noneleaves only a loopback interface. In the project's live test, a connection from inside the sandbox failed withNetwork is unreachable, and the same probe with networking on reached the test server, so the test can fail.--read-onlywith a fixed-size tmpfs lets the converter write only scratch files, only up to the size you set, andnoexecstops it running what it writes.- A numeric non-root user,
--cap-drop=ALLand--security-opt=no-new-privilegesremove the privileges an exploit would use next. Docker's default seccomp profile blocks about 44 system calls, includingmount,unshareandbpf. --memoryequal to--memory-swap,--cpusand--pids-limitbound memory, CPU and process count.--ulimit fsizecaps any single file the converter writes, in bytes: fed the 256 MiB bomb directly, LibreOffice failed to load it once its temporary copy reached 128 MiB. With the file-size and tmpfs limits both lifted, Docker reported the container OOM-killed within three seconds instead, because tmpfs pages count against the container's memory limit.--log-driver=nonekeeps the converted document out of Docker's container logs, since it travels on stdout.- Pin the converter image by digest and rebuild it on a schedule. LibreOffice published more than 20 advisories between January and October 2026, and distributions backport fixes into older versions: Debian 13's 25.2.3 build fixed CVE-2026-63267 in 4:25.2.3-2+deb13u8, so check the distribution's tracker rather than the upstream version number.
# One document, one container: the flags the worker's sandbox_argv() builds
docker run --rm -i --init --name "convert-$job" \
--network=none \
--read-only --tmpfs /tmp:rw,nosuid,nodev,noexec,size=256m \
--user 10001:10001 --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=1g --memory-swap=1g --cpus=1 \
--ulimit fsize=134217728 --ulimit nofile=1024:1024 --ulimit core=0 \
--log-driver=none \
"registry.example.com/doc-converter@sha256:$digest" \
sh -c 'cat > /tmp/in.docx && mkdir /tmp/out &&
soffice --headless --norestore -env:UserInstallation=file:///tmp/profile \
--infilter="MS Word 2007 XML" --convert-to pdf --outdir /tmp/out /tmp/in.docx >&2 &&
cat /tmp/out/in.pdf' < upload.docx > preview.pdfA converter that never finishes is a denial of service, and a hung parser may be an exploit at work. A 17 KB DOCX with 100,000 page breaks passed every preflight check and kept LibreOffice laying out pages for more than two minutes, and a two-line PostScript file ending in { } loop keeps Ghostscript busy forever. Set the deadline from your largest legitimate document, not from the upload size.
Kill the container, not the client. Python's subprocess.run(timeout=...) kills the docker run process, and in the project's tests the container behind it kept running: the starter worker left two hung containers. Name each container, run docker kill <name> at the deadline, then reap the client, and remove the name once more in case the container started after the client died. The function below does that and also caps how much output it will read.
Then check the output against what you asked for: a PDF starts with %PDF- and ends with %%EOF, a PNG has the PNG signature and an IHDR chunk with sensible dimensions, text decodes as UTF-8, and nothing is larger than the output budget. That proves the format, not that the content is harmless: a converted PDF still carries the attacker's text and links. Serve results from a separate origin as downloads, as /domains/appsec/secure-features/secure-file-uploads/ describes, or re-render them to pixels.
# worker.py (excerpt): kill the container, not only the client, at the deadline
import subprocess
import threading
def run_with_deadline(argv: list, data: bytes, name: str, limits: Limits = Limits(),
docker=("docker",)) -> bytes:
"""Run the container; at the deadline kill the container itself, then the client."""
out = {}
def feed(stream):
try:
stream.write(data)
stream.close()
except (BrokenPipeError, ValueError):
pass
def read(stream, key, cap):
out[key] = stream.read(cap)
while stream.read(64 * 1024): # drain so the container never blocks on a full pipe
out[key + "_over"] = True
with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE) as proc:
threads = [
threading.Thread(target=feed, args=(proc.stdin,), daemon=True),
threading.Thread(target=read, args=(proc.stdout, "out", limits.max_output_bytes + 1),
daemon=True),
threading.Thread(target=read, args=(proc.stderr, "err", 16 * 1024), daemon=True),
]
for thread in threads:
thread.start()
try:
status = proc.wait(timeout=limits.timeout_seconds)
except subprocess.TimeoutExpired:
# Killing the docker client does not stop the container: kill the container by name.
subprocess.run([*docker, "kill", name], capture_output=True, timeout=30)
proc.kill()
proc.wait()
# If the client died before the container started, remove the late container too.
subprocess.run([*docker, "rm", "--force", name], capture_output=True, timeout=30)
raise Rejected(f"timed out after {limits.timeout_seconds:g} s; container killed")
finally:
for thread in threads:
thread.join(timeout=5)
if status != 0:
raise Rejected(f"converter failed with exit status {status}")
result = out.get("out", b"")
if out.get("out_over") or len(result) > limits.max_output_bytes:
raise Rejected("output too large")
return resultConfiguration narrows what each tool will do, and none of it replaces the container. LibreOffice's DisableMacrosExecution setting (off by default) turns off Basic, BeanShell, JavaScript and Python macros entirely, and in the conversion path LibreOffice's dispatcher does not pass the macro-execution and link-update arguments it sets when it opens a document for viewing. With LibreOffice 25.2, --convert-to fetched none of the linked images, sections, WEBSERVICE formulas or external DTDs we tried, even with networking on. Later advisories still fixed fetches and memory bugs, so treat these behaviors as version-specific. When your code builds a converter's command line, pass it as an argument list with no shell and put file names after -- (/domains/appsec/secure-coding/os-command-injection/).
| Tool | Setting | Effect | Watch for |
|---|---|---|---|
| LibreOffice | soffice --headless --convert-to pdf --infilter="MS Word 2007 XML" -env:UserInstallation=file:///tmp/profile | Batch conversion with a forced import filter and a throwaway profile on the tmpfs | The 25.8 line reached end of life on 12 June 2026. Run 26.2.5 or later or 26.8, or a distribution build with the 2026 fixes |
| Ghostscript | gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r96 -dFirstPage=1 -dLastPage=1 | SAFER restricts file access, device selection and some device parameters; it has been the default since 9.50 | SAFER is not a sandbox: CVE-2023-36664 (mishandled permission checks for pipe devices), CVE-2024-29510 (a SAFER bypass through the uniprint device) and CVE-2026-101258 (a SAFER bypass from a crafted PostScript or EPS file). Accept only PDF, and isolate the process |
| ImageMagick | policy.xml with <policy domain="coder" rights="none" pattern="{PS,PS2,PS3,EPS,PDF,XPS}" />, a delegate deny and resource limits | The model is everything allowed unless denied, and the last matching policy wins; since 7.1.1-16 a build can select the limited, secure or websafe policy instead of the default open | PDF, PS and EPS are read through Ghostscript. ImageTragick (CVE-2016-3714) ran shell commands through coders. Check with magick identify -list policy |
| PDF.js | getDocument({url, isEvalSupported: false}) on releases up to 5.6; the viewer's enableScripting option | isEvalSupported stopped PDF.js compiling code with eval; enableScripting runs document JavaScript in a QuickJS sandbox and is on in the generic viewer | CVE-2024-4367 was fixed in 4.2.67. 5.7.284 removed isEvalSupported. Turn off enableScripting unless you need form scripts |
| Apache Tika | tika-pipes in 4.x, or tika-server (which forks a child JVM by default since 2.x) in 3.x, with task timeouts and an -Xmx for the forked JVM | Isolates crashes, hangs and memory blowups from the calling service | Tika says it is not a security boundary and to assume a hostile file may take over the parsing process. Upgrade tika-core and the PDF module together to 3.2.2 or later |
Format conversion keeps the document's content: its text, links and anything a later viewer will interpret. The strongest sanitization throws all of that away. Dangerzone, from the Freedom of the Press Foundation, converts a document to PDF with LibreOffice inside a container with no network, renders each page to raw RGB pixels with PyMuPDF, and lets only page sizes and pixels leave the sandbox, read with a maximum page count and page size. Outside the sandbox it builds a new PDF from the pixels and can add a text layer with OCR. The conversion runs under gVisor, and the project's security model assumes LibreOffice and PyMuPDF have undiscovered vulnerabilities: an exploit that takes over LibreOffice still has to break out of gVisor and then the container.
The cost is everything that is not pixels. Links, forms, bookmarks, embedded files and metadata are gone, text is searchable and selectable only after OCR, which is never perfect, and the files are larger. Re-render documents that people will open, such as attachments passed to staff, and use format conversion with output checks for indexing and previews whose output never reaches a viewer that runs code. Dangerzone 0.11.0 itself is an application for people's own computers, with Podman containers as the sandbox; on a server, the same design works with the container flags above plus a gVisor runtime.
The document-conversion-sandbox project below runs these cases against LibreOffice 25.2.3 and Ghostscript 10.05.1 in Docker. The starter worker trusts the upload once its size is fine, leaves the network on and the root filesystem writable, and gives up at the deadline by killing only the client.
| Input | Starter worker | Reference worker |
|---|---|---|
| DOCX of 262 KB that inflates to 256 MiB | Passed preflight; the container was killed (exit status 137) | Rejected at preflight; fed straight to LibreOffice, the load failed at the 128 MiB file-size limit |
| DOCX declaring an external entity: a local file, a URL or a parameter entity | Passed preflight; LibreOffice 25.2 refused to load it | Rejected at preflight. Either way the test's canary server received no request |
| DOCX with a linked image, and a network probe from inside the sandbox | The probe could open connections | Converted with no request to the canary; the probe failed with Network is unreachable |
| DOCX of 17 KB with 100,000 page breaks | The container kept running after the deadline | Container killed at the 15-second deadline, none left afterwards |
PostScript { } loop presented as a PDF | Rejected by the type check, which both workers have; sent to Ghostscript directly, the container kept running | Rejected at preflight; sent to Ghostscript directly, killed at the deadline |
Most broken conversion pipelines trust one layer to do the work of all of them.
- Limiting only the upload size: the 262 KB file above inflated to 256 MiB.
- Trusting the sizes in the ZIP directory instead of counting what the decompressor produces.
- Searching for
<!DOCTYPEas bytes, which misses UTF-16 parts. - Mounting a host directory for input or output, which gives an exploit a writable path out of the container.
- Sending many users' documents through one long-running converter process or container, so one malicious document can read or alter the next.
- Handing files named
.pdfto Ghostscript without checking the header: Ghostscript runs the PostScript it finds. - Leaving credentials, tokens or cloud metadata reachable from the converter: CVE-2024-12426 showed a document reading environment variables into a URL.
- Pinning the converter image and never rebuilding it.
Guided practice
A support portal makes PDF previews of customer attachments. Decide what the reference worker does with each upload, which control decides it and what is published. Its limits: 25 MiB upload, 1,000 members, 64 MiB per member and 100 MiB inflated in total, no DTDs, no macros, a 60-second deadline, 1 GiB of memory and 128 MiB per file.
Supplied scenario material
U1: invoice.docx, 48 KB, 9 members, 210 KB inflated, no DTD.
U2: report.docx, 300 KB; word/document.xml inflates to 2 GiB.
U3: notes.odt; content.xml starts with <!DOCTYPE office:document-content [<!ENTITY x SYSTEM "http://169.254.169.254/latest/meta-data/">]>.
U4: budget.xlsm with xl/vbaProject.bin.
U5: scan.pdf, 4 MB, whose first bytes are %!PS-Adobe-3.0.
U6: manual.docx, 60 KB, 400,000 paragraphs that each end in a page break, 30 MB inflated.
U7: brochure.pdf, 6 MB, starts with %PDF-1.7 and has a JavaScript OpenAction.1. Sort by preflight
Which uploads stop before any container starts, and on which check?
Expected result and reasoning
U2 stops on the inflated-byte count once document.xml passes 64 MiB, U3 on the DTD, U4 on the macro storage and U5 on the type check, because the bytes are PostScript. U1, U6 and U7 go on to a converter.
2. Predict the container outcome
What happens to U6 and U7 in the sandbox?
Expected result and reasoning
U6 passes preflight, since 30 MB is inside both budgets, but layout runs past 60 seconds, so the worker kills the container and rejects the upload. U7 is rendered by Ghostscript, which has no JavaScript engine, so the OpenAction never runs on the server; the original stays untrusted for anyone who opens it.
3. Bound the damage
Suppose U1 exploits a heap overflow in LibreOffice's DOCX import. What can the attacker reach?
Expected result and reasoning
Only the container: no network, a read-only root, a 256 MiB tmpfs, a non-root user with no capabilities and one document on stdin. They can corrupt this conversion's output, which the output checks may catch, but cannot read other uploads, call internal services or stay for the next job.
4. Choose what to publish
Staff will open the preview of U7 in their browsers. What do you publish?
Expected result and reasoning
A re-rendered version (a PNG per page, or a PDF rebuilt from pixels) served from the separate user-content origin, not the original PDF.
Paper exercise. The document-conversion-sandbox project runs the zip bomb, DTD, network and deadline cases against real LibreOffice and Ghostscript containers.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Convert untrusted documents in a locked-down container
Repair a document worker that drives LibreOffice and Ghostscript in Docker, until a zip bomb and DTD payloads stop at preflight, the container has no network or writable root, and a hung conversion is killed with its container.
Needs: The live tests need Docker with Linux containers, and internet access once to build the converter image (about 200 MB of Debian packages). The offline tests need only Python.
In the extracted folder, run:
python3 -m unittest -v test_worker.py
docker build -t doc-converter .
python3 -m unittest -v test_live.py12 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
The offline tests use a stand-in for the docker CLI. The 8 live tests ran against LibreOffice 25.2.3 and Ghostscript 10.05.1 in Docker 29.4.3: zip bomb, XXE, network and timeout cases. It is not content disarm and reconstruction, and output checks do not inspect PDF content.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- ECMA-376 Part 2: Open Packaging Conventions (DTD declarations, requirement M1.18). 4th edition (December 2012) and 5th edition (December 2021).
- OWASP ASVS 5.0.0: V5 File Handling. Version 5.0.0.
- OWASP Cheat Sheet Series: XML External Entity Prevention
- LibreOffice security advisories
- LibreOffice help: starting LibreOffice with parameters
- LibreOffice source: dispatchwatcher.cxx, the load arguments for conversion and viewing
- Debian security tracker: CVE-2026-63267 in libreoffice
- Ghostscript documentation: -dSAFER. Ghostscript 10.08.0.
- NVD: CVE-2024-29510, Ghostscript SAFER bypass via uniprint
- Debian security tracker: CVE-2026-101258 in ghostscript
- PDF.js advisory GHSA-wgrm-67xf-hhpq (CVE-2024-4367)
- PDF.js pull request 21005: remove isEvalSupported. Merged 4 April 2026, released in 5.7.284.
- ImageMagick: security policy. ImageMagick 7.1.2-33.
- Apache Tika: security model
- NVD: CVE-2025-66516, Apache Tika XXE through XFA
- Dangerzone: how Dangerzone works
- Dangerzone: security model
- Dangerzone README. 0.11.0.
- David Fifield: A better zip bomb. USENIX WOOT 2019.
- CVE-2026-15310: zipfile bzip2, LZMA and Zstandard members decompress without a max_length bound (CVE record)
- Python 3.11.17 release notes (CVE-2026-15310 fix and its third-party decompressor caveat). 1 October 2026.
- Docker: docker container run reference
- Docker: seccomp security profiles
- Docker: none network driver
Reference information
Checked against LibreOffice 26.8.1 and 26.2.6, Debian 13's LibreOffice 25.2.3 (4:25.2.3-2+deb13u8) and Ghostscript 10.05.1 (10.05.1~dfsg-1+deb13u3), Ghostscript 10.08.0, PDF.js 6.4.299, ImageMagick 7.1.2-33, Apache Tika 4.1.0, Dangerzone 0.11.0 and Docker 29.4.3.
- Tags
- Input validation and parsingFile uploads and archivesDenial of service and resource limitsMemory safety
- SDLC phase
- DesignDevelopment
- ASVS 5.0.0 requirements
- v5.0.0-5.2.2 · v5.0.0-5.2.3 · v5.0.0-1.5.1 · v5.0.0-15.2.5