description: Cookie Crumbles surveyed cookie handling across browsers and 13 frameworks, then left the server-side parser study as future work. Three years later it still did not exist, so I built it.
tags: security, dotnet, go, webdev
In 2023, Squarcina, Adão, Veronese and Maffei presented Cookie Crumbles:
Breaking and Fixing Web Session Integrity at the 32nd USENIX Security
Symposium. It is a good paper. It surveys cookie integrity across the top three
browsers and 13 web frameworks, and it finds session integrity problems in nine
of them.
Section 4.3 describes a third stage of the work: feeding crafted Cookie
headers to the server-side parsers and comparing what each one understood. Then
it says this:
We acknowledge that this approach does not constitute a systematic evaluation
of server-side parsing inconsistencies. However, our initial analysis provided
strong evidence of the pervasiveness of the issue. We leave such comprehensive
study as future work.
That sentence is an invitation, and as far as I can tell nobody took it. So I
built the bench: https://github.com/earbona23/http-parser-differentials
Why a disagreement is the bug, not a wrong answer
The instinct with a comparison table is to look for the loser. That is the
wrong instinct.
HTTP is parsed many times on the way to your code. A CDN parses it. A reverse
proxy parses it. A WAF parses it. Your framework parses it. Each of those is
free to be reasonable and still differ, because the grammars are old, widely
extended, and full of cases the RFCs never pinned down.
The security consequence does not need either parser to be wrong. It needs them
to be different. A gateway that inspects a field and an application that reads
another value for that same field are both behaving exactly as written. The gap
between them is where the request you blocked and the request you served stop
being the same request.
That is the class the bench is built to make visible.
What it does
Two benches. Each one starts a small reflector program per implementation on
127.0.0.1, sends byte-identical input to all of them over a raw socket, and
prints what each one parsed.
Cookie header, 39 vectors, five implementations: ASP.NET Core
(RequestCookieCollection), Go (net/http), PHP (native $_COOKIE), the
algorithm of the npm cookie package that Express and Koa use, and Python
(http.cookies). Duplicate names, case, percent-decoding, quoting, separators,
whitespace, control and high bytes, repeated Cookie headers.
Multipart, 17 vectors, four implementations: ASP.NET Core (ReadFormAsync),
Go (mime/multipart), PHP, Python (email with an HTTP policy). Duplicate
field names, quoting of the name parameter, repeated and malformed
Content-Disposition, bare LF line endings, boundary handling, RFC 2231
encoded names, preamble and epilogue.
Raw sockets matter here. An HTTP client library will refuse to send half of
these vectors, and if it quietly normalises one of them you end up measuring
your own client instead of the parser. I learned that the hard way on an
unrelated piece of work, when a harness of mine encoded a header as ASCII
before sending it and I spent an afternoon analysing a bug I had written.
What came out
Six of 39 cookie vectors and seven of 17 multipart vectors put one
implementation on its own. The numbers in the repository are measured against
.NET 10.0.14, Go 1.21, PHP 8, Node 24 and Python 3, and they will move. Rerun
rather than trusting my table.
Two results worth pulling out, because they point in opposite directions.
Case. Cookie: A=1; a=2 is two cookies to Go, PHP, Node and Python. To
ASP.NET Core it is one, because the collection is built with
StringComparer.OrdinalIgnoreCase. RFC 6265 gives no case-folding rule and
browsers treat the two as distinct entries. This one is already known upstream
and tracked by the ASP.NET team themselves.
A comma. Cookie: a=1,b=2 returns the cookie to the other four. ASP.NET
Core returns nothing at all, and not just for that pair: a comma in a value
discards the rest of the header from that point. That is fail-closed, which is
the safe direction, but it is a surprising amount of silence for one character.
Standing alone is not the same as being wrong
This is the part I would want a reviewer to hold me to.
Three of the seven multipart cases where ASP.NET Core differs from its peers
are cases where it is stricter. It rejects a duplicated
Content-Disposition, it rejects bare LF line endings, and it rejects a fake
boundary planted inside a field value. Go and Python accept all three; PHP
accepts two. On that evidence its multipart parser is in better shape than the
others, and a table that only counts divergences would tell you the opposite.
A flag in the output is a pointer. The verdict needs a human who checks the
direction: a parser that drops a field fails closed, a parser that invents or
merges one does not, and those are not the same finding.
A bench is only as good as its weakest reflector
An early version of my multipart reflector printed the file size for .NET and
not for the other three. Four parsers agreed perfectly, and my comparison
reported a divergence, because the signatures it compared included a field only
one of them emitted. I was measuring my own code.
I fixed the reflector rather than documenting the false positive, which I think
is the right order, and the story is in the README so the next person building
one of these checks their shapes first.
What this is not
It is not a vulnerability disclosure. Everything in it is public, documented
behaviour of public parsers, exercised against local reflectors written for the
repository. Nothing in it touches a system I do not own, and every byte stays on
loopback.
If you run it against a version I did not, and something in the output looks
like a security bug in a product, take it to that product's security team before
you write about it.
MIT licensed. Issues and additional implementations welcome, particularly Java,
Ruby and Rust, which I did not cover.