Make CookieJar obey RFC 6265's domain rules - #19
Conversation
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.
There was a problem hiding this comment.
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:
- If the domain-attribute is identical to the canonicalized request-host:
- Let the domain-attribute be the empty string.
- Otherwise:
- 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.hostkeepsportin its own field, soexample.com:8080never reachesip-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-dotandbyte-ends-with?are byte-indexed throughout (String.lengthisstrlen,char-atindexes bytes), so a non-ASCII host cannot walk off the end. longest-path-firstis stable and total. It picks the first strictly-longer untaken index, so equal path lengths keep insertion order as documented, andbestcannot stay-1becauseorshort-circuits before the-1index is read.- Wiring.
store-response!is called withcur-urlat 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.
matchingreturns(Array Cookie), so the leading-dot convention really would hand callers a.example.comthe server never sent, and really would splitstore!'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.
|
Thanks — all three findings addressed. @carpentry-reviewer's finding 1 was a real regression and I reproduced it myself before touching anything, against Note the third row: on Where the fix wentNot in The
The security table still reads 0/0/0Finding 2Fixed, and it is a dead-entry fix, not a security fix, exactly as described: Finding 3
TestsThree assertions, 51 → 54. Each teeth-checked by reverting its own mechanism, run against the isolated cookie-jar suite:
Each mutation kills exactly one, so the two halves of finding 1 are pinned independently.
|
There was a problem hiding this comment.
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 (c1d653f → c5e1554 → e39af86) 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=localhostfromevil, fromnotlocalhostand froma.localhostall store 0 — the arm fires only on an exact match against the canonicalized request host, andstore-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 reachingbyte-slice(andshort-circuits onn > 2), and forn > 2the slice is[1, n-1)withn-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="yieldsNothing, notJust "", 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-builtJust ""domain now stores a cookie that matches other hostless URLs, wherec5e1554rejected it — but the no-Domainpath has always done exactly that on both trees, so it is the same degenerate case, not a new one, andClient.*-with-jaralways passes an absolutecur-url. - IP literals and ports.
Domain=127.0.0.1from127.0.0.1is stored as a domain cookie and would reach a host literally namedsub.127.0.0.1— unchanged on all three commits, RFC-conformant (identity domain-match, andsub.127.0.0.1is a host name, not an IP literal), and not registrable.[::1]settingDomain=[::1]is now kept host-only. Ports are ignored, so a cookie set onlocalhost:8080reacheslocalhost, 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.
CookieJarapplied suffix matching to every stored cookie and never looked atwhere a cookie came from. Three consequences, all reproduced on
c1d653fbefore the fix and all now covered by tests:
(a) Host-only cookies were not host-only.
store-response!filled amissing
Domainattribute in with the request host and stopped there, somatchingthen ran it through the same suffix rule as a real domain cookie. Acookie
example.comset with noDomainwas replayed tosub.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
Domainattribute was never validated against the origin. Aresponse from
evil.comcarryingSet-Cookie: sid=x; Domain=example.comwasstored verbatim and handed to
example.comon 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-jarcall.(c) A single-label
Domainwas accepted.example.comcould setDomain=com, which then went toother.com. (b) does not catch this:example.comgenuinely domain-matchescomunder §5.1.3.Measured before, on master:
and after:
Ordering (§5.4 step 2)
matchingnow returns cookies longest path first, which is what decideswhich 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
Cookiehas nowhere to put it, so that key is simply unavailable — I have notinvented a substitute.
cookie-headerinherits the order frommatching.Design: how host-only-ness is carried
Cookielives inhttp(pinned at 0.4.2) and has no host-only field, so thejar has to carry the flag itself. Two options:
(deftype JarCookie [cookie Cookie host-only Bool]),and the jar holds
(Array JarCookie). Chosen..example.comand a host-only cookie bare. No type change.Option 2 lost because
matchingreturns(Array Cookie)to callers, andunder it every domain cookie handed back would carry a dot the server never
sent —
Cookie.domainwould read.example.comfor aDomain=example.comheader, and
Cookie.setwould round-trip that synthetic dot back onto thewire. It also overloads the
store!dedup key, which is name+domain+path: thesame cookie stored through
store!and throughstore-response!would landunder 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.cookiesaccessor. I grepped
carpentry-orgfor it:cookies/set-cookies!on aCookieJarappear only insidesrc/cookie-jar.carpitself (the other hitsare
Response.cookies/Request.cookiesinhttpandweb, a differenttype). The public entry points —
create,store!,store-response!,matching,cookie-header,apply-to-headers,size,clear!— areunchanged, and
matchingstill returns(Array Cookie).store!keeps taking a bareCookieand treats it as a domain cookie: it hasno 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
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 stringsays as much.
Cookie.pathis a plainStringdefaulted to/by
http's parser, so "noPathattribute" is not representable and thedefault-path algorithm cannot be distinguished from an explicit
Path=/.Doing it would need a change in
httpfirst.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.1setting
Domain=0.0.1passed (b)'s new origin check and would then reach10.0.0.1. An IP-literal host now matches only by identity.Tests
test/cookie-jar.carpgoes from 34 assertions to 51. New coverage for each of(a), (b), (c) and the ordering, plus: an explicit
Domainequal 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.comcarryingDomain=other.comwas asserted to be stored andreplayed to
other.com. They are replaced by the rejection they should alwayshave described, plus a legitimate cross-host case that still works
(
sub.example.comsettingDomain=example.com).Every new test was checked for teeth by reverting the corresponding fix:
matchinga cookie stored with no Domain is host-onlyfailsa 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 Domainfaila single-label Domain is rejected,a leading dot does not rescue a single-label Domainfaillongest-path-firstreplaced by the identitycookie-header serializes longer paths firstfailsan IP-literal origin does not domain-match a suffix DomainfailsOne test,
an IP-literal host matches exactly, not by suffix, survives everysingle 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.