From c5e1554cba4fedd3c34b0b850ea9de8698c0504b Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Sun, 23 Aug 2026 17:27:04 +0200
Subject: [PATCH 1/2] Make CookieJar obey RFC 6265's domain rules
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
README.md | 18 ++--
docs/CookieJar.html | 35 ++++---
src/cookie-jar.carp | 163 +++++++++++++++++++++++---------
test/cookie-jar.carp | 215 ++++++++++++++++++++++++++++++++++++-------
4 files changed, 336 insertions(+), 95 deletions(-)
diff --git a/README.md b/README.md
index 2ce5316..02521c2 100644
--- a/README.md
+++ b/README.md
@@ -98,10 +98,14 @@ subsequent requests automatically:
(Result.Error e) (IO.errorln &e)))
```
-The jar handles domain matching (RFC 6265 suffix rules), path matching,
-Secure flag enforcement, and expiry. Cookies are deduplicated by
-name+domain+path. During redirects, cookies from every hop are stored and
-re-applied for each new URL.
+The jar follows RFC 6265 §5.3 and §5.4. A cookie that arrives with no
+`Domain` attribute is host-only: it goes back to the host that set it and to
+no subdomain. A `Domain` attribute the responding host does not domain-match
+is rejected outright, and so is a single-label one such as `Domain=com`. On
+top of that the jar enforces path matching, the `Secure` flag, and expiry;
+cookies are deduplicated by name+domain+path and serialized longest path
+first. During redirects, cookies from every hop are stored and re-applied for
+each new URL.
### Multipart uploads
@@ -207,9 +211,9 @@ given.
| Function | Purpose |
|----------|---------|
| `CookieJar.create` | Create an empty jar |
-| `CookieJar.store! jar cookie` | Store a cookie, replacing duplicates by name+domain+path |
-| `CookieJar.store-response! jar response url` | Store cookies from a response, defaulting domain from URL |
-| `CookieJar.matching jar url` | Return cookies matching the URL by domain, path, security, and expiry |
+| `CookieJar.store! jar cookie` | Store a cookie as a domain cookie, replacing duplicates by name+domain+path |
+| `CookieJar.store-response! jar response url` | Store a response's cookies, applying RFC 6265 §5.3's origin checks |
+| `CookieJar.matching jar url` | Return cookies matching the URL by domain, path, security, and expiry, longest path first |
| `CookieJar.cookie-header jar url` | Build a `Cookie` header value, or `Nothing` if no cookies match |
| `CookieJar.apply-to-headers jar url headers` | Add a `Cookie` header to the headers map |
| `CookieJar.size jar` | Number of stored cookies |
diff --git a/docs/CookieJar.html b/docs/CookieJar.html
index 1f019d4..0fdb74d 100644
--- a/docs/CookieJar.html
+++ b/docs/CookieJar.html
@@ -117,7 +117,7 @@
builds a Cookie header value for the URL, or Nothing
-if no cookies match.
+if no cookies match. Cookies are serialized longest path first.
@@ -131,7 +131,7 @@
instantiate
- (Fn [(Ref CookieJar a)] (Ref (Array Cookie) a))
+ (Fn [(Ref CookieJar a)] (Ref (Array JarCookie) a))
@@ -211,7 +211,7 @@
instantiate
- (Fn [(Array Cookie)] CookieJar)
+ (Fn [(Array JarCookie)] CookieJar)
@@ -238,7 +238,12 @@
returns cookies matching the given URL by domain, path,
-security, and expiry.
+security, and expiry, longest path first (RFC 6265 §5.4 step 2). Cookies of
+equal path length keep their insertion order; the creation time §5.4 asks for
+as the second sort key is not recorded.
+A cookie stored without a Domain attribute is host-only and matches its
+origin host alone; one stored with a Domain attribute also matches that
+domain’s subdomains, unless the request host is an IP literal.
@@ -272,7 +277,7 @@
instantiate
- (Fn [CookieJar, (Array Cookie)] CookieJar)
+ (Fn [CookieJar, (Array JarCookie)] CookieJar)
@@ -292,7 +297,7 @@
instantiate
- (Fn [(Ref CookieJar a), (Array Cookie)] ())
+ (Fn [(Ref CookieJar a), (Array JarCookie)] ())
@@ -332,14 +337,16 @@
defn
- (Fn [(Ref CookieJar a), (Ref Cookie a)] ())
+ (Fn [(Ref CookieJar a), (Ref Cookie b)] ())
(store! jar c)
-
stores a cookie, replacing any with the same name, domain,
-and path.
+ stores a cookie as a domain cookie, replacing any with the same
+name, domain, and path. The cookie is trusted as given: there is no request
+origin to check it against, so prefer store-response! for anything that came
+off the wire.
@@ -359,8 +366,12 @@
(store-response! jar resp url)
-
stores cookies from a response. The URL provides the
-default domain for cookies without a Domain attribute.
+ stores cookies from a response, applying RFC 6265 §5.3.
+A cookie with no Domain attribute becomes host-only: it is replayed to the
+URL’s host and to no other. A Domain attribute the URL’s host does not
+domain-match is rejected outright, and so is one with no embedded dot — the
+latter is the pre-public-suffix-list approximation of RFC 6265 §5.3 step 5,
+and it lets through registrable multi-label suffixes such as co.uk.
@@ -394,7 +405,7 @@
instantiate
- (Fn [CookieJar, (Ref (Fn [(Array Cookie)] (Array Cookie) a) b)] CookieJar)
+ (Fn [CookieJar, (Ref (Fn [(Array JarCookie)] (Array JarCookie) a) b)] CookieJar)
diff --git a/src/cookie-jar.carp b/src/cookie-jar.carp
index 90ab9da..31c31ca 100644
--- a/src/cookie-jar.carp
+++ b/src/cookie-jar.carp
@@ -2,6 +2,12 @@
; Stores cookies from Set-Cookie response headers and replays matching
; cookies on subsequent requests by domain, path, and expiry.
+(doc JarCookie "is a stored cookie together with RFC 6265 §5.3’s
+host-only-flag, which is set when the cookie arrived without a `Domain`
+attribute and may therefore only go back to the host that set it.")
+(deftype JarCookie [cookie Cookie
+ host-only Bool])
+
(doc CookieJar "stores cookies and replays them on matching requests.
Create a jar with `CookieJar.create`, then pass it to `Client.get-with-jar`
@@ -17,19 +23,50 @@ and similar functions:
(Result.Success r) (println* (Response.body &r))
(Result.Error e) (IO.errorln &e)))
```")
-(deftype CookieJar [cookies (Array Cookie)])
+(deftype CookieJar [cookies (Array JarCookie)])
(defmodule CookieJar
(doc create "creates an empty cookie jar.")
(defn create [] (init []))
+ (hidden canonical-host)
+ (private canonical-host)
+ (defn canonical-host [s] (String.ascii-to-lower &(String.trim s)))
+
+ (hidden strip-leading-dot)
+ (private strip-leading-dot)
+ (defn strip-leading-dot [d] (if (byte-starts-with? d ".") (drop-bytes d 1) @d))
+
+ (hidden ip-literal?)
+ (private ip-literal?)
+ (defn ip-literal? [host]
+ (and (not (String.empty? host))
+ (or (String.contains-string? host ":")
+ (Array.all?
+ &(fn [b] (let [v @b] (or (= v 46b) (and (> v 47b) (< v 58b)))))
+ &(String.to-bytes host)))))
+
(hidden domain-matches?)
(private domain-matches?)
(defn domain-matches? [cookie-domain host]
- (let [cd (String.ascii-to-lower &(String.trim cookie-domain))
- h (String.ascii-to-lower host)
- norm (if (byte-starts-with? &cd ".") (drop-bytes &cd 1) @&cd)]
- (or (= &norm &h) (byte-ends-with? &h &(fmt ".%s" &norm)))))
+ (let [norm (strip-leading-dot &(canonical-host cookie-domain))
+ h (canonical-host host)]
+ (or (= &norm &h)
+ (and (not (ip-literal? &h)) (byte-ends-with? &h &(fmt ".%s" &norm))))))
+
+ (hidden acceptable-domain?)
+ (private acceptable-domain?)
+ (defn acceptable-domain? [attr host]
+ (let [norm (strip-leading-dot &(canonical-host attr))]
+ (and (String.contains-string? &norm ".") (domain-matches? &norm host))))
+
+ (hidden host-matches?)
+ (private host-matches?)
+ (defn host-matches? [e host]
+ (let [d (Maybe.from @(Cookie.domain (JarCookie.cookie e)) @"")]
+ (if @(JarCookie.host-only e)
+ (= &(canonical-host &d) host)
+ (domain-matches? &d host))))
(hidden path-matches?)
(private path-matches?)
@@ -39,63 +76,105 @@ and similar functions:
(byte-starts-with? request-path cookie-path)
(byte-starts-with? request-path &(fmt "%s/" cookie-path)))))
- (doc store! "stores a cookie, replacing any with the same name, domain,
-and path.")
- (defn store! [jar c]
- (let [filtered (Array.reduce
+ (hidden longest-path-first)
+ (private longest-path-first)
+ (defn longest-path-first [cs]
+ (let-do [n (Array.length cs)
+ lens (Array.copy-map &(fn [c] (String.length (Cookie.path c))) cs)
+ taken (Array.replicate n &false)
+ out (the (Array Cookie) [])]
+ (for [-k 0 n]
+ (let-do [best -1]
+ (for [i 0 n]
+ (when (and (not @(Array.unsafe-nth &taken i))
+ (or (= best -1)
+ (> @(Array.unsafe-nth &lens i)
+ @(Array.unsafe-nth &lens best))))
+ (set! best i)))
+ (Array.aset! &taken best true)
+ (set! out (Array.push-back out @(Array.unsafe-nth cs best)))))
+ out))
+
+ (hidden store-entry!)
+ (private store-entry!)
+ (defn store-entry! [jar e]
+ (let [c (JarCookie.cookie e)
+ filtered (Array.reduce
&(fn [acc existing]
- (if (and
- (= (Cookie.name existing) (Cookie.name c))
- (and
- (= (Cookie.domain existing) (Cookie.domain c))
- (= (Cookie.path existing) (Cookie.path c))))
- acc
- (Array.push-back acc @existing)))
- (the (Array Cookie) [])
+ (let [ec (JarCookie.cookie existing)]
+ (if (and (= (Cookie.name ec) (Cookie.name c))
+ (and (= (Cookie.domain ec) (Cookie.domain c))
+ (= (Cookie.path ec) (Cookie.path c))))
+ acc
+ (Array.push-back acc @existing))))
+ (the (Array JarCookie) [])
(cookies jar))
- new-arr (Array.push-back filtered @c)]
+ new-arr (Array.push-back filtered @e)]
(set-cookies! jar new-arr)))
- (doc store-response! "stores cookies from a response. The URL provides the
-default domain for cookies without a Domain attribute.")
+ (doc store! "stores a cookie as a domain cookie, replacing any with the same
+name, domain, and path. The cookie is trusted as given: there is no request
+origin to check it against, so prefer `store-response!` for anything that came
+off the wire.")
+ (defn store! [jar c] (store-entry! jar &(JarCookie.init @c false)))
+
+ (doc store-response! "stores cookies from a response, applying RFC 6265 §5.3.
+
+A cookie with no `Domain` attribute becomes host-only: it is replayed to the
+URL’s host and to no other. A `Domain` attribute the URL’s host does not
+domain-match is rejected outright, and so is one with no embedded dot — the
+latter is the pre-public-suffix-list approximation of RFC 6265 §5.3 step 5,
+and it lets through registrable multi-label suffixes such as `co.uk`.")
(defn store-response! [jar resp url]
(match (URI.parse url)
(Result.Error _) ()
(Result.Success uri)
- (let [host (Maybe.from @(URI.host &uri) @"")]
+ (let [host (canonical-host &(Maybe.from @(URI.host &uri) @""))]
(for [i 0 (Array.length (Response.cookies resp))]
- (let [raw @(Array.unsafe-nth (Response.cookies resp) i)
- c (if (Maybe.nothing? (Cookie.domain &raw))
- (Cookie.set-domain raw (Maybe.Just @&host))
- raw)]
- (store! jar &c))))))
+ (let [raw (Array.unsafe-nth (Response.cookies resp) i)]
+ (match @(Cookie.domain raw)
+ (Maybe.Nothing)
+ (store-entry! jar
+ &(JarCookie.init
+ (Cookie.set-domain @raw (Maybe.Just @&host))
+ true))
+ (Maybe.Just d)
+ (when (acceptable-domain? &d &host)
+ (store-entry! jar &(JarCookie.init @raw false)))))))))
(doc matching "returns cookies matching the given URL by domain, path,
-security, and expiry.")
+security, and expiry, longest path first (RFC 6265 §5.4 step 2). Cookies of
+equal path length keep their insertion order; the creation time §5.4 asks for
+as the second sort key is not recorded.
+
+A cookie stored without a `Domain` attribute is host-only and matches its
+origin host alone; one stored with a `Domain` attribute also matches that
+domain’s subdomains, unless the request host is an IP literal.")
(defn matching [jar url]
(match (URI.parse url)
(Result.Error _) []
(Result.Success uri)
- (let [host (Maybe.from @(URI.host &uri) @"")
+ (let [host (canonical-host &(Maybe.from @(URI.host &uri) @""))
path (let [p (Maybe.from @(URI.path &uri) @"")]
(if (byte-starts-with? &p "/") p (fmt "/%s" &p)))
scheme (Maybe.from @(URI.scheme &uri) @"http")
is-secure (= &scheme "https")]
- (Array.reduce
- &(fn [acc c]
- (let [d (Maybe.from @(Cookie.domain c) @"")]
- (cond
- (Cookie.expired? c) acc
- (and @(Cookie.secure c) (not is-secure)) acc
- (Maybe.nothing? (Cookie.domain c)) acc
- (not (domain-matches? &d &host)) acc
- (not (path-matches? (Cookie.path c) &path)) acc
- (Array.push-back acc @c))))
- (the (Array Cookie) [])
- (cookies jar)))))
+ (longest-path-first
+ &(Array.reduce
+ &(fn [acc e]
+ (let [c (JarCookie.cookie e)]
+ (cond
+ (Cookie.expired? c) acc
+ (and @(Cookie.secure c) (not is-secure)) acc
+ (Maybe.nothing? (Cookie.domain c)) acc
+ (not (host-matches? e &host)) acc
+ (not (path-matches? (Cookie.path c) &path)) acc
+ (Array.push-back acc @c))))
+ (the (Array Cookie) [])
+ (cookies jar))))))
(doc cookie-header "builds a Cookie header value for the URL, or Nothing
-if no cookies match.")
+if no cookies match. Cookies are serialized longest path first.")
(defn cookie-header [jar url]
(let [matched (matching jar url)]
(if (Array.empty? &matched)
@@ -121,4 +200,4 @@ headers map. Returns headers unchanged if no cookies match.")
(defn size [jar] (Array.length (cookies jar)))
(doc clear! "removes all cookies.")
- (defn clear! [jar] (set-cookies! jar (the (Array Cookie) []))))
+ (defn clear! [jar] (set-cookies! jar (the (Array JarCookie) []))))
diff --git a/test/cookie-jar.carp b/test/cookie-jar.carp
index 78658d8..a7459f3 100644
--- a/test/cookie-jar.carp
+++ b/test/cookie-jar.carp
@@ -14,6 +14,27 @@
(SameSite.Lax)))
(Array.length &(CookieJar.matching &jar "http://example.com/p"))))
+(defn jar-from [url domain]
+ (let-do [jar (CookieJar.create)
+ resp (Response.init 200
+ @"OK"
+ @"HTTP/1.1"
+ [(Cookie.init @"sid"
+ @"abc"
+ @"/"
+ (Maybe.Nothing)
+ domain
+ false
+ false
+ (SameSite.Lax))]
+ (the (Map String (Array String)) {})
+ @"")]
+ (CookieJar.store-response! &jar &resp url)
+ jar))
+
+(defn sent-to [url domain target]
+ (Array.length &(CookieJar.matching &(jar-from url domain) target)))
+
(defn jar-with-path [path]
(let-do [jar (CookieJar.create)]
(CookieJar.store! &jar
@@ -27,6 +48,35 @@
(SameSite.Lax)))
(Array.length &(CookieJar.matching &jar "http://example.com/p"))))
+(defn redirect-jar []
+ (let-do [jar (CookieJar.create)
+ resp (Response.init 200
+ @"OK"
+ @"HTTP/1.1"
+ [(Cookie.init @"sid"
+ @"abc"
+ @"/"
+ (Maybe.Nothing)
+ (Maybe.Nothing)
+ false
+ false
+ (SameSite.Lax))]
+ (the (Map String (Array String)) {})
+ @"")]
+ (CookieJar.store-response! &jar &resp "http://a.example.com/one")
+ (CookieJar.store-response! &jar &resp "http://b.example.com/two")
+ jar))
+
+(defn at-path [name value path]
+ (Cookie.init @name
+ @value
+ @path
+ (Maybe.Nothing)
+ (Maybe.Just @"example.com")
+ false
+ false
+ (SameSite.Lax)))
+
(defn cookie-jar-tests [state]
(let-do [test @state]
(set! test
@@ -451,44 +501,25 @@
(set! test
(assert-equal &test
1
- (let-do [jar (CookieJar.create)
- resp (Response.init 200
- @"OK"
- @"HTTP/1.1"
- [(Cookie.init @"sid"
- @"abc"
- @"/"
- (Maybe.Nothing)
- (Maybe.Just @"other.com")
- false
- false
- (SameSite.Lax))]
- (the (Map String (Array String)) {})
- @"")]
- (CookieJar.store-response! &jar &resp "https://example.com/login")
- (Array.length &(CookieJar.matching &jar "https://other.com/")))
- "store-response! preserves explicit Domain attribute"))
+ (sent-to "https://sub.example.com/login"
+ (Maybe.Just @"example.com")
+ "https://example.com/")
+ "store-response! keeps a Domain the origin domain-matches"))
(set! test
(assert-equal &test
0
- (let-do [jar (CookieJar.create)
- resp (Response.init 200
- @"OK"
- @"HTTP/1.1"
- [(Cookie.init @"sid"
- @"abc"
- @"/"
- (Maybe.Nothing)
- (Maybe.Just @"other.com")
- false
- false
- (SameSite.Lax))]
- (the (Map String (Array String)) {})
- @"")]
- (CookieJar.store-response! &jar &resp "https://example.com/login")
- (Array.length &(CookieJar.matching &jar "https://example.com/")))
- "explicit Domain cookie does not match request host"))
+ (CookieJar.size
+ &(jar-from "https://evil.com/" (Maybe.Just @"example.com")))
+ "a Domain the origin does not domain-match is rejected outright"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (sent-to "https://evil.com/"
+ (Maybe.Just @"example.com")
+ "https://example.com/")
+ "a rejected cross-origin Domain is never replayed"))
(set! test
(assert-equal &test
@@ -553,4 +584,120 @@
(jar-with-path &(String.concat &[@"/" (continuation 3) @"/"]))
"a directory Path that is not valid UTF-8 does not match"))
+ (set! test
+ (assert-equal &test
+ 0
+ (sent-to "https://example.com/login"
+ (Maybe.Nothing)
+ "https://sub.example.com/")
+ "a cookie stored with no Domain is host-only"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "https://example.com/login"
+ (Maybe.Just @"example.com")
+ "https://sub.example.com/")
+ "an explicit Domain equal to the host still reaches subdomains"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (CookieJar.size
+ &(jar-from "https://example.com/" (Maybe.Just @"com")))
+ "a single-label Domain is rejected"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (CookieJar.size
+ &(jar-from "https://example.com/" (Maybe.Just @".com")))
+ "a leading dot does not rescue a single-label Domain"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "https://example.com/"
+ (Maybe.Just @".example.com")
+ "https://example.com/")
+ "a leading dot in the Domain attribute is ignored"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "https://example.com/"
+ (Maybe.Just @".example.com")
+ "https://sub.example.com/")
+ "a leading-dot Domain reaches subdomains"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "http://EXAMPLE.com/" (Maybe.Nothing) "http://example.com/")
+ "a host-only cookie ignores the case of the origin host"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "http://example.com/" (Maybe.Nothing) "http://EXAMPLE.COM/")
+ "a host-only cookie ignores the case of the request host"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "http://127.0.0.1/" (Maybe.Nothing) "http://127.0.0.1/")
+ "an IP-literal host gets its own cookie back"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (sent-to "http://127.0.0.1/" (Maybe.Nothing) "http://1.127.0.0.1/")
+ "an IP-literal host matches exactly, not by suffix"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (CookieJar.size
+ &(jar-from "http://127.0.0.1/" (Maybe.Just @"0.0.1")))
+ "an IP-literal origin does not domain-match a suffix Domain"))
+
+ (set! test
+ (assert-equal &test
+ 2
+ (CookieJar.size &(redirect-jar))
+ "a redirect to another origin stores a second host-only cookie"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (Array.length
+ &(CookieJar.matching &(redirect-jar) "http://a.example.com/"))
+ "each hop of a redirect chain gets only the cookie it set"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (Array.length
+ &(CookieJar.matching &(redirect-jar) "http://example.com/"))
+ "a redirect chain's host-only cookies do not reach the apex"))
+
+ (set! test
+ (assert-equal &test
+ &(Maybe.Just @"deep=3; mid=2; root=1")
+ &(let-do [jar (CookieJar.create)]
+ (CookieJar.store! &jar &(at-path "root" "1" "/"))
+ (CookieJar.store! &jar &(at-path "deep" "3" "/a/b/c"))
+ (CookieJar.store! &jar &(at-path "mid" "2" "/a"))
+ (CookieJar.cookie-header &jar "https://example.com/a/b/c/d"))
+ "cookie-header serializes longer paths first"))
+
+ (set! test
+ (assert-equal &test
+ &(Maybe.Just @"y=2; x=1")
+ &(let-do [jar (CookieJar.create)]
+ (CookieJar.store! &jar &(at-path "y" "2" "/ab"))
+ (CookieJar.store! &jar &(at-path "x" "1" "/ab"))
+ (CookieJar.cookie-header &jar "https://example.com/ab/z"))
+ "cookies of equal path length keep their insertion order"))
+
test))
From e39af860fb0b04c6d687a0d5989910f7a4d380d8 Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Sun, 23 Aug 2026 23:23:39 +0200
Subject: [PATCH 2/2] Keep a Domain identical to a single-label host, host-only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
docs/Client.html | 5 +
docs/Connection.html | 5 +
docs/CookieJar.html | 13 +-
docs/JarCookie.html | 321 ++++++++++++++++++++++++++++++++++++
docs/Multipart.html | 5 +
docs/http-client_index.html | 5 +
docs/index.html | 5 +
gendocs.carp | 2 +-
src/cookie-jar.carp | 43 +++--
test/cookie-jar.carp | 23 +++
10 files changed, 408 insertions(+), 19 deletions(-)
create mode 100644 docs/JarCookie.html
diff --git a/docs/Client.html b/docs/Client.html
index 2674529..eb8ba00 100644
--- a/docs/Client.html
+++ b/docs/Client.html
@@ -32,6 +32,11 @@
CookieJar
+
+
+ JarCookie
+
+
Multipart
diff --git a/docs/Connection.html b/docs/Connection.html
index 2d75e74..29cd8eb 100644
--- a/docs/Connection.html
+++ b/docs/Connection.html
@@ -32,6 +32,11 @@
CookieJar
+
+
+ JarCookie
+
+
Multipart
diff --git a/docs/CookieJar.html b/docs/CookieJar.html
index 0fdb74d..54e9b95 100644
--- a/docs/CookieJar.html
+++ b/docs/CookieJar.html
@@ -32,6 +32,11 @@
CookieJar
+
+
+ JarCookie
+
+
Multipart
@@ -369,9 +374,11 @@
stores cookies from a response, applying RFC 6265 §5.3.
A cookie with no Domain attribute becomes host-only: it is replayed to the
URL’s host and to no other. A Domain attribute the URL’s host does not
-domain-match is rejected outright, and so is one with no embedded dot — the
-latter is the pre-public-suffix-list approximation of RFC 6265 §5.3 step 5,
-and it lets through registrable multi-label suffixes such as co.uk.
+domain-match is rejected outright. So is one with no embedded dot — the
+pre-public-suffix-list approximation of §5.3 step 5, which lets through
+registrable multi-label suffixes such as co.uk — unless it is identical to
+the host, in which case the cookie is kept host-only, so Domain=localhost
+from http://localhost/ still works.
diff --git a/docs/JarCookie.html b/docs/JarCookie.html
new file mode 100644
index 0000000..4856954
--- /dev/null
+++ b/docs/JarCookie.html
@@ -0,0 +1,321 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ http-client
+
+
+
+
+
+ JarCookie
+
+
+
is a stored cookie together with RFC 6265 §5.3’s
+host-only-flag, which is set when the cookie arrived without a Domain
+attribute and may therefore only go back to the host that set it.
+
+
+
+
+
+ cookie
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a)] (Ref Cookie a))
+
+
+
+
+
+
gets the cookie property of a JarCookie.
+
+
+
+
+
+
+ copy
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a)] JarCookie)
+
+
+
+
+
+
copies a JarCookie.
+
+
+
+
+
+
+ delete
+
+
+
+ instantiate
+
+
+ (Fn [JarCookie] ())
+
+
+
+
+
+
deletes a JarCookie. Should usually not be called manually.
+
+
+
+
+
+
+ host-only
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a)] (Ref Bool a))
+
+
+
+
+
+
gets the host-only property of a JarCookie.
+
+
+
+
+
+
+ init
+
+
+
+ instantiate
+
+
+ (Fn [Cookie, Bool] JarCookie)
+
+
+
+
+
+
creates a JarCookie.
+
+
+
+
+
+
+ prn
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a)] String)
+
+
+
+
+
+
converts a JarCookie to a string.
+
+
+
+
+
+
+ set-cookie
+
+
+
+ instantiate
+
+
+ (Fn [JarCookie, Cookie] JarCookie)
+
+
+
+
+
+
sets the cookie property of a JarCookie.
+
+
+
+
+
+
+ set-cookie!
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a), Cookie] ())
+
+
+
+
+
+
sets the cookie property of a JarCookie in place.
+
+
+
+
+
+
+ set-host-only
+
+
+
+ instantiate
+
+
+ (Fn [JarCookie, Bool] JarCookie)
+
+
+
+
+
+
sets the host-only property of a JarCookie.
+
+
+
+
+
+
+ set-host-only!
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a), Bool] ())
+
+
+
+
+
+
sets the host-only property of a JarCookie in place.
+
+
+
+
+
+
+ str
+
+
+
+ instantiate
+
+
+ (Fn [(Ref JarCookie a)] String)
+
+
+
+
+
+
converts a JarCookie to a string.
+
+
+
+
+
+
+ update-cookie
+
+
+
+ instantiate
+
+
+ (Fn [JarCookie, (Ref (Fn [Cookie] Cookie a) b)] JarCookie)
+
+
+
+
+
+
updates the cookie property of a JarCookie using a function f.
+
+
+
+
+
+
+ update-host-only
+
+
+
+ instantiate
+
+
+ (Fn [JarCookie, (Ref (Fn [Bool] Bool a) b)] JarCookie)
+
+
+
+
+
+
updates the host-only property of a JarCookie using a function f.
+
+
+
+
+
+
+
diff --git a/docs/Multipart.html b/docs/Multipart.html
index 6eb2261..5bbde13 100644
--- a/docs/Multipart.html
+++ b/docs/Multipart.html
@@ -32,6 +32,11 @@
CookieJar
+
+
+ JarCookie
+
+
Multipart
diff --git a/docs/http-client_index.html b/docs/http-client_index.html
index 4da4510..ff21d56 100644
--- a/docs/http-client_index.html
+++ b/docs/http-client_index.html
@@ -28,6 +28,11 @@
CookieJar
+
+
+ JarCookie
+
+
Multipart
diff --git a/docs/index.html b/docs/index.html
index 4da4510..ff21d56 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -28,6 +28,11 @@
CookieJar
+
+
+ JarCookie
+
+
Multipart
diff --git a/gendocs.carp b/gendocs.carp
index 55c6daf..786a7ce 100644
--- a/gendocs.carp
+++ b/gendocs.carp
@@ -37,5 +37,5 @@ transparently over plain TCP or TLS-encrypted streams.
Requires OpenSSL for HTTPS support (via the `tls` library).
Plain HTTP works without OpenSSL.")
-(save-docs Client Connection CookieJar Multipart)
+(save-docs Client Connection CookieJar JarCookie Multipart)
(quit)
diff --git a/src/cookie-jar.carp b/src/cookie-jar.carp
index 31c31ca..5e2096a 100644
--- a/src/cookie-jar.carp
+++ b/src/cookie-jar.carp
@@ -54,11 +54,13 @@ and similar functions:
(or (= &norm &h)
(and (not (ip-literal? &h)) (byte-ends-with? &h &(fmt ".%s" &norm))))))
- (hidden acceptable-domain?)
- (private acceptable-domain?)
- (defn acceptable-domain? [attr host]
- (let [norm (strip-leading-dot &(canonical-host attr))]
- (and (String.contains-string? &norm ".") (domain-matches? &norm host))))
+ ; a dot at neither edge, so `.com` and `com.` stay single-label
+ (hidden embedded-dot?)
+ (private embedded-dot?)
+ (defn embedded-dot? [d]
+ (let [n (String.length d)]
+ (and (> n 2)
+ (String.contains-string? &(String.byte-slice d 1 (dec n)) "."))))
(hidden host-matches?)
(private host-matches?)
@@ -112,6 +114,14 @@ and similar functions:
new-arr (Array.push-back filtered @e)]
(set-cookies! jar new-arr)))
+ (hidden store-host-only!)
+ (private store-host-only!)
+ (defn store-host-only! [jar c host]
+ (store-entry! jar
+ &(JarCookie.init
+ (Cookie.set-domain @c (Maybe.Just @host))
+ true)))
+
(doc store! "stores a cookie as a domain cookie, replacing any with the same
name, domain, and path. The cookie is trusted as given: there is no request
origin to check it against, so prefer `store-response!` for anything that came
@@ -122,9 +132,11 @@ off the wire.")
A cookie with no `Domain` attribute becomes host-only: it is replayed to the
URL’s host and to no other. A `Domain` attribute the URL’s host does not
-domain-match is rejected outright, and so is one with no embedded dot — the
-latter is the pre-public-suffix-list approximation of RFC 6265 §5.3 step 5,
-and it lets through registrable multi-label suffixes such as `co.uk`.")
+domain-match is rejected outright. So is one with no embedded dot — the
+pre-public-suffix-list approximation of §5.3 step 5, which lets through
+registrable multi-label suffixes such as `co.uk` — unless it is identical to
+the host, in which case the cookie is kept host-only, so `Domain=localhost`
+from `http://localhost/` still works.")
(defn store-response! [jar resp url]
(match (URI.parse url)
(Result.Error _) ()
@@ -133,14 +145,15 @@ and it lets through registrable multi-label suffixes such as `co.uk`.")
(for [i 0 (Array.length (Response.cookies resp))]
(let [raw (Array.unsafe-nth (Response.cookies resp) i)]
(match @(Cookie.domain raw)
- (Maybe.Nothing)
- (store-entry! jar
- &(JarCookie.init
- (Cookie.set-domain @raw (Maybe.Just @&host))
- true))
+ (Maybe.Nothing) (store-host-only! jar raw &host)
(Maybe.Just d)
- (when (acceptable-domain? &d &host)
- (store-entry! jar &(JarCookie.init @raw false)))))))))
+ (let [norm (strip-leading-dot &(canonical-host &d))]
+ (cond
+ (not (embedded-dot? &norm))
+ (when (= &norm &host) (store-host-only! jar raw &host))
+ (domain-matches? &norm &host)
+ (store-entry! jar &(JarCookie.init @raw false))
+ ()))))))))
(doc matching "returns cookies matching the given URL by domain, path,
security, and expiry, longest path first (RFC 6265 §5.4 step 2). Cookies of
diff --git a/test/cookie-jar.carp b/test/cookie-jar.carp
index a7459f3..7c1a167 100644
--- a/test/cookie-jar.carp
+++ b/test/cookie-jar.carp
@@ -614,6 +614,29 @@
&(jar-from "https://example.com/" (Maybe.Just @".com")))
"a leading dot does not rescue a single-label Domain"))
+ (set! test
+ (assert-equal &test
+ 0
+ (CookieJar.size
+ &(jar-from "https://example.com/" (Maybe.Just @"..com")))
+ "a doubled leading dot does not rescue a single-label Domain"))
+
+ (set! test
+ (assert-equal &test
+ 1
+ (sent-to "http://localhost/"
+ (Maybe.Just @"localhost")
+ "http://localhost/")
+ "a single-label Domain identical to the host is kept"))
+
+ (set! test
+ (assert-equal &test
+ 0
+ (sent-to "http://localhost/"
+ (Maybe.Just @"localhost")
+ "http://sub.localhost/")
+ "a single-label Domain identical to the host is host-only"))
+
(set! test
(assert-equal &test
1