Hostile by Default

Hostile by Default

The threat model is one sentence: every input document is untrusted bytes. A converter is a parser farm, and parser farms are where memory bugs, hangs, and resource exhaustion live. zenfmt’s answer is not cleverness. It is arithmetic: every allocation bounded, every loop bounded, every expansion budgeted, and every refusal explained to the person holding the file with the exact bound and a safe next action.

The limits table

Every named bound lives in one struct, core/src/limits.zig. Nothing else in the tree hard-codes a resource number. Each field is overridable from the command line, with one exception explained below.

LimitDefaultWhat it bounds
max_input_bytes512 MiBbytes read from one input document
max_depth256nesting of either node tree, and the size of every explicit walker stack
max_archive_entries4,096entries admitted from one archive central directory
max_entry_uncompressed256 MiBexpansion of one archive entry
max_total_uncompressed1 GiBexpansion of all read entries together
max_compression_ratio200expansion ratio, checked during streaming decompression
max_entry_name_bytes1,024length of one archive entry name
max_xml_depth256XML element nesting
max_scan_chunk_bytes1 MiBscanner scratch per chunk
max_manifest_bytes16 MiBsize of an adjacent manifest accepted on input
max_plugin_data_bytes4 MiBone plugin-data namespace value
max_manifest_depth64JSON nesting in an accepted manifest
max_report_samples4locations one aggregated report lists before counting the rest
max_reports_total16 Kidistinct aggregated report groups
max_resources256resources a reader may extract
max_resource_bytes128 MiBtotal extracted resource bytes
max_nodes16 Mikernel nodes per document
max_facet_rows1 Mifacet rows across all tables
max_decoded_text_bytes256 MiBdecoded text pool bytes
max_lowering_alternatives8lowering alternatives per construct
max_lowering_work64 Mirule applications per conversion
Every limit, its default, and what it bounds. Overrides use --limit NAME=VALUE.

The exception: max_depth and max_xml_depth may be raised only to 4,096, the hard cap that sizes every fixed walker stack in the binary. An override above the cap is refused as an invalid value. There is no flag that turns a bounded stack into an unbounded one.

The IR v2 bounds near the bottom come from ZDS 0013. max_nodes and max_decoded_text_bytes bound the kernel itself, so a small compressed input cannot decode into an unbounded tree or text pool. A facet bomb, one paragraph carrying a million annotations, dies at max_facet_rows as a refusal rather than an allocation storm; the erasure axiom makes this safe, because no facet can change what renders. The two lowering limits cap the writer’s planning work under hostile rule interactions.

Memory has one more bound worth knowing. Since the InputMode repair, a ZIP-backed format read from a file is never held in memory whole: the reader windows the central directory and each entry separately, so peak memory is the directory plus one expanding entry, not the archive beside its expansion. Piped input for those formats spills to a temporary file first for the same reason.

Bombs are a budget problem

A zip bomb is a small file that expands enormously. The defense is not detecting bombs. It is refusing to pay for them. Each archive entry gets a budget:

allowance=max(compressed size,64)×max_compression_ratio

With the default ratio of 200, a 1 KiB entry may expand to 200 KiB, and a 486 KiB entry to about 95 MiB. The check runs inside the inflate loop, as output is produced. The moment the stream exceeds its allowance, the conversion stops with an archive-limit report and the remaining gigabytes are never decompressed. The per-entry and total caps back the ratio up, so many medium entries cannot add up to a surprise either.

The same budget discipline guards PDF streams: FlateDecode output is capped by the same ratio and size rules before predictors run.

The attacks with names

Billion laughs. The XML entity-expansion attack needs a DTD, and a DTD needs a DOCTYPE. zenfmt refuses the DOCTYPE itself. This is the real banner, produced by a crafted .docx:

-- XML WITH A DOCTYPE REFUSED ---------------------------------- zenfmt

A part in this document carries a DOCTYPE declaration. No office
application writes one, and DTD processing is how XML entity-expansion
attacks work, so zenfmt never processes them.

The conversion stopped and no output file was created.

What you can do:

    Treat the file as suspect; a legitimate document re-exported from
    its native application will not carry a DOCTYPE.

Note the last line of the middle paragraph. No output file was created. We verify that claim below.

Cyclic chains. A CFB file whose FAT points sector 3 at sector 5 and sector 5 back at sector 3 would hang a naive reader forever. Every chain walk in zenfmt_cfb is bounded by the sector count, so the cycle is detected within one pass and refused as malformed.

Reference loops. A PDF object that resolves to itself, directly or through friends, would recurse a naive resolver to death. The resolver bounds indirection at 32 hops and carries an in-progress set, so a loop is pdf.malformed, not a hang. The xref chain is bounded at 64 sections; the page tree walk carries a visited set and a page cap.

Traversal names. An archive entry named ../../etc/passwd rejects the entire archive before any entry is read. There is no per-entry forgiveness, because a name like that means the archive was built by an attacker, and nothing else in it deserves trust.

Encryption is always a refusal

zenfmt does not decrypt documents, not even with well-known default passwords. An encrypted input is refused with a format-specific code and no override flag:

CodeTrigger
docx.encryptedan OOXML package with encryption
doc.encryption-refusedthe FIB encryption flag in a .doc
xls.encryption-refuseda FILEPASS record in the workbook
ppt.encryption-refuseda crypt-session container in the deck
pdf.encryption-refusedan /Encrypt dictionary in the trailer, including the empty-password case
epub.drm-refuseda META-INF/encryption.xml entry

The include directives of the lightweight formats get the same treatment. AsciiDoc include:: and reStructuredText .. include:: would let a document read arbitrary files from the machine converting it. Both are refused, with asciidoc.include-refused and rst.include-refused.

The answer to the last part: refusals carry an exit class. The CLI exits 0 on success, and maps conversion failures, usage errors, and limit refusals to 1, 2, and 3. A bulk script can count zip bombs without reading a single banner.

Strict mode

--strict tests the selected lowering plan before the writer opens. A strict failure therefore sends no stream bytes and leaves no artifact, manifest, or media file. Pipelines that must not accept lossy conversions get a hard gate; interactive users keep the default, which converts and explains.

Running out of memory is not a crash

convert never returns an error union. Its result always carries reports. One failure mode deserves special care: what if the failure is that there is no memory left to build a report? zenfmt reserves a static report, core.out-of-memory, compiled into the binary, requiring zero allocation to return. The allocation-failure suite proves it: the test harness injects failure at every single allocation site in a conversion, one site at a time, and asserts that every run returns the reserved report and never a crash or a partial artifact.

The fuzzers complete the picture. Every text reader has a fuzz target that feeds it arbitrary bytes, with the tree validator of chapter 2 as the oracle. A reader may refuse garbage. It may convert garbage into a strange but valid document. It may not produce an invalid tree, and it may not hang, because every loop it runs is bounded by the limits table above.

Nothing partial is published to a path

The last defense is the commit protocol. A conversion that fails before publication leaves destination files as it found them, and a completion manifest is never observable before its whole artifact ensemble. Direct streams cannot be rolled back; their API result reports untouched, partial, or complete so callers can discard an incomplete prefix.

The publication commit order as a sequence. Both the artifact and its manifest are first written to unpredictable temporary names, so a partial write is never visible under the real name. The artifact is then renamed into place, and only afterwards the manifest. Because the artifact becomes visible first, an observer can see an artifact without a manifest, but never a manifest describing a file that does not exist.
The commit order. Every write goes to an unpredictable temporary name first. The artifact becomes visible before its manifest, so a manifest never describes a file that does not exist.

The mechanics are ordinary and boring, which is the point. Temporary files are created next to their targets with unpredictable names. The artifact is published by link, which fails if the destination exists and --overwrite was not given (core.destination-exists), or by replace when it was. Media files follow, under the artifact’s own <stem>_media directory. The manifest publishes last. A crash at any point cannot leave a manifest claiming an incomplete ensemble is complete. A crash after artifact publication may leave new artifact or media files without a manifest—several filesystem renames cannot form one portable atomic transaction. Such an ensemble is deliberately uncertified; rerun with --overwrite to complete it. Temporary files are ignored by the next run.

We promised the DOCTYPE banner told the truth. Here is the check:

$ zenfmt doctype.docx -o out.md; echo "exit=$?"
exit=3
$ ls out.md
ls: out.md: No such file or directory

Refused, explained, exit class 3, and no partial out.md anywhere.

Exercise 6.1.

Compute the maximum bytes zenfmt will decompress for an archive with three entries of compressed sizes 100 bytes, 10 KiB, and 400 KiB, under the default limits. Which limit binds first for each entry?

Hint: Remember the floor of 64 bytes, and check the per-entry cap against the ratio result.

Exercise 6.2.

Build the DOCTYPE refusal yourself: any stored ZIP with a word/document.xml whose first bytes declare a DOCTYPE will do. Run it with --reports=json and find the stable code your script would match on.