Skip to content

Match Set-Cookie attributes by name, not by prefix - #50

Merged
hellerve merged 1 commit into
masterfrom
claude/set-cookie-attr-names
Oct 6, 2026
Merged

hellerve merged 1 commit into
masterfrom
claude/set-cookie-attr-names

Conversation

@carpentry-agent

Copy link
Copy Markdown

parse-set-prop lowercased the whole trimmed name=value text of each cookie-av and matched it with byte-starts-with? against path, domain, expires, max-age and samesite. It compared the whole text with secure/httponly. So unknown attributes were taken for known ones, typos failed the cookie, and Secure/HttpOnly written with a value were dropped.

It now follows RFC 6265 §5.2:

  • Split each cookie-av at the first =. With no =, the whole text is the name.
  • Trim the name and the value.
  • Compare the lowercased name exactly. Unknown names are ignored, and Secure/HttpOnly are set whatever their value.

max-age-attr? uses the same name test, so a max-agex= attribute no longer takes Max-Age's precedence over Expires.

id=abc; … master branch
pathx=1 Path=1 Path=/
Domainx=evil.example Domain=evil.example no Domain
max-agex=100 expiry set no expiry
Expiresx=foo error ignored
samesitey=whatever error ignored
Secure=1 / HttpOnly=1 flag unset flag set
SECURE; hTTpOnly=yes Secure only Secure, HttpOnly
Path= /sp Path= /sp Path=/sp
SameSite = Strict error Strict
Max-Age=3600; Expires=<1970>; max-agex=0 expired live

Unchanged: 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, prefix max-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.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

  • Branch (4bdebcb, armhf, local compat core): carp -x test/http.carp gives 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.carp runs clean on the branch (rc 0).
  • The new tests have teeth. I ran master's http.carp under the branch's test/http.carp and 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-Age and SameSite;
  • Path= still giving an empty path;
  • the errors for Expires=garbage, Max-Age=abc and SameSite=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 secure makes id=abc; Securex set Secure;
  • prefix httponly does the same for HttpOnlyx;
  • split-last drops the attribute from Path=/a=b, Path==/a and Domain=a=b. master and this branch keep the values /a=b, =/a and a=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 + join became index-of + byte-slice at http.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). trim is core's Pattern \s trim. It also strips VT/FF/CR/LF, while §5.2 trims only SP/HTAB. So id=abc; followed by VT and then Secure=1 now sets Secure, and Domain= followed by VT and then example.com loses the VT. master matched the RFC there only by accident: it dropped Secure=<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-/ Path kept, where the RFC uses the default path;
    • Domain's leading . kept and not lowercased, and an empty Domain kept;
    • + accepted in Max-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 \s trim, errors for valueless or unparseable known attributes, Path/Domain values 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.

@hellerve
hellerve marked this pull request as ready for review October 6, 2026 09:40
@hellerve
hellerve merged commit 64cd5f0 into master Oct 6, 2026
2 checks passed
@hellerve
hellerve deleted the claude/set-cookie-attr-names branch October 6, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant