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 @@

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 @@ + + + + + + + + + +
    + +
    +

    + 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.

    + +
    +
    + + + +
    + 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.

    + +

    +
    +
    + + + +
    + 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.

    + +

    +
    +
    + + + +
    + 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