Security
Probatio sits at the edge of a program: it validates data that came from
somewhere you do not control. That data is often hostile, and through
from_json_schema even the schema can be hostile. This page is honest about
what Probatio defends against, how, and where the responsibility stays with you.
Threat model
Section titled “Threat model”The thing Probatio touches most is untrusted data. A developer writes a schema,
Probatio validates incoming values against it. That direction is safe: a schema
is plain Python the developer wrote, and validating data against it does not
expose any code-execution surface. Probatio does not eval, exec, pickle,
or marshal the values it validates. There is no path where validating data
runs attacker code.
The harder case is from_json_schema (and from_openapi). There the schema
itself is decoded from an untrusted document. That widens the attack surface,
because now both the data and the rules describing it are attacker-controlled.
An attacker against this surface is not after code execution; there is none to get. The realistic goals are denial of service: burn CPU until the process is useless, or exhaust the stack and crash the interpreter. Probatio’s safeguards target exactly those two outcomes.
Threats and mitigations
Section titled “Threats and mitigations”| Threat | Vector | Mitigation |
|---|---|---|
| Catastrophic backtracking (ReDoS) | A pattern in an untrusted JSON Schema, compiled to a regex | from_json_schema refuses a nested unbounded quantifier with SchemaError, before it compiles |
| Stack exhaustion from a deep document | A pathologically nested untrusted JSON Schema | The decoder caps nesting depth and raises SchemaError instead of overflowing the stack |
| Stack exhaustion from deep or cyclic data | Crafted data run through a recursive Self schema | A recursion depth guard raises a clean Invalid instead of RecursionError |
| Arbitrary object construction from YAML | Tags in an untrusted YAML payload | Probatio never parses YAML (or anything else), so it adds no deserialization surface; parse with a safe loader before validating |
These guards cover the specific shapes in the table, and only those. What they do not do is bound the overall size of the input: a validator cannot know how much data is too much for your application, so capping the size and shape of what you hand to a schema stays the calling application’s job. The project’s security policy draws the same line in its out-of-scope list.
Regex denial of service
Section titled “Regex denial of service”Python’s re engine backtracks, so a pattern like (a+)+$ runs in exponential
time on crafted input. There is no safe timeout for re in pure Python, so the
only defense is to refuse the dangerous pattern before compiling it.
from_json_schema does that. When a pattern contains a nested unbounded
quantifier (an unbounded repeat applied to a group that is itself unbounded),
the decoder raises SchemaError rather than building a validator that could
hang:
from probatio import from_json_schema
from_json_schema({"type": "string", "pattern": "(a+)+$"})A benign pattern compiles as you would expect:
from probatio import from_json_schema
schema = from_json_schema({"type": "string", "pattern": "^[a-z]+$"})print(schema("hello")) # helloThe trust boundary is the from_json_schema path, and only that path. A
Match pattern you write in Python is not checked. That matches voluptuous:
a developer-written regex is the developer’s responsibility. If you compile a
pattern from input you do not trust, screen it yourself before handing it to
Match.
Recursion and stack exhaustion
Section titled “Recursion and stack exhaustion”Two recursive shapes can drive Probatio into the Python stack: a deeply nested
schema document, and deeply nested data validated against a recursive schema.
A naive recursive walk turns either into a RecursionError, which is an
unhandled crash. Probatio guards both.
A JSON Schema document nested past a fixed depth is refused while decoding:
from probatio import from_json_schema
def nest(levels): root = {"type": "object", "properties": {}} cursor = root for _ in range(levels): child = {"type": "object", "properties": {}} cursor["properties"]["x"] = child cursor = child return root
from_json_schema(nest(500))On the data side, Self lets a schema validate a recursive structure, like a
tree. Feed it data nested deeper than the recursion guard allows, and it raises
a clean Invalid with the path to where it gave up, not a RecursionError:
from probatio import Schema, Self, Invalid
schema = Schema({"value": int, "children": [Self]})
# A normal tree validates fine.ok = {"value": 1, "children": [{"value": 2, "children": []}]}print(schema(ok)) # {'value': 1, 'children': [{'value': 2, 'children': []}]}
def deep(levels): node = {"value": 0, "children": []} for _ in range(levels): node = {"value": 0, "children": [node]} return node
try: schema(deep(5000))except Invalid as err: print(err.error_message) # data is nested too deeply for this recursive schemaThe result is the same in both directions: a depth that would crash the process becomes a normal, catchable error instead.
Parsing stays with you
Section titled “Parsing stays with you”Probatio validates parsed Python objects; it never parses a document itself. That
keeps a whole class of parser vulnerabilities out of the library, but it also puts
the choice of parser on you. Parse untrusted YAML with a safe loader (PyYAML’s
yaml.safe_load, not yaml.load) so a hostile document cannot construct
arbitrary Python objects, then hand the result to the schema.
import yamlfrom probatio import Schema, Required
schema = Schema({Required("name"): str, Required("port"): int})schema(yaml.safe_load("name: app\nport: 8080")) # {'name': 'app', 'port': 8080}Keeping secrets out of error output
Section titled “Keeping secrets out of error output”Configuration often carries credentials: a password, an API token, a private key.
Wrap the key in Secret, and a validation failure under it is redacted: the error
still reports the path and the reason, but shows <redacted> instead of the
offending value, so a rejected credential does not leak into a rendered error, a
log line, or a stack trace.
from probatio import Schema, Required, Secretfrom probatio.humanize import humanize_errorfrom probatio.error import MultipleInvalid
schema = Schema({Required(Secret("api_token")): str, Required("host"): str})data = {"api_token": 12345, "host": "example.com"}try: schema(data)except MultipleInvalid as err: print(humanize_error(data, err)) # expected str at 'api_token'. Got <redacted>The redaction is targeted: only the secret key’s value is hidden, a sibling
field’s value still renders. Each error also carries a secret flag (on the
error and in as_dict()), so a consumer building its own output can redact the
same values.
The threat model is deliberately narrow: Secret covers validation error output,
the place a schema library controls. It does not reach values you log yourself
elsewhere, and it does not encrypt or mask data at rest. The validated value
passes through unchanged; if you also need to keep it out of your own log lines,
wrap it in your own carrier after validation.
Reporting a vulnerability
Section titled “Reporting a vulnerability”Found something that looks like a security issue? Please report it privately through GitHub’s private vulnerability reporting, not as a public issue. That gives a fix time to land before the details are out in the open.
The full policy lives in
SECURITY.md:
the disclosure timeline, what to include in a report, and an email fallback if
you cannot use GitHub’s reporting flow.