audit-security · git:20261030.370d4be · 2026-10-30 · sha256 ad482f0fd89f2d36
audit-security git:20261030.370d4beA
Immutable. This exact content never changes and is served at /api/v1/blob/ad482f0fd89f2d36.
--- name: audit-security description: "Audit and extend Nino's defenses against hostile input in every runtime (.NET, Go, Rust, TypeScript and the code nino-gen writes) - NinoDataException through NinoThrow, lengths checked before allocating, depth limits, canonical bytes only, hash-flooding limits, the Untrusted settings - and run the fuzzers, hostile-input tests and the malicious corpus. Use for any change to a read path, a new type or collection, a new setting, or a security review. Triggers: NinoDataException, NinoThrow, NinoLimits, TryReadLength, MaxDepth, MaxHashCollisions, Untrusted, EnterStruct, HashTables, tests/Nino.Fuzz, malicious corpus, StrictReadTests, crash, OOM, DoS, untrusted data, 安全, 恶意数据, 不可信输入, 模糊测试." metadata: internal: true --- # Security (hostile input) Nino reads untrusted data (network messages, files, request bodies): crafted bytes must never crash the process, read outside the input, allocate out of proportion to it, recurse without a bound or take time out of proportion to it. These rules hold in .NET; each runtime's own form of them is in `runtimes/*/AGENTS.md`, and generated code follows the runtime it targets. ## Rules - Every check of the data throws `NinoDataException`, through a `NinoThrow` helper (out of line). The caller's mistakes and the write side (a graph nested too deep or holding itself, a collection changed while written) keep `InvalidOperationException`. Tests assert the exact type (`Assert.ThrowsExactly`). - Check a length against the bytes left (`TryReadLength`, `NinoLimits.TooLong`) before allocating; grow, never presize, what an element larger in memory than on the wire claims. Every recursive path counts depth: class bodies through `Depth`, structs and collections that can hold themselves through `EnterStruct`/`ExitStruct` on both the reader and the writer, against `NinoSettings.MaxDepth`. - Reject bytes no writer writes: a `bool` or presence byte other than 0 or 1 (`ReadBool`, `ReadPresent`, `NinoChecks.Bools`), keyed members that do not fill their entry (`EndMember`), sections of deferred bodies that do not match their region, headers that are not minimal. A raw block copy of a type with a `bool` in it goes through `NinoLayout<T>.CopiesAsIs`, never `IsCanonical` alone. - A hash table Nino fills counts the keys in each bucket against `NinoSettings.MaxHashCollisions` (`HashTables`: `TryFill`, `Insert`, or `Added`/`Walk` after the table's own add, as `HashTables.Plan` says; `ConcurrentTables` for `ConcurrentDictionary`). A new path that adds keys from the data to a hash table does one of these. - `NinoSettings.Untrusted` holds the checks that cost time on every read (seeded comparers, value checks of raw structs, trailing bytes). It is a constant once frozen (`NinoLimits.Untrusted`): keep each test of it a single `if` on the field, with the work out of line, so the default path compiles to what it was; prove it with `benchmarks/asm.py compare` (`optimize`). - A rule the other runtimes must follow goes into `docs/spec/wire-format.md` §9, with malicious cases in the corpus (`wire-format`). ## Look for - A new array allocated uninitialized (`NinoUnsafe.NewArray`, `CopiedWhole`) stored in the caller's slot before a read that can still reject the data (bools, has-value bytes, Untrusted checks): reading into an existing value then left it stale heap memory. Such an array takes the slot once filled (`NinoElements.Rejecting`, `ReadPublished`); `UntrustedInputTests.ARejectedReadKeepsNewArraysOutOfTheSlot` covers T?[], List<T?>, List<bool>, (int, bool) arrays, lists and Memory, and a bool[] member. The fuzzer's reuse reads did not see it: stale bytes are valid values. - A read that allocates from a count before checking it against the bytes left (collections, strings, multi-dim arrays, `BigInteger`, `BitArray`, `StringBuilder`, custom formatters' helpers). - A hash table filled from the data whose hash the data can predict. In other runtimes the language's own table may be unseeded: V8 hashes number and `bigint` keys of `Map`/`Set` without a seed (8,000 crafted ints took 40 times as long as ordinary ones in Node 22), which `NinoKeyGuard` in `nino.ts` bounds by counting the buckets of the table V8 ends with; it is exact for V8 only (JavaScriptCore and SpiderMonkey hash their own way). A new collection of number or `bigint` keys in generated TypeScript takes the guard (`TsShape.Guard`); Go and Rust tables are seeded (maphash, `RandomState`). - A recursive path without depth counting (a struct holding a collection of itself, polymorphic slots, tuples, `object` members, reference tracking's back references). - A loop whose work grows faster than the input (nested lengths, quadratic rehashing, deferred sections re-read). - A new built-in type or collection without its malicious cases and fuzz shapes. - An exception other than `NinoDataException` escaping a read (`IndexOutOfRange`, `Overflow`, `OutOfMemory`, `ArgumentException` from a BCL constructor fed raw data). - Settings that weaken a check by default. ## Proof - `tests/Nino.Fuzz` (`tests/Nino.Fuzz/README.md`): `selftest`, then a timed run with and without `DOTNET_EnableHWIntrinsic=0`; a finding becomes a regression test and, when it is about the bytes, a malicious case. - The hostile-input unit tests: `StrictReadTests`, `HashFloodingTests`, `UntrustedModeTests`, `SettingsTests`, the nesting tests (`tests/Nino.UnitTests/Security`). - The conformance corpus in .NET, Go, Rust and TypeScript; the runtimes' own fuzzers (`FuzzReader`, `FuzzDecode`, `FuzzTagged`, `FuzzTaggedDecode`; Rust `cargo fuzz` and the corpus fuzz tests; the TypeScript suite). - A security finding from a reviewer stays open until the owner decides; a weakened check is never the fix for a failing test.