On May 21, 2026, SwiftNIO released version 2.100.0 with several security fixes. One of them looked almost ordinary: the HTTP/1 decoder gained limits for the total size and number of header fields. The decoder already rejected an individual field when its name and value grew beyond roughly 80 KB, so the system was not completely unprotected. The problem was one level higher. A peer could keep sending small, valid fields, and every single one could stay within the rule.
For the parser, the HTTP was valid. For the process, memory use could keep growing.
That is already a useful example of how a local limit can look like a system boundary when it is not. But the SwiftNIO story gets more interesting. After the first fix, maintainers went back through the history of the parser integration. They found that an aggregate 80 KB header limit had existed before. It had not been forgotten when the original parser was written. It had been lost during a perfectly reasonable migration to a different parsing library.
The old parser already knew what could go wrong
Early SwiftNIO versions used Node.js’s http_parser. In the source SwiftNIO carried in its repository, the parser had a COUNT_HEADER_SIZE guard. It tracked the total size of HTTP headers and stopped parsing with HPE_HEADER_OVERFLOW after the configured maximum was crossed.
The comment above that check was unusually clear about why it existed. The guard protected embedders from denial-of-service cases where a remote peer keeps feeding header data and the application keeps buffering it. In other words, the parser already encoded the exact safety property that would matter years later: untrusted HTTP input must not grow without a bound before parsing finishes.
The trouble was that http_parser was no longer a good long-term dependency. SwiftNIO later described the Node.js parser as having been unmaintained for some time. Its maintained replacement, llhttp, was the natural path forward. Moving to it was not reckless maintenance. It was the kind of dependency upgrade a healthy infrastructure library should make before an abandoned dependency becomes a problem of its own.
The commit that moved SwiftNIO to llhttp says exactly that. The old parser had been unmaintained, and SwiftNIO needed to move back onto the supported path. The visible product changes were described as fairly small: some buffer-position tracking changed, upgrade handling needed adjustment, and the error path had to be adapted because llhttp checked a slightly different set of conditions.
HTTP still parsed. The public behavior largely worked. The migration did its visible job.
The aggregate header-size guard disappeared with it.
That is not a reconstruction based only on reading an old diff. A later SwiftNIO pull request documented the history directly: the old 80 KB aggregate limit had existed, and the migration to llhttp accidentally removed it.
This makes the incident more useful than a simple story about “missing validation.” The validation had existed. It was just not part of a Swift API, a named policy object, or an explicit compatibility checklist. It lived inside the behavior of a dependency that was replaced.
What remained still looked like protection
After the move to llhttp, SwiftNIO still did not accept an infinitely large individual field. The decoder kept an 80 KB limit on a field name plus its value, and tests covered oversized names, values, and URLs. Looking at that code locally, the parser did not appear careless: it had headerOverflow, it had negative tests, and obviously excessive input was rejected.
But the thing being bounded had changed.
The old parser bounded the aggregate header block. The newer code bounded an individual field. Those properties sound similar until a request contains a lot of fields.
Ten fields of 1 KB each do not cross an 80 KB per-field limit. Neither do a hundred. The same remains true for thousands of small headers as long as no single one becomes large enough. The 2026 security advisory described exactly this path: a peer could send hundreds of thousands of valid header fields, and HTTPDecoder would append them to the resulting HTTPHeaders value without an aggregate bound.
The important detail is when that allocation happened: before application code saw the request.
That changes where a real defense can live. A Vapor or Hummingbird middleware could certainly decide that a request has too many headers and reject it. But by the time middleware receives a decoded request, the lower layer has already read the bytes, parsed the fields, created strings, and accumulated the collection. The application can still refuse the request, but it cannot make the parser retroactively avoid the resource cost that was needed to reach that decision.
The advisory documented two concrete downstream effects. Hummingbird 2, which bridges HTTPHeaders into swift-http-types.HTTPFields, could reach a precondition failure with a sufficiently large number of fields and terminate the process. Vapor 4 did not crash in the tested path, but the memory footprint of a request grew linearly with the number of headers.
In both cases, the important boundary was below normal application logic. The risky work happened while untrusted network bytes were being turned into a convenient Swift value.
The fix was not the end of the story
SwiftNIO 2.100.0 introduced NIOHTTPDecoderLimitConfiguration and made the resource model explicit. The decoder began tracking three different things: the maximum size of one field, the total size of the header list, and the number of fields. Crossing one of the configured boundaries causes parsing to fail with HTTPParserError.headerOverflow.
The first defaults were 80 KB for a single field, 2 MB for the complete header list, and 256 fields. From the security advisory’s point of view, that solved the central problem: the collection could no longer grow without a ceiling. The limits were also configurable, so an application with unusual requirements could opt into a different resource budget.
A few weeks later, SwiftNIO opened another pull request with a revealing title: “restore old limit of 80 kB headers.” Once the unbounded behavior had been fixed, the maintainers looked again at what the defaults themselves should mean.
Johannes Weiss argued that a 2 MB aggregate default was too large because it allowed each connection to hold a substantial amount of header data, which was not a great default for slow-client scenarios. At the same time, a hard default of 256 fields was awkward for proxy software. In HTTP, the number of physical header fields is not always semantically meaningful. Repeated fields can often be represented either as several fields with the same name or as one field containing a list of values. Rejecting one wire representation because it crosses a field-count limit while accepting an equivalent representation is a strange default for a low-level parser.
The follow-up change restored the aggregate default to 80 KB and raised the field-count default to UInt16.max - 1, or 65,534, to remain compatible with the limit in swift-http-types.HTTPFields. Current SwiftNIO source reflects those later defaults: 80 KB for an individual field, 80 KB for the aggregate header list, and 65,534 fields by default. All three values remain configurable.
That second step matters because it shows what a normal engineering response to a security discovery can look like. There is no magic “safe number” that can be dropped into a parser and forgotten. A limit is a security policy, a compatibility contract, and a resource budget at the same time. First the system needed bounded behavior again. Then the project had to decide what default boundary made sense for real users.
Dependency migrations lose more than APIs
If this story ended with “the aggregate limit was missing,” the lesson would be simple: when you limit an item, also check the collection. That is useful, but the history of the old http_parser makes the incident much more specific.
SwiftNIO did not move to llhttp in order to weaken its HTTP parser. It moved from an unmaintained dependency to a maintained one. The replacement still parsed HTTP, the public API continued to work, and tests covered a large amount of existing behavior. From the local view of a migration, those are exactly the signals we usually look for.
But a dependency is more than its API and happy-path output. Mature libraries carry less visible behavior with them: limits, timeouts, validation rules, normalization, buffering strategy, error modes, backpressure, and memory ownership. Some of these properties may be so old and stable that a team starts treating them as properties of the whole system even though they technically live inside somebody else’s implementation.
Those are the invariants that are easy to lose in an “equivalent” replacement.
In this case, the old source even explained why the aggregate bound existed: it protected the embedder from denial of service through unbounded buffering. Years later, that same protection had to be rediscovered as a SwiftNIO security issue.
That changes how I would review a dependency migration. “Does it compile?” and “do the same functional tests pass?” are necessary, but they are not enough. For a parser, compare limits and rejection behavior. For a database driver, compare timeout and retry semantics. For a queue client, check backpressure and reconnect behavior. For a cache, check eviction and memory bounds. For a serialization library, check validation and maximum nesting.
A compatible replacement has to preserve not only what the system can do, but also what the system is not allowed to do without a bound.
The 80 KB limit was not a new idea SwiftNIO invented in 2026. That is the part worth remembering. The system had already known this boundary once. Then the dependency changed, visible HTTP behavior kept working, and one old safety invariant quietly left with the old parser.
