Skip to content

Security

Schemas arrive from elsewhere as often as documents do. A schema is a document that names other documents and describes patterns to be compiled — three attack surfaces before anything is validated.

No network, by default

FileResolver refuses http:// and https:// outright.

error[XSD1101]: refusing to fetch `http://www.w3.org/2001/xml.xsd` over the network;
                supply a resolver or a local copy

schemaLocation is a hint; the specification is explicit that a processor may ignore it. Following one over the network turns loading a schema into fetching and trusting a remote document, makes builds depend on someone else's uptime, and leaks the fact that you are processing that schema.

If you want network fetching, supply a Resolver and own the decision — including the timeout, the allowlist and the cache.

No external entities

This is not a setting to get wrong. roxmltree performs no I/O, so an external entity cannot be fetched no matter what the document asks for.

Internal DTD subsets are accepted in schema documents, because real schemas use them — the W3C's own schema for schemas among them — with entity-reference-loop detection closing the billion-laughs vector. Instance documents get no DTD processing at all: referencing an entity one declares is XSD1001, rather than a value quietly missing its text.

Bounded work

Every unbounded thing has a bound.

Bound Default What it stops
nodes_limit 10,000,000 A single schema document exhausting memory
max_depth 256 A schema document nested deeply enough to overflow the stack
Instance nesting 10,000, fixed A document being validated nested past what the reader counts
Include nesting depth 64, fixed An include chain that never ends
Cycle guards — Circular includes, derivations, substitutions, structural cycles

python,ignore schemas = xsdkit.SchemaSet.from_file("untrusted.xsd", nodes_limit=100_000, max_depth=64)

Nesting is bounded because parsing recurses. The XML parser descends one native stack frame per level, and a stack overflow is not an error anyone can catch — it aborts the process. So a schema document's nesting is measured before it is parsed, counting any markup its internal DTD subset could insert through an entity reference, and a document deeper than max_depth is refused with XSD1001. 256 is libxml2's default for the same reason. It leaves room on a 1 MiB stack in a release build, where the deepest-recursing construct, nested anonymous types, reached about 875 levels; an unoptimised build spends several times as much stack per level.

Instance documents are validated with a stack of the validator's own and never recurse, but the XML reader counts nesting in 16 bits and resolves namespaces against the wrong scopes past 65,535 levels. A document nested deeper than 10,000 is refused rather than misread.

Cycles in a schema are legal and common — a type may contain an element of its own type — so they are detected rather than forbidden. Every graph walk in the library carries a guard.

Patterns are transpiled, not passed through

XSD's pattern language is not PCRE. Patterns are transpiled to the regex crate, which has no backtracking and therefore no catastrophic-backtracking class of denial of service — a pattern is linear in the input, whatever it looks like. A pattern is also one of the fuzz targets.

The regex crate does cap how large a compiled expression may grow, so a valid pattern such as (a{1000}){1000} can be refused. That is reported as XSD1104 at the type that declared it — an error by default, a warning under lax — rather than dropped, since a pattern that is not enforced is a type that accepts more than it says.

Fuzzed

Four cargo-fuzz targets, seeded from the W3C corpus:

Target Surface
load_schema Arbitrary bytes into the loader
xsd_regex Arbitrary patterns into the transpiler
parse_value Arbitrary lexical forms into all 50 datatypes
validate_instance Arbitrary XML into the validator, against a real schema

CI builds all four on every commit and smoke-runs each for 30 seconds; longer campaigns are run locally. Every finding has a named regression test rather than only a corpus entry — a crash that is only remembered by a binary blob is a crash that comes back.

No unsafe

#![forbid(unsafe_code)] at the crate root. The Python bindings go through PyO3, which contains the unsafety at a reviewed boundary rather than spreading it through the library.

Reporting

Security issues can be reported through GitHub's private vulnerability reporting on the repository.