In short

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 kill the container by name: in our tests, killing the docker run client left the container running.
  • Ghostscript has run with -dSAFER by 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.

Verify it

  1. Send a DOCX whose member inflates past your budget and confirm it is rejected before any container starts: docker ps -a shows nothing new.
  2. Send a DOCX with <!DOCTYPE in word/document.xml, and the same part saved as UTF-16, and confirm preflight rejects both.
  3. 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.
  4. Convert a document that outlasts the deadline and confirm docker ps -a --filter name=<job> is empty afterwards.
  5. During a conversion, confirm with docker inspect that HostConfig.ReadonlyRootfs is true, NetworkMode is none, CapDrop is ALL and SecurityOpt includes no-new-privileges.
  6. 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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 40 minutes: 15 to read and 25 for the exercise

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 practice

One conversion, one disposable container

No network, read-onlyUploaderConversion workerConvertercontainerStorageDOCX, XLSX, ODT or PDFupload1Type, members,inflated bytes,DTDs2Document on stdin3docker kill at thedeadline4PDF, text or PNG onstdout5Checked output only5Trust boundaryUntrusted input1Step, described below
  1. Receive into quarantineThe upload is stored privately under a server-generated name; nothing is published yet.
  2. 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.
  3. 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.
  4. Stop at the deadlineIf the converter is still running at the deadline, the worker kills the container by name, then the client.
  5. Check what comes backOutput that is not the requested format, or is too large, is discarded; only checked output is stored for publishing.
The converter sees attacker bytes only inside the container, and nothing it returns is trusted until it is checked.

What a document can make a parser do

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.

What a document can make a parser do
ThreatHow it arrivesPublished exampleControl
MacrosVBA projects in .docm and .xlsm, Basic libraries in ODF, scripts bound to links and clicksLibreOffice CVE-2024-3044: a script bound to a graphic's click ran without the macro check, fixed in 7.6.7 and 24.2.3Reject documents that carry macros; keep the converter current
XML entitiesA DTD in document.xml, content.xml or another XML part, or XFA data inside a PDFApache Tika CVE-2025-54988 and CVE-2025-66516: XXE through crafted XFA in a PDF, fixed in 3.2.2Refuse every DTD; parse XML with entities off
Zip bombsA member that inflates about 1,000 to 1, or overlapping members that reuse one compressed blockDavid 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 typeCount 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 nameLibreOffice 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.0Reject unsafe and duplicate names; give the converter nothing writable but a tmpfs
Remote fetchesLinked images, floating frames, external data ranges, linked media and remote templatesLibreOffice 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 bugsMalformed records that corrupt memory in any of dozens of import filtersLibreOffice 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.1Patch quickly, force the import filter and assume the parser can be taken over
PDF scriptsDocument actions, form scripts and font programsPDF.js CVE-2024-4367: a missing type check in font handling let a PDF run JavaScript in the viewer's context, before 4.2.67Keep viewers current, turn off form scripting you do not need and re-render to pixels

Check the package before any parser reads it

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.

Fixedworker.pyPython
# 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 kind

Run each document in its own locked-down container

Assume 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=none leaves only a loopback interface. In the project's live test, a connection from inside the sandbox failed with Network is unreachable, and the same probe with networking on reached the test server, so the test can fail.
  • --read-only with a fixed-size tmpfs lets the converter write only scratch files, only up to the size you set, and noexec stops it running what it writes.
  • A numeric non-root user, --cap-drop=ALL and --security-opt=no-new-privileges remove the privileges an exploit would use next. Docker's default seccomp profile blocks about 44 system calls, including mount, unshare and bpf.
  • --memory equal to --memory-swap, --cpus and --pids-limit bound memory, CPU and process count. --ulimit fsize caps 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=none keeps 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.
Configurationconvert one documentShell
# 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.pdf

Kill the container at the deadline, then check the output

A 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.

Fixedworker.pyPython
# 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 result

Settings for the common converters

Configuration 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/).

Settings for the common converters
ToolSettingEffectWatch for
LibreOfficesoffice --headless --convert-to pdf --infilter="MS Word 2007 XML" -env:UserInstallation=file:///tmp/profileBatch conversion with a forced import filter and a throwaway profile on the tmpfsThe 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
Ghostscriptgs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r96 -dFirstPage=1 -dLastPage=1SAFER restricts file access, device selection and some device parameters; it has been the default since 9.50SAFER 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
ImageMagickpolicy.xml with <policy domain="coder" rights="none" pattern="{PS,PS2,PS3,EPS,PDF,XPS}" />, a delegate deny and resource limitsThe 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 openPDF, PS and EPS are read through Ghostscript. ImageTragick (CVE-2016-3714) ran shell commands through coders. Check with magick identify -list policy
PDF.jsgetDocument({url, isEvalSupported: false}) on releases up to 5.6; the viewer's enableScripting optionisEvalSupported stopped PDF.js compiling code with eval; enableScripting runs document JavaScript in a QuickJS sandbox and is on in the generic viewerCVE-2024-4367 was fixed in 4.2.67. 5.7.284 removed isEvalSupported. Turn off enableScripting unless you need form scripts
Apache Tikatika-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 JVMIsolates crashes, hangs and memory blowups from the calling serviceTika 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

Re-render to pixels when people will open the result

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.

What the project's live tests showed

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.

What the project's live tests showed
InputStarter workerReference worker
DOCX of 262 KB that inflates to 256 MiBPassed 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 entityPassed preflight; LibreOffice 25.2 refused to load itRejected at preflight. Either way the test's canary server received no request
DOCX with a linked image, and a network probe from inside the sandboxThe probe could open connectionsConverted with no request to the canary; the probe failed with Network is unreachable
DOCX of 17 KB with 100,000 page breaksThe container kept running after the deadlineContainer killed at the 15-second deadline, none left afterwards
PostScript { } loop presented as a PDFRejected by the type check, which both workers have; sent to Ghostscript directly, the container kept runningRejected at preflight; sent to Ghostscript directly, killed at the deadline

Pitfalls

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 <!DOCTYPE as 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 .pdf to 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
Plain text
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. 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. 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. 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. 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.

Your deliverable: A table for U1 to U7: preflight result, container outcome, the check or limit that decided it, and what is published.

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.

Python 3.11+ offline · Docker for the live tests · 45–75 min

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:

Shell
python3 -m unittest -v test_worker.py
docker build -t doc-converter .
python3 -m unittest -v test_live.py

12 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.

  1. ECMA-376 Part 2: Open Packaging Conventions (DTD declarations, requirement M1.18). 4th edition (December 2012) and 5th edition (December 2021).
  2. OWASP ASVS 5.0.0: V5 File Handling. Version 5.0.0.
  3. OWASP Cheat Sheet Series: XML External Entity Prevention
  4. LibreOffice security advisories
  5. LibreOffice help: starting LibreOffice with parameters
  6. LibreOffice source: dispatchwatcher.cxx, the load arguments for conversion and viewing
  7. Debian security tracker: CVE-2026-63267 in libreoffice
  8. Ghostscript documentation: -dSAFER. Ghostscript 10.08.0.
  9. NVD: CVE-2024-29510, Ghostscript SAFER bypass via uniprint
  10. Debian security tracker: CVE-2026-101258 in ghostscript
  11. PDF.js advisory GHSA-wgrm-67xf-hhpq (CVE-2024-4367)
  12. PDF.js pull request 21005: remove isEvalSupported. Merged 4 April 2026, released in 5.7.284.
  13. ImageMagick: security policy. ImageMagick 7.1.2-33.
  14. Apache Tika: security model
  15. NVD: CVE-2025-66516, Apache Tika XXE through XFA
  16. Dangerzone: how Dangerzone works
  17. Dangerzone: security model
  18. Dangerzone README. 0.11.0.
  19. David Fifield: A better zip bomb. USENIX WOOT 2019.
  20. CVE-2026-15310: zipfile bzip2, LZMA and Zstandard members decompress without a max_length bound (CVE record)
  21. Python 3.11.17 release notes (CVE-2026-15310 fix and its third-party decompressor caveat). 1 October 2026.
  22. Docker: docker container run reference
  23. Docker: seccomp security profiles
  24. 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.

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

Download this guide as Markdown