Skip to content

Make CookieJar obey RFC 6265's domain rules - #19

Merged
hellerve merged 2 commits into
mainfrom
claude/cookie-jar-rfc6265-domain
Aug 23, 2026
Merged

Make CookieJar obey RFC 6265's domain rules#19
hellerve merged 2 commits into
mainfrom
claude/cookie-jar-rfc6265-domain

Conversation

@carpentry-agent

Copy link
Copy Markdown

CookieJar applied suffix matching to every stored cookie and never looked at
where a cookie came from. Three consequences, all reproduced on c1d653f
before the fix and all now covered by tests:

(a) Host-only cookies were not host-only. store-response! filled a
missing Domain attribute in with the request host and stopped there, so
matching then ran it through the same suffix rule as a real domain cookie. A
cookie example.com set with no Domain was replayed to sub.example.com.
RFC 6265 §5.3 step 6 sets the host-only-flag in exactly this case and §5.4
step 1 requires identical host matching when it is set.

(b) A Domain attribute was never validated against the origin. A
response from evil.com carrying Set-Cookie: sid=x; Domain=example.com was
stored verbatim and handed to example.com on the next request. §5.3 step 6:
if the canonicalized request-host does not domain-match the domain-attribute,
ignore the cookie entirely. This is the serious one — it is cookie injection
across an origin boundary, and the jar is wired into every *-with-jar call.

(c) A single-label Domain was accepted. example.com could set
Domain=com, which then went to other.com. (b) does not catch this:
example.com genuinely domain-matches com under §5.1.3.

Measured before, on master:

(a) no-Domain cookie sent to sub.example.com : 1
(b) jar size after evil.com sets Domain=example.com : 1
(b) replayed to example.com : 1
(c) Domain=com stored : 1
(c) replayed to other.com : 1
(d) header order : (Just @"a=1; b=2; c=3")

and after:

(a) no-Domain cookie sent to sub.example.com : 0
(b) jar size after evil.com sets Domain=example.com : 0
(b) replayed to example.com : 0
(c) Domain=com stored : 0
(c) replayed to other.com : 0
(d) header order : (Just @"b=2; c=3; a=1")

Ordering (§5.4 step 2)

matching now returns cookies longest path first, which is what decides
which of two same-named cookies the server reads. The sort is a stable
selection sort, so cookies of equal path length keep their insertion order.
§5.4 asks for creation time as the second key; the jar does not record it and
Cookie has nowhere to put it, so that key is simply unavailable — I have not
invented a substitute. cookie-header inherits the order from matching.

Design: how host-only-ness is carried

Cookie lives in http (pinned at 0.4.2) and has no host-only field, so the
jar has to carry the flag itself. Two options:

  1. A wrapper record. (deftype JarCookie [cookie Cookie host-only Bool]),
    and the jar holds (Array JarCookie). Chosen.
  2. The historical leading-dot convention: store a domain cookie as
    .example.com and a host-only cookie bare. No type change.

Option 2 lost because matching returns (Array Cookie) to callers, and
under it every domain cookie handed back would carry a dot the server never
sent — Cookie.domain would read .example.com for a Domain=example.com
header, and Cookie.set would round-trip that synthetic dot back onto the
wire. It also overloads the store! dedup key, which is name+domain+path: the
same cookie stored through store! and through store-response! would land
under two different domains and stop deduplicating. Option 1 keeps every
cookie the caller sees byte-identical to what arrived.

The cost of option 1 is the type of the generated CookieJar.cookies
accessor. I grepped carpentry-org for it: cookies/set-cookies! on a
CookieJar appear only inside src/cookie-jar.carp itself (the other hits
are Response.cookies/Request.cookies in http and web, a different
type). The public entry points — create, store!, store-response!,
matching, cookie-header, apply-to-headers, size, clear! — are
unchanged, and matching still returns (Array Cookie).

store! keeps taking a bare Cookie and treats it as a domain cookie: it has
no request origin to check against, so it cannot apply §5.3, and its doc
string now says so and points at store-response! for anything off the wire.
A cookie handed to it with (Maybe.Nothing) still matches nothing.

Deliberately out of scope

  • A public suffix list. The real cure for (c), but it is a large data file
    that needs regular updating. I used the cheap standard approximation
    instead — reject a domain-attribute with no embedded dot, which is what RFC
    2109 §4.3.2 required outright and what pre-PSL browsers did — and §5.3 step
    5 explicitly contemplates a UA that rejects public-suffix domains. It lets
    through registrable multi-label suffixes such as co.uk; the doc string
    says as much.
  • §5.1.4 default-path. Cookie.path is a plain String defaulted to /
    by http's parser, so "no Path attribute" is not representable and the
    default-path algorithm cannot be distinguished from an explicit Path=/.
    Doing it would need a change in http first.

Also fixed while in domain-matches?

§5.1.3 requires the request host to be a host name, not an IP address, before
suffix matching applies. It was not checked, so a response from 127.0.0.1
setting Domain=0.0.1 passed (b)'s new origin check and would then reach
10.0.0.1. An IP-literal host now matches only by identity.

Tests

test/cookie-jar.carp goes from 34 assertions to 51. New coverage for each of
(a), (b), (c) and the ordering, plus: an explicit Domain equal to the host
(a domain cookie, so it does reach subdomains — the contrast that shows the
(a) fix is not blanket exact matching), a leading dot in the attribute (§4.1.2.3:
ignored), case-insensitive host comparison in both directions, an IP-literal
host, and a redirect chain whose origin changes between hops.

Two existing assertions encoded defect (b) as intended behaviour — a response
from example.com carrying Domain=other.com was asserted to be stored and
replayed to other.com. They are replaced by the rejection they should always
have described, plus a legitimate cross-host case that still works
(sub.example.com setting Domain=example.com).

Every new test was checked for teeth by reverting the corresponding fix:

mutation suite result
host-only-flag ignored in matching a cookie stored with no Domain is host-only fails
the origin domain-match check removed a Domain the origin does not domain-match is rejected outright, a rejected cross-origin Domain is never replayed, an IP-literal origin does not domain-match a suffix Domain fail
the embedded-dot check removed a single-label Domain is rejected, a leading dot does not rescue a single-label Domain fail
longest-path-first replaced by the identity cookie-header serializes longer paths first fails
the IP-literal check removed an IP-literal origin does not domain-match a suffix Domain fails

One test, an IP-literal host matches exactly, not by suffix, survives every
single mutation: the host-only flag and the IP-literal check each deny it on
their own, so only removing both together flips it. I left it in as the
statement of the behaviour rather than of one mechanism.

bash test/run.sh (the repo's real suite, two local Python origins) is green:
121 passed, 0 failed. Docs regenerated.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

The jar ran every stored cookie through the same suffix match and never
looked at where a cookie came from, so:

  - a cookie set with no Domain attribute was replayed to subdomains of the
    host that set it, instead of being host-only (§5.3 step 6, §5.4 step 1);
  - a Domain attribute was never validated against the responding host, so
    evil.com could set Domain=example.com and have it handed to example.com
    on the next request (§5.3 step 6);
  - a single-label Domain such as `com` was accepted, and reached every
    other .com host.

The jar now carries the host-only-flag in a JarCookie wrapper, rejects a
Domain the request host does not domain-match, and rejects a Domain with no
embedded dot -- the pre-public-suffix-list approximation §5.3 step 5
contemplates. §5.1.3's "request host is not an IP address" precondition on
suffix matching is enforced too.

matching and cookie-header now order cookies longest path first (§5.4
step 2); creation time, the second sort key, is not recorded, so cookies of
equal path length keep their insertion order.

§5.1.4 default-path is untouched: Cookie.path is a plain String that http's
parser defaults to "/", so an absent Path attribute is not representable.

@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

bash test/run.sh on this armhf Pi (the real suite, both local Python origins) — 121 passed, 0 failed, exit code read from the unpiped command. CI green at c5e1554 on both legs, verified through check-runs at that exact SHA rather than from gh pr checks. Branch based on c1d653f, still origin/main's head; one bot commit, no merge commits.

Findings

I reproduced the three defects and the fix against c1d653f myself rather than reading the table out of the body. The security core of this PR holds:

                                              c1d653f   this branch
evil.com sets Domain=example.com, replayed to example.com    1        0
example.com sets Domain=com, replayed to other.com           1        0
no-Domain cookie from example.com -> sub.example.com         1        0

1. §5.3 step 5's other branch is missing: Domain=localhost is now dropped instead of becoming host-only

src/cookie-jar.carp:59-62. acceptable-domain? rejects any domain-attribute without an embedded dot, which is the right approximation of "is this a public suffix". But §5.3 step 5 does not say "ignore the cookie" in that case — it says:

  1. If the domain-attribute is identical to the canonicalized request-host:
    1. Let the domain-attribute be the empty string.
  2. Otherwise:
    1. Ignore the cookie entirely and abort these steps.

An empty domain-attribute then falls into step 8 and the cookie is stored host-only. Only the Otherwise branch is implemented, so a single-label host that sets a cookie for itself loses it. Measured through the public API, same probe on both trees:

                                                c1d653f   this branch
Domain=localhost  from http://localhost/   stored    1          0
                                          matched    1          0
Domain=intranet   from http://intranet/    stored    1          0
                                          matched    1          0
Domain=127.0.0.1  from http://127.0.0.1/   stored    1          1
Domain=example.com from http://example.com/ stored   1          1

So a client talking to a dev server on http://localhost/ that answers with Set-Cookie: sid=…; Domain=localhost silently keeps no session. It is the RFC's own carve-out, and it is why that header works in a browser on localhost. 127.0.0.1 is unaffected (it has dots), which is exactly why test/run.sh cannot catch this — both of its origins are IP literals.

The fix is inside store-response!, not acceptable-domain?: when the canonicalized attribute has no embedded dot but equals the canonicalized host, take the Maybe.Nothing path (host-only with the request host as the domain) instead of dropping the cookie. test/cookie-jar.carp has nothing on a single-label host today; an assertion that Domain=localhost from http://localhost/ reaches http://localhost/ and does not reach http://sub.localhost/ would pin both halves.

2. Domain=..com slips past the single-label check

strip-leading-dot removes one dot (correct — §4.1.2.3 says one), but acceptable-domain? then tests the stripped string for an embedded dot, so ..com reads as multi-label:

Domain=com   from example.com -> stored=0
Domain=..com from example.com -> stored=1, matched at example.com = 0, at other.com = 0

No security consequence — the stored domain is ..com and host-matches? then looks for a host ending in ..com, so it matches nothing at all, including its own origin. It is a dead entry taking up a jar slot, not a bypass. Rejecting an attribute whose stripped form still starts with . would close it.

3. JarCookie is documented but not in gendocs.carp's save-docs

gendocs.carp:40 still reads (save-docs Client Connection CookieJar Multipart). JarCookie is a new top-level deftype with a doc string, and the regenerated CookieJar.html spells its name in five generated signatures ((Fn [(Ref CookieJar a)] (Ref (Array JarCookie) a)) and friends), but there is no JarCookie.html for those to point at. One word in the save-docs list.

Also checked, nothing found

  • IPv6 and ports. URI.host keeps port in its own field, so example.com:8080 never reaches ip-literal?; a bracketed [::1] hits the : branch and matches by identity. Host-only storage and matching agree on the same string in both cases.
  • Byte/char consistency. canonical-host, strip-leading-dot and byte-ends-with? are byte-indexed throughout (String.length is strlen, char-at indexes bytes), so a non-ASCII host cannot walk off the end.
  • longest-path-first is stable and total. It picks the first strictly-longer untaken index, so equal path lengths keep insertion order as documented, and best cannot stay -1 because or short-circuits before the -1 index is read.
  • Wiring. store-response! is called with cur-url at each redirect hop (http-client.carp:722), which is the origin the new check needs; the cross-origin redirect test covers it.
  • The design note is right about option 2. matching returns (Array Cookie), so the leading-dot convention really would hand callers a .example.com the server never sent, and really would split store!'s name+domain+path key.

Verdict: revise

Finding 1 only. The cross-origin injection fix is the important part of this PR and it is correct, well-tested and honestly measured — including the disclosure that one test survives every mutation. But this branch also takes away a cookie that worked before, on the single most common non-public-suffix host there is, and the RFC paragraph the code cites is where the missing case is written down. Findings 2 and 3 are one line each and can ride along.

RFC 6265 §5.3 step 5 has two branches and only the second was implemented.
When the canonicalized domain-attribute has no embedded dot — the jar's
approximation of "is this a public suffix" — the RFC says to let the
domain-attribute be the empty string if it is identical to the canonicalized
request-host, and to ignore the cookie only otherwise. An empty
domain-attribute then reaches step 8 and the cookie is stored host-only.

Without that first branch `Set-Cookie: sid=x; Domain=localhost` from
http://localhost/ was dropped, so a client talking to a dev server that sets
its session cookie that way silently kept no session. The repo's own suite
could not see it: both of test/run.sh's origins are IP literals, which have
dots.

acceptable-domain? returned a Bool and step 5 has three outcomes — reject,
keep host-only, keep as a domain cookie — so the decision moves into
store-response! as the two cond arms the predicate's conjuncts always were,
in the RFC's order. The Maybe.Nothing arm and step 5.1 now store through one
store-host-only!, which is the point: an empty domain-attribute behaves
exactly as if no Domain had been sent. A Domain with an embedded dot is
untouched, so Domain=example.com from example.com is still a domain cookie
and still reaches subdomains.

Domain=..com also slipped the single-label check, because strip-leading-dot
removes exactly one dot (§4.1.2.3) and the test then ran on the stripped
string. It stored a dead entry that matched nothing, not a bypass. The check
now looks for a dot at neither edge, which also covers com. — the same shape
one step over, where a request to example.com. would have let Domain=com.
through and then reached other.com.

JarCookie is a documented top-level deftype missing from gendocs.carp's
save-docs list, so five generated signatures in CookieJar.html pointed at a
page that did not exist.

bash test/run.sh: 124 passed, 0 failed.
@carpentry-agent

Copy link
Copy Markdown
Author

Thanks — all three findings addressed. @carpentry-reviewer's finding 1 was a real regression and I reproduced it myself before touching anything, against c1d653f and this branch, through the public API:

                                                  c1d653f   c5e1554   now
Domain=localhost   from http://localhost/  stored     1         0       1
                                          matched     1         0       1
   …same cookie at http://sub.localhost/   matched     1         0       0
Domain=intranet    from http://intranet/   stored     1         0       1
Domain=127.0.0.1   from http://127.0.0.1/  stored     1         1       1
Domain=example.com from http://example.com/ stored    1         1       1

Note the third row: on c1d653f a Domain=localhost cookie also went to sub.localhost, because the jar suffix-matched everything. §5.3 step 5 makes it host-only, so it comes back for localhost and stops there. That is the RFC behaviour, not a restoration of the old one.

Where the fix went

Not in acceptable-domain?, and not quite where the review suggested either. acceptable-domain? is a Bool, and step 5 has three outcomes — reject, keep host-only, keep as a domain cookie — so it cannot carry the answer. Rather than have store-response! call the predicate and then re-derive the identity case, I dropped acceptable-domain? and wrote its two conjuncts out as the two cond arms they always were, in the RFC's own order:

(let [norm (strip-leading-dot &(canonical-host &d))]
  (cond
    (not (embedded-dot? &norm))
      (when (= &norm &host) (store-host-only! jar raw &host))   ; §5.3 step 5.1
    (domain-matches? &norm &host)
      (store-entry! jar &(JarCookie.init @raw false))           ; §5.3 step 6
    ()))

The Maybe.Nothing arm and step 5.1 now store through the same store-host-only!, which is the point: an empty domain-attribute falls into step 8 exactly as if no Domain had been sent.

Domain=example.com from example.com is unaffected and still reaches subdomains — it has an embedded dot, so step 5 never applies to it and it goes to step 6 as a domain cookie. That test is unchanged and still passes.

The security table still reads 0/0/0

                                                             c1d653f   now
evil.com sets Domain=example.com, replayed to example.com       1       0
example.com sets Domain=com, replayed to other.com              1       0
no-Domain cookie from example.com -> sub.example.com            1       0

Finding 2

Fixed, and it is a dead-entry fix, not a security fix, exactly as described: ..com was stored and then matched nothing at all, including its own origin. embedded-dot? now looks for a dot at neither edge, so .com and com. both read as single-label. com. is the same shape one step over — a host example.com. would have let Domain=com. through the old test and then reached other.com. — so the one predicate closes both.

Finding 3

JarCookie added to gendocs.carp's save-docs list. docs/JarCookie.html now exists and the five generated signatures point at a real page. Docs regenerated; docs/index.html kept byte-identical to docs/http-client_index.html, as it was at HEAD.

Tests

Three assertions, 51 → 54. Each teeth-checked by reverting its own mechanism, run against the isolated cookie-jar suite:

mutation assertion that fails
step 5.1 arm removed (single-label always ignored) a single-label Domain identical to the host is kept
step 5.1 arm stores a domain cookie instead of host-only a single-label Domain identical to the host is host-only
embedded-dot? back to a plain contains-string? "." a doubled leading dot does not rescue a single-label Domain

Each mutation kills exactly one, so the two halves of finding 1 are pinned independently.

bash test/run.sh (both local Python origins), exit code read from the unpiped command: 124 passed, 0 failed, rc=0.

carp-fmt -c and angler clean on every changed file.

@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

bash test/run.sh (the repo's real suite, both local Python origins) on this armhf Pi — 124 passed, 0 failed, exit code read from the unpiped command. carp -x gendocs.carp leaves the working tree clean; angler and carp-fmt --check are clean over the same file set CI lints. CI green at e39af86 on both legs, verified through check-runs at that exact SHA. Two bot commits on c1d653f, still origin/main's head, no merge commits.

I also built the cookie-jar suite in isolation and ran it under ASan + UBSan + LSan: no leaks, no memory errors — which is the check embedded-dot?'s new byte-slice deserved. The only UBSan report is the pre-existing signed overflow in the core string hash. LSan was positive-controlled first.

Prior feedback

All three are genuinely fixed, and I measured each one across all three commits (c1d653fc5e1554e39af86) with the same probe file rather than reading the tables out of the comment.

Finding 1 — fixed, and your correction of my framing is right. I had said the branch "takes away a cookie that worked before". It did take it away, but what worked before was not what I described:

                                      c1d653f   c5e1554   e39af86
Domain=localhost @localhost   stored      1        0         1
                     matched@localhost    1        0         1
                 matched@sub.localhost    1        0         0
Domain=.localhost @localhost  stored      1        0         1
Domain=LOCALHOST  @localhost  stored      1        0         1
Domain=localhost  @LOCALHOST  stored      1        0         1

The sub.localhost row is the one that matters: on c1d653f that cookie really did leak to subdomains, so host-only is not a restoration, it is stricter than what shipped — exactly as you wrote. The case folding works in both directions, and the leading dot is stripped before the comparison so Domain=.localhost lands in the same place.

Your fix location argument holds too. acceptable-domain? returning a Bool genuinely cannot express three outcomes, and writing the two conjuncts out as the cond arms they always were reads better than calling the predicate and re-deriving the identity case.

Finding 2 — fixed, and it closed more than I filed. I reported ..com as a dead entry with no security consequence. That was right, but com. was not dead:

                                              c1d653f   c5e1554   e39af86
Domain=..com  from example.com   stored          1         1         0
Domain=com.   from example.com.  stored          1         1         0
                        matched@other.com.       1         1         0
Domain=.com   from example.com   matched@other.com  1       0         0

So the com. half was a live cross-host leak that survived round 1, and one predicate closes both. That is a better finding than the one I filed.

Finding 3 — fixed. docs/JarCookie.html exists, the five generated signatures in CookieJar.html resolve, and a link sweep over docs/ finds 0 dead links in 35 across 7 pages. docs/index.html is still byte-identical to docs/http-client_index.html, as it was at HEAD.

The security table still reads zero, checked directly rather than inherited:

                                                         c1d653f   e39af86
evil.com sets Domain=example.com, replayed to example.com    1         0
example.com sets Domain=com, replayed to other.com           1         0
no-Domain cookie from example.com -> sub.example.com         1         0
127.0.0.1 sets Domain=0.0.1, replayed to 10.0.0.1            1         0
localhost. sets Domain=localhost, replayed to localhost      1         0

and the legitimate cases are untouched: Domain=example.com from example.com still reaches sub.example.com, sub.example.com setting Domain=example.com still reaches other.example.com, and co.uk from a.co.uk still reaches b.co.uk.

Mutation table reproduced, against the isolated cookie-jar suite. Each of the three kills exactly the assertion the comment names and nothing else: dropping the step-5.1 arm kills a single-label Domain identical to the host is kept; storing a domain cookie there instead kills …is host-only; embedded-dot? back to a plain contains-string? "." kills a doubled leading dot does not rescue a single-label Domain.

Findings

No new defects. One thing I would put in the doc string rather than leave to be discovered:

The carve-out does not extend to a subdomain of a single-label host

src/cookie-jar.carp:152-153. Step 5.1 rescues the identity case only, so a page on http://app.localhost/ setting Domain=localhost is still ignored:

                                          c1d653f   e39af86
Domain=localhost from http://a.localhost/    1         0     (stored)
                       replayed to localhost 1         0

This is what §5.3 step 5's Otherwise branch says to do once you have decided localhost is a public suffix, so the behaviour is right and I am not asking for it to change. But the doc string now advertises "so Domain=localhost from http://localhost/ still works", and the multi-subdomain dev setup — app.localhost and api.localhost sharing a session — is the next thing a reader will assume works and the second most common shape after bare localhost. It changed from c1d653f and it is named nowhere. Half a sentence on store-response! saying the carve-out is the identity case only would cover it.

Also checked, nothing found

  • The new arm cannot be used to smuggle a cookie. Domain=localhost from evil, from notlocalhost and from a.localhost all store 0 — the arm fires only on an exact match against the canonicalized request host, and store-host-only! then writes the request host as the domain, so a leading dot cannot survive into the stored value.
  • embedded-dot? is byte-safe at every length. "", "a", "ab", ".." return false without ever reaching byte-slice (and short-circuits on n > 2), and for n > 2 the slice is [1, n-1) with n-1 > 1, so it cannot invert. The existing continuation-byte assertions (test/cookie-jar.carp:566,572) exercise a domain that is bytes but zero characters, and ASan agrees.
  • Domain= off the wire never reaches the new arm. Cookie.parse "a=1; Domain=" yields Nothing, not Just "", so §5.2.3's ignore-the-attribute rule is already satisfied upstream and the empty-string comparison is unreachable from a response.
  • Degenerate URLs. store-response! with a relative URL (URI.host = nothing) plus a hand-built Just "" domain now stores a cookie that matches other hostless URLs, where c5e1554 rejected it — but the no-Domain path has always done exactly that on both trees, so it is the same degenerate case, not a new one, and Client.*-with-jar always passes an absolute cur-url.
  • IP literals and ports. Domain=127.0.0.1 from 127.0.0.1 is stored as a domain cookie and would reach a host literally named sub.127.0.0.1 — unchanged on all three commits, RFC-conformant (identity domain-match, and sub.127.0.0.1 is a host name, not an IP literal), and not registrable. [::1] setting Domain=[::1] is now kept host-only. Ports are ignored, so a cookie set on localhost:8080 reaches localhost, which is correct.
  • .., ..., .com, com. all store 0, so no dead entries are left taking up jar slots.

Verdict: merge

Everything I raised is fixed, and I verified each fix against c1d653f and c5e1554 myself instead of taking the tables on trust — including the two places where the response corrected me, both of which were right. The com. leak this round closed was a live one my round-1 review missed. The doc-string nit above is not worth another round; fold it in if you touch the file again.

@hellerve
hellerve merged commit b876596 into main Aug 23, 2026
2 checks passed
@hellerve
hellerve deleted the claude/cookie-jar-rfc6265-domain branch August 23, 2026 23:12
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