Assay
A file you do not trust
The defaults are generous, because most of the time you are decoding your own data. The moment you are taking files from strangers, say what you will accept.
@Schema(keys: .snakeCase, formats: .all)struct Upload { var title: String var tags: [String] = []}
let strict = Limits(maxIssues: 20, maxDepth: 8, maxBytes: 4_096)Three numbers, three different attacks.
The parser you did not ask for
Section titled “The parser you did not ask for”Content-Type: application/xmlaccepting: [.json]
<!DOCTYPE lolz [<!ENTITY lol "lol"><!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;">]><upload><title>&lol2;</title></upload>upload.xml: error: media type application/xml is not in the accepted list
1 errorThat is an entity-expansion attempt in XML, offered to an endpoint of yours accepting only
JSON. It produced one issue and never entered an XML parser, because accepting: is
checked before anything reads the body.
This is why that argument is required and has no default. The cheapest defence against a parser’s edge cases is not reaching the parser, so you opt in at the call site, in writing, to each format a request can reach.
Worth knowing even so: had you listed XML, the entity would have been refused anyway. Assay’s XML parser has no code path that fetches an external entity, so XXE is not a flag you can forget to set.
Nesting
Section titled “Nesting”maxDepth: 8, and a document nested 20 deep
[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]<input>:1:10: error: nesting exceeds the maximum depth of 8 1 │ [[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]] │ ^
1 errormaxDepth guards the stack. A recursive-descent parser meeting twenty thousand open brackets
traps rather than handing you an error, and if you ship to WebAssembly the stack is small
enough that it happens sooner than you would think.
This example goes through the value model on purpose. Your schema bounds its own nesting just by being a declaration — your types stop somewhere. A document whose shape you do not know is bounded only by whoever sent it, which is exactly when the limit earns its place.
maxBytes: 4096, and a 5012-byte documentupload.json: error: input exceeds the maximum size of 4096 bytes
1 errorChecked before a byte is parsed, so an oversized body costs you a comparison rather than a parse. XML entity expansion is bounded the same way but by ratio — 32× the input, with a 64 KB floor — because the honest limit on expansion is the size of what was sent.
The attacks a depth limit does not catch
Section titled “The attacks a depth limit does not catch”Two shapes are small on disk, shallow, and enormous once expanded. Depth cannot see either of them, because the expansion is in the breadth.
Alias expansion in YAML. A few nested anchors, each referring to the one before. 331 bytes reached 11.4 million nodes before the budget that stops it existed. The guard charges each alias its expanded size against a budget derived from the input size.
Shared objects in a binary property list. Ten arrays of a thousand references each, under a kilobyte, 10³⁰ nodes. No cycle, every reference to a real distinct object, and a depth of ten. Same answer: a node budget, because a real document cannot describe more nodes than it has bytes to describe them with.
Both are built byte by byte in the test suite rather than described, and you can go read them. A test that asserts a limit exists without constructing the input it bounds is a test that keeps passing when the limit is deleted.
Issue budget
Section titled “Issue budget”maxIssues bounds the report rather than the parse. A hostile file can be wrong in a million
places, and collecting a million issues — each retaining a source span — is its own denial of
service against you.
let d = Upload.diagnose(json: bytes, limits: strict)if d.issuesWereTruncated { /* there were more than we kept */ }issuesWereTruncated distinguishes “twenty problems” from “at least twenty”, which matters when
you are deciding whether to show a user a list or tell them the file is broken.
A reasonable starting point
Section titled “A reasonable starting point”let publicUpload = Limits( maxIssues: 50, // enough to be useful, not enough to be a weapon maxDepth: 16, // deeper than any hand-written document maxBytes: 1 << 20 // whatever your endpoint actually needs)Then the accepting list, which is the one with no default:
try Upload.parse(body: bytes, contentType: ct, accepting: [.json], limits: publicUpload)What the limits do not cover
Section titled “What the limits do not cover”Everything above bounds what the decoder does: bytes read, nesting entered, expansion allowed, issues kept. None of it bounds code you supply and the decoder calls.
Two of those exist, and the first one is a genuine denial of service you can walk into.
A .regex rule runs your pattern, and Swift’s Regex backtracks. A pattern like
^(a+)+$ against a long run of a takes exponential time, and no byte or depth limit sees
it, because the document is small and shallow — the cost is in the matcher.
@Validate(.regex("^(a+)+$")) var code: String // 30 characters of input is enoughIf a pattern’s input comes from strangers, keep the pattern simple and anchored, put a
.max in front of it so the string is short before the matcher sees it, or use .prefix,
.suffix and .oneOf, which are linear.
@Validate(.max(64), .regex("^[a-z][a-z0-9-]*$")) var slug: StringA @Check you write runs once per value, and once per element for a collection. If it
queries a database or is quadratic, that is the cost of the document, multiplied by however
many values it has.
Beyond those, Assay is a decoder and not a sandbox. It does not limit memory the way a container does, it does not bound the time the whole parse takes, and a process that must survive deliberately hostile input needs those from the layer that owns the process.
- Limits and security — every budget and what it stops.
- HTTP bodies — negotiation in full.
- A JSON API endpoint — the handler these limits belong in.