Repository navigation
Match Set-Cookie attributes by name, not by prefix - #50
Conversation
parse-set-prop lowercased the whole trimmed cookie-av and tested it with byte-starts-with? against path, domain, expires, max-age and samesite, and compared the whole text with secure/httponly. So `pathx=1` set the path, `Domainx=evil.example` set the domain, `max-agex=100` set an expiry, `Expiresx=foo` and `samesitey=whatever` failed the whole cookie, `Secure=1`/`HttpOnly=1` left the flags unset, and `Path= /sp` kept the leading space. Following RFC 6265 §5.2, each cookie-av is now split at its first `=`, name and value are trimmed, and the lowercased name must equal a known attribute name; unknown names are ignored and Secure/HttpOnly are set whatever their value. max-age-attr? uses the same name, so a `max-agex=` attribute no longer takes Max-Age's precedence over Expires. The errors for a known name without `=`, an unparseable Expires, a malformed Max-Age and an unknown SameSite value are unchanged.
There was a problem hiding this comment.
Build & Tests
- Branch (
4bdebcb, armhf, local compat core):carp -x test/http.carpgives 555 passed, 0 failed (rc 0). master (c9aec42) gives 543/0. Both match the body. - CI: 555/0 on ubuntu-latest and macos-latest. Lint (angler) and the format check pass on both.
carp -x gendocs.carpruns clean on the branch (rc 0).- The new tests have teeth. I ran master's
http.carpunder the branch'stest/http.carpand got 544 passed, 11 failed. The 11 are exactly the new tests the body names. The 12th,parse-set still rejects a Path attribute without a value, passes on master as claimed.
Before/after table
I ran every row on master and on the branch through Cookie.parse-set → Cookie.set, using a probe program built against each side's http.carp. All 11 inputs give the body's master value and the body's branch value. The "Unchanged" claims also hold:
- the no-value errors for
Path,Domain,Expires,Max-AgeandSameSite; Path=still giving an empty path;- the errors for
Expires=garbage,Max-Age=abcandSameSite=bogus.
The only text difference is that the unknown-SameSite message now quotes the trimmed value: SameSite= Bogus gives unknown value: Bogus instead of unknown value: Bogus.
Findings
1. Two of the body's claims aren't pinned by any test. These are coverage gaps; the code itself behaves correctly. I added env-gated mutants to one build and ran the full suite once per mutant. A clean baseline row gave 555/0.
| mutant | suite | killed by |
|---|---|---|
(= k "path") → prefix match on the name |
554/1 | the pathx test |
same for domain / expires / max-age / samesite |
554/1 each | that arm's own new test |
(= k "secure") → prefix match |
555/0 | nothing |
(= k "httponly") → prefix match |
555/0 | nothing |
split at the last = (in attr-name and parse-set-prop) |
555/0 | nothing |
attr-name without trim / right-trim only |
518/37 | many |
attr-name left-trim only |
554/1 | SameSite = Strict only |
attr-name without lowercase |
523/32 | many |
| value untrimmed | 552/3 | 3 tests |
max-age-attr? back to the prefix test |
554/1 | the max-agex=0 precedence test |
known name with no = read as an empty value |
554/1 | the Path no-value test |
| Secure/HttpOnly honoured only without a value | 552/3 | the 3 new flag tests |
None of the three survivors is an equivalent mutant. On the probe corpus:
- prefix
securemakesid=abc; Securexset Secure; - prefix
httponlydoes the same forHttpOnlyx; - split-last drops the attribute from
Path=/a=b,Path==/aandDomain=a=b. master and this branch keep the values/a=b,=/aanda=b, which is RFC 6265 §5.2's split at the first=.
The body states both of these rules ("Split each cookie-av at the first =", "Compare the lowercased name exactly"), and this PR rewrote exactly the code that implements them:
split-by+joinbecameindex-of+byte-sliceathttp.carp:199/:209.- The whole-text compare for the flags became a name compare at
:216-217.
Two assertions would pin both, next to the new tests at test/http.carp:808-851:
(canonical-set-cookie "id=abc; Path=/a=b")→"id=abc; Path=/a=b; SameSite=Lax"(canonical-set-cookie "id=abc; Securex; HttpOnlyx")→"id=abc; Path=/; SameSite=Lax"
2. Downstream, this fixes whole-response failures, not just cookie fields. Response.parse (http.carp:655-658) aborts the entire response when any Set-Cookie fails parse-set. Measured on both sides:
| response carrying | master | branch |
|---|---|---|
Set-Cookie: id=abc; SameSite = Strict |
ERR Malformed response: found header 'malformed samesite property in set-cookie, unknown value: Strict' |
200, 1 cookie |
Set-Cookie: id=abc; Expires= Thu, 01 Jan 2037 00:00:00 GMT |
ERR Malformed response: … unrecognized date format: Thu, … |
200, 1 cookie |
http-client (Response.parse at http-client.carp:553, then Response.cookies → CookieJar.store-response!) and web pin http@0.7.0, so they only see this after a release and a pin bump. Nothing in either repo's tests pins the old prefix or error behaviour. I grepped every local clone for parse-set and Set-Cookie; I didn't run them. The body could mention the Response.parse effect, since it is the user-visible part.
3. Independent check against RFC 6265 §5.2/§5.3 (informational, not blocking). I wrote a §5.2 reference in Python: split at the first =, trim WSP (SP/HTAB), case-insensitive exact names, last Max-Age wins over Expires, 6265bis SameSite. I generated 3,000 random Set-Cookie strings (seed 20261006) with case-mixed, prefixed and suffixed names, SP/HTAB/VT padding around names, = and values, missing =, and values containing =. Each string went through master and the branch.
-
Branch fixes 922 that master got wrong. 0 inputs that master parses are rejected by the branch, and 315 that master rejected now parse.
-
Branch moves away from the RFC on 24, and all 24 contain a VT byte (0x0B).
trimis core's Pattern\strim. It also strips VT/FF/CR/LF, while §5.2 trims only SP/HTAB. Soid=abc;followed by VT and thenSecure=1now sets Secure, andDomain=followed by VT and thenexample.comloses the VT. master matched the RFC there only by accident: it droppedSecure=<value>entirely, and it never trimmed the start of a value. The trim set itself is pre-existing. I don't think it matters for real headers. -
The remaining 1,420 disagreements are the deviations the body keeps on purpose or never touched:
- errors instead of ignoring a bad or valueless known attribute;
- an empty or non-
/Pathkept, where the RFC uses the default path; Domain's leading.kept and not lowercased, and an emptyDomainkept;+accepted inMax-Age.
They aren't owed on this PR.
-
Nothing is left unexplained. A second model is §5.2 plus exactly those kept deviations: the Pattern
\strim, errors for valueless or unparseable known attributes,Path/Domainvalues kept verbatim, and+in Max-Age. It reproduces the branch on 3,000/3,000 inputs.
4. Nothing else in the file. The other byte-starts-with? uses are legitimate prefix tests (RFC 4647 language ranges, W/, quoted strings). max-age-attr? was the only other place doing name matching. Under --log-memory, parsing and re-serialising the 3,000 corpus strings twice leaves Debug.memory-balance unchanged on both master and the branch (delta 0; the absolute balance read 3,099 at that point, so the counter is live).
Verdict: merge
The change is correct and does what the body says (every before/after row reproduced, 0 new rejections on a 3,000-input RFC differential, CI green). The two unpinned rules in finding 1 deserve two one-line tests but don't block.
parse-set-proplowercased the whole trimmedname=valuetext of each cookie-av and matched it withbyte-starts-with?againstpath,domain,expires,max-ageandsamesite. It compared the whole text withsecure/httponly. So unknown attributes were taken for known ones, typos failed the cookie, andSecure/HttpOnlywritten with a value were dropped.It now follows RFC 6265 §5.2:
=. With no=, the whole text is the name.max-age-attr?uses the same name test, so amax-agex=attribute no longer takes Max-Age's precedence over Expires.id=abc; …pathx=1Domainx=evil.examplemax-agex=100Expiresx=foosamesitey=whateverSecure=1/HttpOnly=1SECURE; hTTpOnly=yesPath= /sp/spSameSite = StrictMax-Age=3600; Expires=<1970>; max-agex=0Unchanged: the errors for a known name without
=(Path,Domain,Expires,Max-Age,SameSite), for an unparseable Expires, a malformed Max-Age and an unknown SameSite value.Path=still sets an empty path. I compared all of these on master and the branch.Tests: 12 new. 11 fail on master. The 12th pins the kept
Path-without-a-value error. The suite gives 555 passed, 0 failed on armhf (543 on master). I tried six mutants: name trimmed only on the left, value untrimmed, prefixmax-age-attr?, flags only without a value, prefix Path, and no=read as an empty value. Each fails 1–3 tests.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.