-
Notifications
You must be signed in to change notification settings - Fork 0
Security
This page states the threat model plainly, what has been defended, and where the register stands. 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 — and a deterministic fuzzer now runs on every push. 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.
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.
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.
PackageLimitsbounds 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 viaConversionOptions.Limitsfor a document that genuinely needs more, and catchPackageTooLargeExceptionto 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.
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 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.
-
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 aFontLibraryvalidates 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).
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,
intoverflow 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,gridSpanautofit arrays, chart point counts, a list label built from an unboundedw:start— #147, #152–#155);NaNreaching the PDF content stream (#156). -
Fonts: unbounded glyph-buffer growth in shaping,
cmapsubtables that hang or OOM,ttcfheaders that pre-allocate gigabytes, raw unchecked table accessors (#157–#159, #181–#186). -
The XML DOM:
ReadPartAsXmlno 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.
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 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.
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 the next planned layer is static analysis in CI (Semgrep, with custom threat-model rules — epic #228).
Treat conversion of untrusted documents as what it is — untrusted-input processing — cleared register or not:
-
Always set
Limitsto the smallest bounds your documents genuinely need, and catchPackageTooLargeException. - 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.
- 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.
-
Watch the register. If a finding is filed tomorrow, the open
securitylabel 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.
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.
Using n8PDF
What it does
How it works
Contributing