Content input boundaries and hostile-input behaviour
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the content sources, PathContainment.hpp and the fuzz and containment tests at 009d40f5; no fuzzer, sanitizer build or test was run. The cgltf residual is CNA's own record; three gaps are established by reading only.
Loading content means trusting lengths, counts, recursion, compression, paths and decoders that come from a file, and then allocating GPU and audio resources on the strength of them. This page collects, across XNB, CNB, CNJ and the build-time XNB importer, where CNA enforces each bound at this snapshot, which trust rule applies to which kind of path, how the loaders are fuzzed and compared against independent implementations, which exceptions a hostile file can produce, and which asymmetries remain. It is written for games that load mods or downloads and for maintainers who change a reader; it states the enforcement points and the gaps without turning either into a blanket claim of safety.
Limits are useful only where they are enforced
A named limit is a guarantee only at the sites that check it. The table compares the four input routes.
| Control | XNB through Load<T> | XNB read by cna-content | CNB | CNJ |
|---|---|---|---|---|
| Whole file | Read in full up to INT32_MAX bytes; the 64 MiB maxFileSize is not applied to this read | At most 64 MiB, checked before reading; declared length must equal the file size | At most 512 MiB, checked before reading; declared size must equal the real size | No limit; read in full |
| Decompressed data | Declared size at most 256 MiB before decoding; LZ4 bounded at every step; LZX compared with the declared size after the last block | Same decoders | Per chunk at most 384 MiB and all chunks at most 1 GiB, checked before allocating; a compressed chunk must expand exactly | Not applicable |
| Strings | Reader-table names at most 1 MiB, checked after the read; other strings only against the remaining stream | Same | At most 1 MiB | No limit |
| Counts | Readers 4,096; shared resources 1,000,000; each collection 10,000,000 | Same | Chunks 65,536; array elements 16 Mi | No limit |
| Nesting | Type names and typed objects at most 256; the untyped object path is not counted | Same decoders | Flat chunk table; no recursive structure in the container | JSON nesting unbounded |
| Integrity | Declared length at most the file size | Declared length exactly the file size | CRC-32C over header, table of contents and each chunk's stored bytes | None beyond the JSON grammar |
The first row is the canonical warning against reading a constant's name as a guarantee. XnbReadLimits::maxFileSize sounds like a file-admission guard, but on the run-time path it bounds only the compressed payload, after TryReadAssetBytes has already read the whole file, so an uncompressed .xnb between 64 MiB and 2 GiB is read and parsed; the content manifest scan reads a file of any size. The build-time importer is the route where the 64 MiB limit is a real admission check. Per-limit enforcement sites for XNB are listed on The XNB container and object graph, the CNB limits on CNB Format.
Decompression output
The two XNB decompressors differ in when they stop. The LZ4 decoder allocates exactly the declared size and refuses any literal or match that would pass it. The LZX loop in XnbDecompression.cpp checks the declared size (at most 256 MiB) before it starts and compares the produced length with it once, after the last block; each block adds at most one window's worth of output, and nothing in the loop stops a stream whose blocks produce more than it declared. That was read from the loop, not exercised. CNB's Zstandard chunks are checked against their declared logical size and the aggregate budget before any allocation and must expand exactly.
Top-level names and internal references have different trust rules
ContentManager::Load<T> deliberately accepts an absolute name, and RootDirectory is a base, not a sandbox: a caller that passes an untrusted name is responsible for validating it. Containment applies instead to paths written inside content: CNJ sourceFile values and sidecar fields, XNB external references, XNB Song and Video media names, and CNB external-reference names. Each family's base directory and refusals are tabulated on ContentManager resolution rules. The shared primitives in PathContainment.hpp compare paths component by component after optional weakly_canonical, never by string prefix, so content-evil is not inside content. One rule is easy to miss: a referring file lexically inside the content root is confined to that root, while a file loaded through an explicit outside-root path is confined to its own folder, which keeps external bundles loadable without letting their embedded paths roam.
ContentPathContainmentTests.cpp (18 cases) covers deep traversal, absolute and drive paths, Unicode-shaped traversal and existing-symlink escapes for Song and Video XNB media, Texture3D, Effect, AnimationClip, Model and skinned-model sidecars, and asserts that each refusal happens before the file is created or read and does not poison the cache. It also pins what stays legal: . segments, repeated separators and .. that normalises back inside the root (ModelNormalizedInRootBinaryFieldsStillLoad, ExplicitExternalSongXnbRemainsConfinedAndLoadable). Symlink checking is not claimed to be race-proof: canonicalisation is followed later by an ordinary open, a time-of-check to time-of-use window. The accurate statement is that escapes present in the filesystem state being checked are refused, not that a concurrently changing filesystem cannot slip one past.
Recursion: two axes bounded, two open
- Type names. Canonical reader names nest generic arguments;
XnbTypeName::ParseOneis bounded at 256 levels. At about four bytes per level, a sub-megabyte name exhausted the stack before the bound was added. - Typed objects. Nested
ReadObject<T>dispatch is bounded at 256 by an RAII guard that decrements on success and on every exception, so one failed nested read cannot use up the session's remaining depth. - Untyped objects.
InnerReadObjectAny, which reads object-typed values (Model tags,Dictionary<String, Object>values), shared resources and the untyped root, does not consult that guard. By reading, a file that nests object-typed dictionaries inside each other recurses without the 256 bound; this was not exercised. - CNJ documents. The JSON parser recurses per array and object with no depth counter, so deeply nested brackets exhaust the stack rather than being refused.
Shared resources use a separate two-pass discipline: counts and indices are validated before any fixup is queued, every shared object is read before any fixup runs, and identity survives forward references. Recursion through the manager is a third kind: no load is marked as in progress, so a reference chain that leads back to its own asset re-enters Load without a cycle check; only CNJ sourceFile refuses cycles explicitly.
Fuzzing reaches the public loaders
The fuzz targets are ordinary GoogleTest cases with fixed seeds, so every run mutates the same way and a failure reproduces:
| Target | Input and mutations | Acceptable outcome |
|---|---|---|
XnbContainerFuzzTests.cpp | Four real fixtures (the MonoGame Model cube, a Texture2D uncompressed and as LZ4, a 16-bit SoundEffect), 1,500 mutations each by bit flips, truncation, overwrites and insertions (which shift every later field), loaded through the real ContentManager::Load<T> | Success or one of an enumerated set of clean exceptions (next section); std::bad_alloc is recorded as a failure, "an allocation-bomb guard gap"; the Model case is skipped on renderers without a 3D pipeline |
LzxDecoderFuzzTests.cpp | 2,000 mutations of two real LZX payloads, including the decompressed-size hint | A clean failure, or a decode of exactly the declared length |
CnbContainerFuzzTests.cpp | 4,000 mutated Curve files, 4,000 AnimationClip files, 6,000 Model files, and 3,000 files of noise or bare magic | Nothing but ContentLoadException; noise is never taken for a CNB file |
XnbWriterInputFuzzTests.cpp | Adversarial and mutated canonical values handed to the XNB writers | Refused cleanly with the writer's own exception type or written; a refused write leaves no partial file |
SoundEffectContentTypeReaderPropertyTests.cpp | A WAVEFORMATEX boundary-value sweep | Clean acceptance or refusal |
These targets found defects that review did not: the device-dimension check in the Texture2D reader was added after the container fuzzer produced real process crashes, stack smashing under Vulkan and a non-catchable WebGPU panic, as the comment in Texture2DContentTypeReader.cpp records; neither API's own validation caught them. The build offers CNA_SANITIZE for address and undefined-behaviour sanitizer builds. Coverage is not universal: there is no CNJ document fuzzer, and glTF relies on cgltf's parser plus CNA's container validation (ValidateGltfEXT, which now calls cgltf_validate and checks alignment and required extensions) and conformance tests rather than a mutation fuzzer. CNA's integration record of 9 August 2026 lists a low-severity misaligned read in the vendored cgltf sparse-accessor path as an accepted residual (REMED-CONTENT-010, a duplicate of REMED-NA-016). A later change (plan item GLTF-036, 12 August) root-caused it as a malformed file that CNA accepted: ValidateGltfEXT now refuses, before cgltf_validate runs, a base accessor, sparse index or value view whose offset is not a multiple of its component size and a byteStride that is not a multiple of 4 (GltfContainerValidation.AMisalignedSparseValuesArrayIsRejected and its neighbours), so a file that passes CNA's validation no longer reaches that load. The vendored header is deliberately left unpatched. The residual record itself and the sanitizer results are CNA's own and were read, not re-run, here.
Differential oracles reduce self-consistency risk
A writer and a reader that share one wrong assumption agree with each other, so the strongest evidence comes from outside CNA's own code:
- the LZX decoder is compared byte for byte with FNA's unmodified C# decoder run under Mono, on a single-block and a multi-block payload;
- reader fixtures come from other producers: MonoGame's content pipeline, genuine XNA Game Studio 4.0 output and an LZ4 block made with upstream liblz4;
- transcoding is checked by loading the original XNB and the CNB built from it and comparing the runtime objects (identical pixels, equivalent font, sound and model semantics);
- readers check their own inputs against independent expectations: every texture level's byte count against the block-rounded size its format requires, and a SoundEffect's decoded length against the duration the file stores.
Each oracle has a stated scope. Duration agreement cannot prove waveform identity, a storage round trip cannot prove shader sampling, and a MonoGame-produced fixture proves the layouts MonoGame writes, not every layout XNA could. The per-reader evidence is on XNB type readers.
Error normalisation and its limits
The XNB container fuzzer is also the most precise statement of which exceptions a malformed .xnb may produce through Load<T>, because anything outside its list fails the test:
| Exception | Typical cause |
|---|---|
ContentLoadException | Container, table, limit, index and reader validation |
System::IO::EndOfStreamException | A truncated header or a blob shorter than declared |
System::ArgumentException family | The XNA API's own argument checks refusing mutated content before it reaches the GPU, for example VertexBuffer::SetData refusing a declaration whose elements lie outside the uploaded bytes |
System::NotSupportedException | Only a graphics-profile refusal (for example a vertex declaration the profile cannot express); any other one fails the test |
std::bad_any_cast | A mutated index resolving to a reader of the wrong type |
std::out_of_range, std::length_error, std::invalid_argument | A bad bone or shared-resource index caught by checked access, a container size the standard library refuses, a malformed generic type name |
The CNB fuzzer is stricter and accepts only ContentLoadException from the container and codecs. On the loose tier the manager wraps any std::exception from a reader in ContentLoadException with the original as its cause; that is why a malformed .gltf loaded through Load<Model> now reaches the caller as ContentLoadException even though the glTF import core itself throws std::runtime_error, and why a game's RegisterCnjLoader factory failures are normalised too. Two paths stay unwrapped: the loose tier of Load<SoundEffect>, and exceptions thrown by the object constructors that XNB readers and CNB loaders call. A caller that must survive hostile content catches std::exception as well as ContentLoadException; the tier-by-tier list is on ContentManager resolution rules.
Asymmetries that remain at this snapshot
- The run-time whole-file read of an
.xnbis capped only atINT32_MAX, not bymaxFileSize. - LZX output is compared with the declared size only after the last block (by reading).
- The untyped object path is not depth-counted (by reading).
- CNJ has no document-size or nesting limit, still reads Model, custom-GLSL Effect and skinned-model fields with substring scanners, and has no fuzzer.
- The untyped external-reference decoder refuses LZ4 and lacks the typed decoder's 14-byte guard for compressed files.
- Texture3D and TextureCube readers leave dimension limits to texture creation, and Model draw ranges, vertex-element offsets and SpriteFont rectangles are trusted.
- Symlink containment is not time-of-check to time-of-use proof.
- A
.cnbbuilt from a source is chosen over the source by existence alone; a stale compiled file is not detected.
Several controls that older descriptions of CNA list as missing are enforced at this snapshot: SpriteFont refuses glyph, cropping, character and kerning lists of different lengths; Texture3D and TextureCube levels must have exact byte counts under a cumulative decoded-size budget; Curve key counts are bounded; SoundEffect loop regions are checked against the decoded frame count in 64-bit arithmetic; index-buffer byte counts must be a whole number of indices; and glTF files are validated with cgltf_validate before import. Extending the same observable limits and fuzz discipline to CNJ and to the untyped object path is the remaining work the asymmetries describe.
Evidence and what is not proven
Read at this snapshot from the content sources and tests linked on this page and its neighbours; no test, fuzzer or sanitizer build was run for it. Established by reading only: the late LZX length comparison, the unguarded untyped object path and the absence of a CNJ depth bound (each a reading of the code, not an executed input). The cgltf residual and the fuzzer's historical findings are CNA's own records.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Content architecture
- Maintainer workflow
- I need to modify ContentManager · I need to modify the Content Pipeline
- Tests and validation
- Test architecture and change recipes