Skip to content

Security

npond edited this page Aug 24, 2026 · 5 revisions

Security

This page states the threat model plainly, what has been defended, and what is honestly still open. The project audits itself and files every finding as a public GitHub issue — the current register is the security label, open and closed alike. Nothing on this page is softened: a converter that reads other people's files is a parser exposed to hostile input, and pretending otherwise would be the real vulnerability.

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 stays filed as its own issue 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 are filed individually (see below).

Everything else that reads

Every attack-shaped finding — however small — is filed with a reproduction, a severity, and a statement of what a hostile .docx gets out of it. Two critical findings (unbounded recursion in nested-TIFF-in-JPEG strips, and an EMF+ image recursing back into the image reader — each a StackOverflowException that kills the process) were fixed the way everything here is fixed: with the attack built as a test.

What is honestly still open

The register is public and this summary will age; the live security label is the truth. As of August 2026, roughly eighty open findings, almost all denial-of-service-shaped, concentrated in:

  • Image decoders (the largest group, tracked under 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+. The conversion-level net (above) stops these aborting a conversion; the memory- and CPU-exhaustion holes remain until each is individually closed.
  • Parsers and layout: unbounded recursion in nested tables, text boxes, equation markup and inline wrappers (stack overflow kills the process — #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 builds an XDocument bounded only by the byte cap, so a small compressed part can amplify to roughly 2 GB of managed heap (#149).
  • No fuzzing yet. One hang test is the whole of the malformed-input corpus; a real fuzzing setup is filed and blocked on the hardening epics (#71).

Guidance for callers

Until the hardening epics close, treat conversion of untrusted documents as what it is — untrusted-input processing:

  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 an OOM, hang or stack overflow costs a worker rather than the service. This is sound advice for any document converter; it is stated here because this one tells you honestly why.
  3. A broken picture, chart or embedded font costs its own placement, not the conversion — expect documents to convert with pieces missing rather than fail, and decide whether that is acceptable for your use.
  4. Watch the register. The open issues state exactly what a hostile document can currently get; whether that risk is acceptable is your call to make with real information, which is the point of keeping the register public.

Reporting

Findings are tracked openly as GitHub issues labelled security with a severity (sev:critical through sev:low — defined on Reporting Bugs). If you find something, an issue with a reproduction is the fastest path — ideally the smallest crafted .docx that demonstrates it; each parser/decoder finding should state what a hostile .docx gets out of it, because that framing is what the register is organised around.

Clone this wiki locally