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.