Skip to content

Security

npond edited this page Aug 30, 2026 · 6 revisions

Security

This page tells the story of what has been defended and where the register stands. The trust boundary it is all measured against — the trusted and attacker-controlled surfaces, the sinks, and the availability-not-RCE guarantee — is set out as a structured Threat Model, which is the artifact a "is this a security bug?" call is judged against. A suspected vulnerability is reported privately, per SECURITY.md — not as a public issue.

The project audits itself and files every finding as a public GitHub issue — the register is the security label, open and closed alike. As of August 2026 every finding in that register is closed — over a hundred of them, each with the attack built as a test — a deterministic fuzzer runs on every push, and a coverage-guided one runs nightly and has since found a defect of its own (#296, fixed in 0.1.4). Nothing on this page is softened in either direction: a converter that reads other people's files is a parser exposed to hostile input, and a cleared register is a statement about the attacks that were found, not a proof that none remain.

The attack vector

A .docx is attacker-controlled input, and n8PDF exists to read it. Concretely, one document can carry all of the following, and every one of them is parsed by code in this repository:

  • a ZIP container whose headers describe its own sizes — and a hostile file describes them wrongly
  • XML parts (the document, styles, numbering, charts, diagrams, settings…)
  • images in six formats — PNG, GIF, BMP, TIFF (including LZW, PackBits, Deflate and fax-encoded strips, tiles, planar layouts), EMF/EMF+ metafiles, and JPEG (sequential, progressive, arithmetic)
  • embedded fonts (w:embedRegular) — since these were implemented, the SFNT/OpenType/AAT parsers read attacker-controlled bytes too
  • charts, equations and diagrams, each with counts, offsets and recursion of their own

Because the library is pure managed .NET with no native code and no unsafe, the realistic failure modes are denial of service, not memory corruption: unbounded allocation (OOM), unbounded work (hangs), stack overflow (which kills a .NET process outright — it cannot be caught), and uncaught exceptions aborting a conversion. Those are the attacks the audits look for, find, and file.

What has been done

The container: decompression bombs

Two attacks were cheap to write and were both once open here; both are now closed and the tests build each attack rather than describing it:

  • A part that decompresses without bound. Zeros compress about a thousand to one. PackageLimits bounds it: 128 MB for one part, 512 MB across a package, 4096 parts. Limits are counted against what actually comes out of the decompressor, never what a header claims — so a lying header cannot smuggle anything past. Raise them via ConversionOptions.Limits for a document that genuinely needs more, and catch PackageTooLargeException to know that is what happened.
  • XML entity expansion (billion laughs). Ten entities, each ten of the one below, expand a kilobyte into a gigabyte — an attack no counting of compressed bytes can catch. Parts are read through a reader that prohibits DTDs outright, which costs nothing legitimate: the Open Packaging Conventions forbid a DTD in a part.

Images: the lying header

An image declares its own size in its header, and decoders allocate from what it says before reading a byte of the picture — a 57-byte PNG can call itself fifty thousand pixels square and ask for 7.5 GB. MaximumImagePixels bounds the declared area (default 50 million — a 600dpi A4 scan with room to spare), counted in long arithmetic since 70,000² does not fit the int the pixels would have been allocated with. A picture past the limit is left out the way any unreadable picture is: the document loses the picture, not the conversion.

The net under the decoders

The audit reproduced crafted files of a few dozen bytes escaping the decoders' own error handling with runtime exceptions — index-out-of-range, overflow, divide-by-zero and the like — that flew straight out of the public API. The conversion-level net now catches exactly the types the audit reproduced, so a malformed image costs its own placement, not the conversion (#180). OutOfMemoryException is deliberately not caught — that one means the process is in trouble, and hiding it helps nobody. The net is the net, not the holes: each underlying validation gap was filed as its own issue and closed with its own fix and decoder-level regression test.

Fonts

  • Embedded fonts are bounded by PackageLimits.MaximumFontBytes; a face that will not parse is left out and the conversion proceeds in substitutes, exactly as if it had not been carried. Registering a face into a FontLibrary validates it (FontFormatException).
  • The SFNT table directory clamps declared table lengths to what is actually in the file — a malformed face can no longer declare a two-gigabyte table and be believed.
  • When embedded fonts landed, the whole of Fonts/ was re-audited as an untrusted-input surface (#171); its findings were filed individually and are all closed (see below).

Everything else that reads

Every attack-shaped finding — however small — was filed with a reproduction, a severity, and a statement of what a hostile .docx gets out of it, then worked in severity order until the register was empty. The full sweep closed:

  • Image decoders (the largest group, hardening epics #49–#56): unvalidated counts used as allocation sizes, int overflow slipping past bounds checks, uncaught exceptions from malformed streams, and per-decoder gaps in TIFF, BMP, GIF, PNG, JPEG, CCITT and EMF/EMF+ — each closed with the crafted input committed as a regression test.
  • Parsers and layout: unbounded recursion in nested tables, text boxes, equation markup and inline wrappers — the stack overflows that kill a .NET process outright (#143–#146); unbounded allocations from hostile counts (w:cols/@num, gridSpan autofit arrays, chart point counts, a list label built from an unbounded w:start — #147, #152–#155); NaN reaching the PDF content stream (#156).
  • Fonts: unbounded glyph-buffer growth in shaping, cmap subtables that hang or OOM, ttcf headers that pre-allocate gigabytes, raw unchecked table accessors (#157–#159, #181–#186).
  • The XML DOM: ReadPartAsXml no longer amplifies a small compressed part into gigabytes of managed heap (#149).
  • Crafted-input quadratic scans and hang loops — the CPU-exhaustion counterparts of the allocation findings — closed in the same severity-ordered sweep, down through the lows.

The fuzzer the hardening was building toward

With the catalogued findings closed, the fuzzing issue (#71) unblocked and landed as FuzzTests: a deterministic mutation fuzzer over both untrusted entry points — ImageReader.TryRead and Converter.Convert — seeded from valid images in every supported format, a real document, and the crafted hostile corpus the hardening built. The oracle is the contract the hardening established: a malformed image returns null rather than throwing or hanging; a malformed document throws only documented exception types, never a raw runtime crash; each input runs on a time-bounded thread so a hang fails as a stuck join. It is deterministic and sub-second (thousands of mutated images and hundreds of mutated documents per run), so it runs on every push inside the normal suite; a by-hand deep run just raises the iteration counts. Any future input that escapes the oracle gets committed as a seed and filed as a defect.

The continuous fuzzer, and the first thing it caught

FuzzTests is deterministic and fixed-corpus, which is what lets it run in the normal suite on every push — and also what bounds it: it explores what its seeds and its mutations reach, and no further. Behind it runs a coverage-guided fuzzer that does not stop (#263, #264). The SharpFuzz harnesses in fuzz/ drive five entry points — ImageReader.TryRead, the font parser, the obfuscated-embedded-font path, OpcPackage, and Converter.Convert — under libFuzzer, which instruments the library and evolves inputs toward branches nothing has taken yet. It runs nightly as its own workflow, and the corpus accumulates across runs through the Actions cache, so each night starts from everything every previous night found. libFuzzer is Linux-only, which is why this lives in CI rather than on the author's Mac. A crash fails the run and uploads the minimised reproducer.

On 30 August 2026 it earned its keep. The image target crashed after ~127,000 executions on a 60-byte PNG (#296). A zlib header with the FDICT bit set asks for a preset dictionary rather than being corrupt; zlib answers Z_NEED_DICT, and .NET raises ZLibException — which derives from IOException, not InvalidDataException, and so fell outside the catch in both image inflaters and outside every clause of the ImageReader.TryRead net. Two bytes of an IDAT therefore aborted a whole conversion: exactly the "uncaught exceptions aborting a conversion" failure mode named at the top of this page, in the one shape the fixed-corpus fuzzer had no seed for. Reading around the fix found the same hole a second time in the TIFF Deflate path, which no fuzzer had reached. Both are closed, each with its attack as a decoder-level test where the TryRead net cannot swallow the evidence, and libFuzzer's minimised unit is seeded into the image corpus so every future night re-checks it. The fix shipped in 0.1.4; 0.1.0 through 0.1.3 carry the defect.

There is a detail worth recording for anyone writing the same catch. ZLibException cannot be named in .NET 10: it is public in System.IO.Compression at runtime but absent from the net10.0 reference assembly, so catch (ZLibException) does not compile. The catch is written against its public base, IOException, which costs nothing on these paths because the only stream is a MemoryStream over a byte array already in hand and performs no I/O.

That episode is the system working as designed rather than a failure of it — but the honest reading is the one this page has taken throughout. The register being closed never meant no defect remained; it meant every defect the audits had found was fixed. The continuous fuzzer is the part of the apparatus whose job is to find the rest, and the first one it found was real, reachable from an ordinary .docx, and had been shipped.

Where the register stands

The live security label is the truth when this page and it disagree. As of August 2026 it is empty: all 107 filed findings are closed — from the two criticals (crafted documents killing the process via stack overflow) through the highs (memory exhaustion, hangs), the mediums (aborted conversions, malformed output) and down through the lows — every severity label in the tracker now sits only on closed issues. That count now includes the first finding to come from the nightly fuzzer rather than from an audit sweep: #296, raised against released code and closed the same day.

Closed does not mean finished. What "closed" claims is precisely this: every attack the project's own audits found has a fix and a test that builds the attack. It does not claim nobody will find another — the fuzzer hunts for exactly that, and static analysis now runs in CI too — Semgrep with custom threat-model rules, advisory to code scanning with a diff-aware gate on new high/critical findings (epic #228, closed).

Guidance for callers

Treat conversion of untrusted documents as what it is — untrusted-input processing — cleared register or not:

  1. Always set Limits to the smallest bounds your documents genuinely need, and catch PackageTooLargeException.
  2. Isolate hostile input. For a service converting documents from the public, run conversions in a worker process with an OS-level memory cap and a timeout, so a failure nobody has found yet costs a worker rather than the service. This is sound advice for any document converter, and a cleared register does not retire it — defence in depth is for the attacks not yet catalogued.
  3. A broken picture, chart or embedded font costs its own placement, not the conversion — expect malformed documents to convert with pieces missing rather than fail, and decide whether that is acceptable for your use.
  4. Watch the register. If a finding is filed tomorrow, the open security label is where it will appear; whether the risk is acceptable is your call to make with real information, which is the point of keeping the register public.

Reporting

A suspected vulnerability is reported privately, not as a public issue. Use GitHub's private vulnerability reporting — the Report a vulnerability button under the repository's Security tab — which opens a private advisory. SECURITY.md sets out the process, the response targets, and what to include; the Threat Model is what an accepted-or-declined call is measured against. A public reproduction is a working exploit handed to everyone, so the crafted .docx goes in the private advisory, not a public issue.

The closed register, by contrast, is deliberately public: findings the project's own audits already fixed are tracked as GitHub issues labelled security with a severity (sev:critical through sev:low — defined on Reporting Bugs), each with the attack built as a test. Non-security bugs — fidelity, a missing feature, a wrong layout — are ordinary public issues; see Reporting Bugs.

Clone this wiki locally