OCaml HTTP cookie handling library with support for Eio-based storage jars
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

json: rename mem -> member / finish -> seal across the codec + value API

Object combinators: [Object.mem] -> [Object.member], [Object.opt_mem]
-> [Object.opt_member], [Object.case_mem] -> [Object.case_member]. The
sibling submodules [Object.Mem] / [Object.Mems] become
[Object.Member] / [Object.Members]. RFC 8259 §4 calls these
"name/value pairs, referred to as the members", so mirror the spec
name rather than the shortened [mem].

[Object.finish] -> [Object.seal]. "Seal" reads as "close the map, no
more members added", which is what the operation does.

Value constructors/queries: [Value.mem] (function) -> [Value.member];
[Value.mem_find] -> [Value.member_key]; [Value.mem_names] ->
[Value.member_names]; [Value.mem_keys] -> [Value.member_keys].
[type mem = ...] -> [type member = ...]; [type object'] still points
at [member list].

Downstream (~80 files across slack, sbom, stripe, sigstore, requests,
claude, irmin, freebox) updated via perl-pie. dune build clean,
dune test ocaml-json clean.

+8410
+17
.gitignore
··· 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Third-party sources (fetch locally with opam source) 7 + third_party/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/
+1
.ocamlformat
··· 1 + version = 0.29.0
+53
.tangled/workflows/build.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: ["main"] 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - shell 10 + - stdenv 11 + - findutils 12 + - binutils 13 + - libunwind 14 + - ncurses 15 + - opam 16 + - git 17 + - gawk 18 + - gnupatch 19 + - gnum4 20 + - gnumake 21 + - gnutar 22 + - gnused 23 + - gnugrep 24 + - diffutils 25 + - gzip 26 + - bzip2 27 + - gcc 28 + - ocaml 29 + - pkg-config 30 + 31 + steps: 32 + - name: opam 33 + command: | 34 + opam init --disable-sandboxing -a -y 35 + - name: repo 36 + command: | 37 + opam repo add aoah https://tangled.org/anil.recoil.org/aoah-opam-repo.git 38 + - name: switch 39 + command: | 40 + opam install . --confirm-level=unsafe-yes --deps-only 41 + - name: build 42 + command: | 43 + opam exec -- dune build -p cookie 44 + - name: switch-test 45 + command: | 46 + opam install . --confirm-level=unsafe-yes --deps-only --with-test 47 + - name: test 48 + command: | 49 + opam exec -- dune runtest --verbose 50 + - name: doc 51 + command: | 52 + opam install -y odoc 53 + opam exec -- dune build @doc
+16
LICENSE.md
··· 1 + 2 + ISC License 3 + 4 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org> 5 + 6 + Permission to use, copy, modify, and distribute this software for any 7 + purpose with or without fee is hereby granted, provided that the above 8 + copyright notice and this permission notice appear in all copies. 9 + 10 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+128
README.md
··· 1 + # Cookie - HTTP Cookie Management for OCaml 2 + 3 + Cookie parsing, validation, and jar management following 4 + [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265). 5 + 6 + ## Packages 7 + 8 + - **`cookie`** (module `Cookie`): Cookie parsing, creation, and serialization. 9 + Handles Set-Cookie and Cookie headers with support for all attributes 10 + including SameSite, Partitioned (CHIPS), and HttpOnly. 11 + - **`cookie.jar`** (module `Cookie_jar`): Cookie jar storage with domain/path 12 + matching, delta tracking, and persistence in Mozilla format. 13 + 14 + ## Cookie Attributes 15 + 16 + - **Domain**: Tail matching for hostnames, exact match for IPs 17 + - **Path**: Prefix matching with "/" separator 18 + - **Secure**: HTTPS-only transmission 19 + - **HttpOnly**: No JavaScript access 20 + - **Expires / Max-Age**: Cookie lifetime (session cookies when omitted) 21 + - **SameSite**: Cross-site request behavior (`Strict`, `Lax`, `None`) 22 + - **Partitioned**: CHIPS partitioned storage (requires Secure) 23 + 24 + ## Usage 25 + 26 + ### Parsing Headers 27 + 28 + ```ocaml 29 + (* Parse a Set-Cookie response header *) 30 + let cookie = 31 + Cookie.of_set_cookie_header 32 + ~now:(fun () -> Ptime_clock.now ()) 33 + ~domain:"example.com" 34 + ~path:"/" 35 + "session=abc123; Secure; HttpOnly; SameSite=Strict" 36 + 37 + (* Parse a Cookie request header *) 38 + let cookies = 39 + Cookie.of_cookie_header 40 + ~now:(fun () -> Ptime_clock.now ()) 41 + ~domain:"example.com" 42 + ~path:"/" 43 + "session=abc123; theme=dark" 44 + ``` 45 + 46 + ### Serializing Headers 47 + 48 + ```ocaml 49 + (* Generate a Cookie request header from a list of cookies *) 50 + let header = Cookie.cookie_header cookies 51 + (* "session=abc123; theme=dark" *) 52 + 53 + (* Generate a Set-Cookie response header *) 54 + let header = Cookie.set_cookie_header cookie 55 + ``` 56 + 57 + ### Creating Cookies 58 + 59 + ```ocaml 60 + let cookie = 61 + Cookie.v 62 + ~domain:"example.com" 63 + ~path:"/" 64 + ~name:"session" 65 + ~value:"abc123" 66 + ~secure:true 67 + ~http_only:true 68 + ~same_site:`Strict 69 + ~creation_time:(Ptime_clock.now ()) 70 + ~last_access:(Ptime_clock.now ()) 71 + () 72 + ``` 73 + 74 + ### Cookie Jar 75 + 76 + ```ocaml 77 + (* Create an empty cookie jar *) 78 + let jar = Cookie_jar.v () in 79 + 80 + (* Add a cookie *) 81 + Cookie_jar.add_cookie jar cookie; 82 + 83 + (* Get cookies matching a request URL *) 84 + let cookies = 85 + Cookie_jar.cookies jar 86 + ~clock 87 + ~domain:"example.com" 88 + ~path:"/api" 89 + ~is_secure:true 90 + in 91 + 92 + (* Generate Cookie header *) 93 + let header = Cookie.cookie_header cookies 94 + ``` 95 + 96 + ### Delta Tracking 97 + 98 + ```ocaml 99 + (* Track which cookies were added since jar creation *) 100 + let new_cookies = Cookie_jar.delta jar in 101 + List.iter (fun c -> print_endline (Cookie.set_cookie_header c)) new_cookies 102 + ``` 103 + 104 + ### Persistence 105 + 106 + Cookies are persisted in Mozilla format: 107 + 108 + ```ocaml 109 + (* Save to file *) 110 + Cookie_jar.save path jar; 111 + 112 + (* Load from file (needs clock for access times) *) 113 + let jar = Cookie_jar.load ~clock path 114 + ``` 115 + 116 + ## Validation 117 + 118 + The `Cookie.Validate` module enforces RFC 6265 server requirements: 119 + 120 + ```ocaml 121 + Cookie.Validate.cookie_name "session" (* Ok "session" *) 122 + Cookie.Validate.cookie_value "abc;123" (* Error "..." *) 123 + Cookie.Validate.domain_value "example.com" (* Ok "example.com" *) 124 + ``` 125 + 126 + ## Licence 127 + 128 + ISC
+173
RFC-TODO.md
··· 1 + # RFC 6265 Compliance TODO 2 + 3 + This document tracks deviations from [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) (HTTP State Management Mechanism) and missing features in ocaml-cookie. 4 + 5 + ## High Priority 6 + 7 + ### 1. Public Suffix Validation (Section 5.3, Step 5) 8 + 9 + **Status:** ✅ IMPLEMENTED 10 + 11 + The RFC requires rejecting cookies with domains that are "public suffixes" (e.g., `.com`, `.co.uk`) to prevent domain-wide cookie attacks. 12 + 13 + **Implementation:** 14 + - Uses the `publicsuffix` library which embeds the Mozilla Public Suffix List at build time 15 + - Validates Domain attribute in `of_set_cookie_header` before creating the cookie 16 + - Rejects cookies where Domain is a public suffix (e.g., `.com`, `.co.uk`, `.github.io`) 17 + - Allows cookies where the request host exactly matches the public suffix domain 18 + - IP addresses bypass PSL validation (per RFC 6265 Section 5.1.3) 19 + - Cookies without Domain attribute (host-only) are always allowed 20 + 21 + **Security impact:** Prevents attackers from setting domain-wide cookies that would affect all sites under a TLD. 22 + 23 + --- 24 + 25 + ## Medium Priority 26 + 27 + ### 2. IP Address Domain Matching (Section 5.1.3) 28 + 29 + **Status:** ✅ IMPLEMENTED 30 + 31 + The RFC specifies that domain suffix matching should only apply to host names, not IP addresses. 32 + 33 + **Implementation:** 34 + - Uses the `ipaddr` library to detect IPv4 and IPv6 addresses 35 + - IP addresses require exact match only (no suffix matching) 36 + - Hostnames continue to support subdomain matching when `host_only = false` 37 + 38 + --- 39 + 40 + ### 3. Expires Header Date Format (Section 4.1.1) 41 + 42 + **Status:** Wrong format 43 + 44 + **Current behavior:** Outputs RFC3339 format (`2021-06-09T10:18:14+00:00`) 45 + 46 + **RFC requirement:** Use `rfc1123-date` format (`Wed, 09 Jun 2021 10:18:14 GMT`) 47 + 48 + **Location:** `cookie.ml:447-448` 49 + 50 + **Fix:** Implement RFC1123 date formatting for Set-Cookie header output. 51 + 52 + --- 53 + 54 + ### 4. Cookie Ordering in Header (Section 5.4, Step 2) 55 + 56 + **Status:** ✅ IMPLEMENTED 57 + 58 + When generating Cookie headers, cookies are sorted: 59 + 1. Cookies with longer paths listed first 60 + 2. Among equal-length paths, earlier creation-times listed first 61 + 62 + **Implementation:** `cookies` function in `cookie_jar.ml` uses `compare_cookie_order` to sort cookies before returning them. 63 + 64 + --- 65 + 66 + ### 5. Creation Time Preservation (Section 5.3, Step 11.3) 67 + 68 + **Status:** ✅ IMPLEMENTED 69 + 70 + When replacing an existing cookie (same name/domain/path), the creation-time of the old cookie is preserved. 71 + 72 + **Implementation:** `add_cookie` and `add_original` functions in `cookie_jar.ml` use `preserve_creation_time` to retain the original creation time when updating an existing cookie. 73 + 74 + --- 75 + 76 + ### 6. Default Path Computation (Section 5.1.4) 77 + 78 + **Status:** Not implemented (caller responsibility) 79 + 80 + The RFC specifies an algorithm for computing default path when Path attribute is absent: 81 + 1. If uri-path is empty or doesn't start with `/`, return `/` 82 + 2. If uri-path contains only one `/`, return `/` 83 + 3. Return characters up to (but not including) the rightmost `/` 84 + 85 + **Suggestion:** Add `default_path : string -> string` helper function. 86 + 87 + --- 88 + 89 + ## Low Priority 90 + 91 + ### 7. Storage Limits (Section 6.1) 92 + 93 + **Status:** Not implemented 94 + 95 + RFC recommends minimum capabilities: 96 + - At least 4096 bytes per cookie 97 + - At least 50 cookies per domain 98 + - At least 3000 cookies total 99 + 100 + **Suggestion:** Add configurable limits with RFC-recommended defaults. 101 + 102 + --- 103 + 104 + ### 8. Excess Cookie Eviction (Section 5.3) 105 + 106 + **Status:** Not implemented 107 + 108 + When storage limits are exceeded, evict in priority order: 109 + 1. Expired cookies 110 + 2. Cookies sharing domain with many others 111 + 3. All cookies 112 + 113 + Tiebreaker: earliest `last-access-time` first (LRU). 114 + 115 + --- 116 + 117 + ### 9. Two-Digit Year Parsing (Section 5.1.1) 118 + 119 + **Status:** Minor deviation 120 + 121 + **RFC specification:** 122 + - Years 70-99 → add 1900 123 + - Years 0-69 → add 2000 124 + 125 + **Current code** (`cookie.ml:128-130`): 126 + ```ocaml 127 + if year >= 0 && year <= 68 then year + 2000 128 + else if year >= 69 && year <= 99 then year + 1900 129 + ``` 130 + 131 + **Issue:** Year 69 is treated as 1969, but RFC says 70-99 get 1900, implying 69 should get 2000. 132 + 133 + --- 134 + 135 + ## Compliant Features 136 + 137 + The following RFC requirements are correctly implemented: 138 + 139 + - [x] Case-insensitive attribute name matching (Section 5.2) 140 + - [x] Leading dot removal from Domain attribute (Section 5.2.3) 141 + - [x] Max-Age takes precedence over Expires (Section 5.3, Step 3) 142 + - [x] Secure flag handling (Section 5.2.5) 143 + - [x] HttpOnly flag handling (Section 5.2.6) 144 + - [x] Cookie date parsing with multiple format support (Section 5.1.1) 145 + - [x] Session vs persistent cookie distinction (Section 5.3) 146 + - [x] Last-access-time updates on retrieval (Section 5.4, Step 3) 147 + - [x] Host-only flag for domain matching (Section 5.3, Step 6) 148 + - [x] Path matching algorithm (Section 5.1.4) 149 + - [x] IP address domain matching - exact match only (Section 5.1.3) 150 + - [x] Cookie ordering in headers - longer paths first, then by creation time (Section 5.4, Step 2) 151 + - [x] Creation time preservation when replacing cookies (Section 5.3, Step 11.3) 152 + - [x] Public suffix validation - rejects cookies for TLDs like .com (Section 5.3, Step 5) 153 + 154 + --- 155 + 156 + ## Extensions Beyond RFC 6265 157 + 158 + These features are implemented but not part of RFC 6265: 159 + 160 + | Feature | Specification | 161 + |---------|---------------| 162 + | SameSite | RFC 6265bis (draft) | 163 + | Partitioned | CHIPS proposal | 164 + | Mozilla format | De facto standard | 165 + 166 + --- 167 + 168 + ## References 169 + 170 + - [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) - HTTP State Management Mechanism 171 + - [RFC 6265bis](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis) - Updated cookie spec (draft) 172 + - [Public Suffix List](https://publicsuffix.org/) - Mozilla's public suffix database 173 + - [CHIPS](https://developer.chrome.com/docs/privacy-sandbox/chips/) - Cookies Having Independent Partitioned State
+17
bin/cookiecat.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let () = 7 + Eio_main.run @@ fun env -> 8 + let args = Sys.argv in 9 + if Array.length args < 2 then ( 10 + Fmt.epr "Usage: %s <cookies.txt>\n" args.(0); 11 + exit 1); 12 + let file_path = args.(1) in 13 + let fs = Eio.Stdenv.fs env in 14 + let clock = Eio.Stdenv.clock env in 15 + let path = Eio.Path.(fs / file_path) in 16 + let jar = Cookie_jar.load ~clock path in 17 + Fmt.pr "%a@." Cookie_jar.pp jar
+4
bin/dune
··· 1 + (executable 2 + (name cookiecat) 3 + (public_name cookiecat) 4 + (libraries cookie cookie.jar eio_main ptime))
+47
cookie.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Cookie parsing and management library" 4 + description: 5 + "Cookie provides cookie parsing and serialization for OCaml applications. It handles parsing Set-Cookie and Cookie headers with full support for all cookie attributes." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: [ 8 + "Anil Madhavapeddy <anil@recoil.org>" 9 + "Thomas Gazagnaire <thomas@gazagnaire.org>" 10 + ] 11 + license: "ISC" 12 + tags: ["org:blacksun" "network" "http"] 13 + homepage: "https://tangled.org/gazagnaire.org/ocaml-cookie" 14 + doc: "https://tangled.org/gazagnaire.org/ocaml-cookie" 15 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-cookie/issues" 16 + depends: [ 17 + "dune" {>= "3.21"} 18 + "ocaml" {>= "5.2.0"} 19 + "eio" {>= "1.0.0"} 20 + "fmt" {>= "0.9.0"} 21 + "logs" {>= "0.9.0"} 22 + "ptime" {>= "1.1.0"} 23 + "ipaddr" {>= "5.6.0"} 24 + "domain-name" {>= "0.4.0"} 25 + "publicsuffix" 26 + "eio_main" 27 + "alcotest" {with-test} 28 + "odoc" {with-doc} 29 + ] 30 + build: [ 31 + ["dune" "subst"] {dev} 32 + [ 33 + "dune" 34 + "build" 35 + "-p" 36 + name 37 + "-j" 38 + jobs 39 + "@install" 40 + "@runtest" {with-test} 41 + "@doc" {with-doc} 42 + ] 43 + ] 44 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-cookie" 45 + x-maintenance-intent: ["(latest)"] 46 + x-quality-fuzz: "2026-04-15" 47 + x-quality-test: "2026-04-15"
+2
cookie.opam.template
··· 1 + x-quality-fuzz: "2026-04-15" 2 + x-quality-test: "2026-04-15"
+3
dune
··· 1 + (env 2 + (dev 3 + (flags :standard %{dune-warnings})))
+32
dune-project
··· 1 + (lang dune 3.21) 2 + 3 + (name cookie) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + (source (tangled gazagnaire.org/ocaml-cookie)) 9 + (authors "Anil Madhavapeddy <anil@recoil.org>" "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + (homepage "https://tangled.org/gazagnaire.org/ocaml-cookie") 11 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 12 + (bug_reports "https://tangled.org/gazagnaire.org/ocaml-cookie/issues") 13 + (documentation "https://tangled.org/gazagnaire.org/ocaml-cookie") 14 + (maintenance_intent "(latest)") 15 + 16 + (package 17 + (name cookie) 18 + (synopsis "Cookie parsing and management library") 19 + (tags (org:blacksun network http)) 20 + (description "Cookie provides cookie parsing and serialization for OCaml applications. It handles parsing Set-Cookie and Cookie headers with full support for all cookie attributes.") 21 + (depends 22 + (ocaml (>= 5.2.0)) 23 + (eio (>= 1.0.0)) 24 + (fmt (>= 0.9.0)) 25 + (logs (>= 0.9.0)) 26 + (ptime (>= 1.1.0)) 27 + (ipaddr (>= 5.6.0)) 28 + (domain-name (>= 0.4.0)) 29 + publicsuffix 30 + eio_main 31 + (alcotest :with-test) 32 + (odoc :with-doc)))
+26
fuzz/dune
··· 1 + ; Crowbar fuzz testing for cookie 2 + ; 3 + ; Quick check: dune build @fuzz 4 + ; With AFL: crow start --cpus=4 5 + 6 + (executable 7 + (name fuzz) 8 + (libraries cookie alcobar fmt)) 9 + 10 + (rule 11 + (alias runtest) 12 + (enabled_if 13 + (<> %{profile} afl)) 14 + (deps fuzz.exe) 15 + (action 16 + (run %{exe:fuzz.exe}))) 17 + 18 + (rule 19 + (alias fuzz) 20 + (enabled_if 21 + (= %{profile} afl)) 22 + (deps fuzz.exe) 23 + (action 24 + (progn 25 + (run %{exe:fuzz.exe} --gen-corpus corpus) 26 + (run afl-fuzz -V 60 -i corpus -o _fuzz -- %{exe:fuzz.exe} @@))))
+1
fuzz/fuzz.ml
··· 1 + let () = Alcobar.run "cookie" [ Fuzz_cookie.suite ]
+84
fuzz/fuzz_cookie.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* Alcobar-based fuzz testing for cookie parsing *) 7 + 8 + open Alcobar 9 + 10 + let now () = Ptime.epoch 11 + 12 + (* Test that of_set_cookie_header never crashes *) 13 + let test_parse_setcookie_safe input = 14 + ignore 15 + (Cookie.of_set_cookie_header ~now ~domain:"example.com" ~path:"/" input); 16 + check true 17 + 18 + (* Test that of_cookie_header never crashes *) 19 + let test_parse_cookie_no_crash input = 20 + ignore (Cookie.of_cookie_header ~now ~domain:"example.com" ~path:"/" input); 21 + check true 22 + 23 + (* Test cookie serialization never crashes *) 24 + let test_serialize_setcookie_safe name value = 25 + let now = Ptime.epoch in 26 + let cookie = 27 + Cookie.v ~domain:"example.com" ~path:"/" ~name ~value ~creation_time:now 28 + ~last_access:now () 29 + in 30 + ignore (Cookie.set_cookie_header cookie); 31 + check true 32 + 33 + (* Test cookie value trimming never crashes *) 34 + let test_value_trimmed_no_crash name value = 35 + let now = Ptime.epoch in 36 + let cookie = 37 + Cookie.v ~domain:"example.com" ~path:"/" ~name ~value ~creation_time:now 38 + ~last_access:now () 39 + in 40 + ignore (Cookie.value_trimmed cookie); 41 + check true 42 + 43 + (* Test Set-Cookie header format *) 44 + let test_set_cookie_format name value domain = 45 + let header = 46 + Fmt.str "%s=%s; Domain=%s; Path=/; Secure; HttpOnly" name value domain 47 + in 48 + ignore 49 + (Cookie.of_set_cookie_header ~now ~domain:"example.com" ~path:"/" header); 50 + check true 51 + 52 + (* Test cookie with various attributes *) 53 + let test_cookie_attributes name value max_age = 54 + let header = 55 + Fmt.str "%s=%s; Max-Age=%d; SameSite=Strict" name value 56 + (abs max_age mod 86400) 57 + in 58 + ignore 59 + (Cookie.of_set_cookie_header ~now ~domain:"example.com" ~path:"/" header); 60 + check true 61 + 62 + (* Test multiple cookies in Cookie header *) 63 + let test_multiple_cookies name1 value1 name2 value2 = 64 + let header = Fmt.str "%s=%s; %s=%s" name1 value1 name2 value2 in 65 + ignore (Cookie.of_cookie_header ~now ~domain:"example.com" ~path:"/" header); 66 + check true 67 + 68 + let suite = 69 + ( "cookie", 70 + [ 71 + test_case "of_set_cookie_header no crash" [ bytes ] 72 + test_parse_setcookie_safe; 73 + test_case "of_cookie_header no crash" [ bytes ] test_parse_cookie_no_crash; 74 + test_case "to_set_cookie_header no crash" [ bytes; bytes ] 75 + test_serialize_setcookie_safe; 76 + test_case "value_trimmed no crash" [ bytes; bytes ] 77 + test_value_trimmed_no_crash; 78 + test_case "set_cookie format" [ bytes; bytes; bytes ] 79 + test_set_cookie_format; 80 + test_case "cookie attributes" [ bytes; bytes; int ] test_cookie_attributes; 81 + test_case "multiple cookies" 82 + [ bytes; bytes; bytes; bytes ] 83 + test_multiple_cookies; 84 + ] )
+4
fuzz/fuzz_cookie.mli
··· 1 + (** Fuzz tests for {!Cookie}. *) 2 + 3 + val suite : string * Alcobar.test_case list 4 + (** Test suite. *)
fuzz/input/empty

This is a binary file and will not be displayed.

+1031
lib/core/cookie.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let err_missing_separator name_value = 7 + Error (Fmt.str "Cookie missing '=' separator: %S" name_value) 8 + 9 + let err_duplicate_cookie name = 10 + Error 11 + (Fmt.str 12 + "Duplicate cookie name %S in Cookie header; RFC 6265 Section 4.2.1 \ 13 + forbids duplicate names" 14 + name) 15 + 16 + let src = Logs.Src.create "cookie" ~doc:"Cookie management" 17 + 18 + module Log = (val Logs.src_log src : Logs.LOG) 19 + 20 + (** SameSite attribute for cross-site request control. 21 + 22 + The SameSite attribute is defined in the RFC 6265bis draft and controls 23 + whether cookies are sent with cross-site requests. 24 + 25 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> 26 + RFC 6265bis Section 5.4.7 - The SameSite Attribute *) 27 + module Same_site = struct 28 + type t = [ `Strict | `Lax | `None ] 29 + 30 + let equal = ( = ) 31 + 32 + let pp ppf = function 33 + | `Strict -> Fmt.string ppf "Strict" 34 + | `Lax -> Fmt.string ppf "Lax" 35 + | `None -> Fmt.string ppf "None" 36 + end 37 + 38 + (** Cookie expiration type. 39 + 40 + Per 41 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 42 + Section 5.3}, cookies have either a persistent expiry time or are session 43 + cookies that expire when the user agent session ends. 44 + 45 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 46 + RFC 6265 Section 5.3 - Storage Model *) 47 + module Expiration = struct 48 + type t = [ `Session | `DateTime of Ptime.t ] 49 + 50 + let equal e1 e2 = 51 + match (e1, e2) with 52 + | `Session, `Session -> true 53 + | `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2 54 + | _ -> false 55 + 56 + let pp ppf = function 57 + | `Session -> Fmt.string ppf "Session" 58 + | `DateTime t -> Fmt.pf ppf "DateTime(%a)" Ptime.pp t 59 + end 60 + 61 + type t = { 62 + domain : string; 63 + path : string; 64 + name : string; 65 + value : string; 66 + secure : bool; 67 + http_only : bool; 68 + partitioned : bool; 69 + host_only : bool; 70 + expires : Expiration.t option; 71 + max_age : Ptime.Span.t option; 72 + same_site : Same_site.t option; 73 + creation_time : Ptime.t; 74 + last_access : Ptime.t; 75 + } 76 + (** HTTP Cookie *) 77 + 78 + (** {1 Cookie Accessors} *) 79 + 80 + let domain cookie = cookie.domain 81 + let path cookie = cookie.path 82 + let name cookie = cookie.name 83 + let value cookie = cookie.value 84 + 85 + let value_trimmed cookie = 86 + let v = cookie.value in 87 + let len = String.length v in 88 + if len < 2 then v 89 + else 90 + match (v.[0], v.[len - 1]) with 91 + | '"', '"' -> String.sub v 1 (len - 2) 92 + | _ -> v 93 + 94 + let secure cookie = cookie.secure 95 + let http_only cookie = cookie.http_only 96 + let partitioned cookie = cookie.partitioned 97 + let host_only cookie = cookie.host_only 98 + let expires cookie = cookie.expires 99 + let max_age cookie = cookie.max_age 100 + let same_site cookie = cookie.same_site 101 + let creation_time cookie = cookie.creation_time 102 + let last_access cookie = cookie.last_access 103 + 104 + let v ~domain ~path ~name ~value ?(secure = false) ?(http_only = false) ?expires 105 + ?max_age ?same_site ?(partitioned = false) ?(host_only = false) 106 + ~creation_time ~last_access () = 107 + { 108 + domain; 109 + path; 110 + name; 111 + value; 112 + secure; 113 + http_only; 114 + partitioned; 115 + host_only; 116 + expires; 117 + max_age; 118 + same_site; 119 + creation_time; 120 + last_access; 121 + } 122 + 123 + (** {1 RFC 6265 Validation} 124 + 125 + Validation functions for cookie names, values, and attributes per 126 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 127 + Section 4.1.1}. 128 + 129 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 130 + RFC 6265 Section 4.1.1 - Syntax *) 131 + module Validate = struct 132 + (** Check if a character is a valid RFC 2616 token character. 133 + 134 + Per RFC 6265, cookie-name must be a token as defined in RFC 2616 Section 135 + 2.2: token = 1*<any CHAR except CTLs or separators> separators = "(" | ")" 136 + | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> | "/" | "\[" | "\]" | "?" 137 + | "=" | "\{" | "\}" | SP | HT 138 + 139 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 140 + RFC 6265 Section 4.1.1 *) 141 + let is_token_char = function 142 + | '\x00' .. '\x1F' | '\x7F' -> false (* CTL characters *) 143 + | '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '\\' | '"' | '/' | '[' 144 + | ']' | '?' | '=' | '{' | '}' | ' ' -> 145 + false (* separators - note: HT (0x09) is already covered by CTL range *) 146 + | _ -> true 147 + 148 + (** Validate a cookie name per RFC 6265. 149 + 150 + Cookie names must be valid RFC 2616 tokens: one or more characters 151 + excluding control characters and separators. 152 + 153 + @param name The cookie name to validate 154 + @return [Ok name] if valid, [Error message] with explanation if invalid 155 + 156 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 157 + RFC 6265 Section 4.1.1 *) 158 + let cookie_name name = 159 + let len = String.length name in 160 + if len = 0 then 161 + Error "Cookie name is empty; RFC 6265 requires at least one character" 162 + else 163 + let rec find_invalid i acc = 164 + if i >= len then acc 165 + else 166 + let c = String.unsafe_get name i in 167 + if is_token_char c then find_invalid (i + 1) acc 168 + else find_invalid (i + 1) (c :: acc) 169 + in 170 + match find_invalid 0 [] with 171 + | [] -> Ok name 172 + | invalid_chars -> 173 + let chars_str = 174 + invalid_chars |> List.rev 175 + |> List.map (fun c -> Fmt.str "%C" c) 176 + |> String.concat ", " 177 + in 178 + Error 179 + (Fmt.str 180 + "Cookie name %S contains invalid characters: %s. RFC 6265 \ 181 + requires cookie names to be valid tokens (no control \ 182 + characters, spaces, or separators like ()[]{}=,;:@\\\"/?<>)" 183 + name chars_str) 184 + 185 + (** Check if a character is a valid cookie-octet. 186 + 187 + Per RFC 6265 Section 4.1.1: cookie-octet = %x21 / %x23-2B / %x2D-3A / 188 + %x3C-5B / %x5D-7E (US-ASCII excluding CTLs, whitespace, DQUOTE, comma, 189 + semicolon, backslash) 190 + 191 + In practice, real-world servers (e.g. LinkedIn) send cookie values 192 + containing spaces (e.g. "delete me" for cookie deletion). We accept spaces 193 + to match browser behavior. 194 + 195 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 196 + RFC 6265 Section 4.1.1 *) 197 + let is_cookie_octet = function 198 + | '\x20' -> true (* space - not RFC-compliant but common in practice *) 199 + | '\x21' -> true (* ! *) 200 + | '\x23' .. '\x2B' -> true (* # $ % & ' ( ) * + *) 201 + | '\x2D' .. '\x3A' -> true (* - . / 0-9 : *) 202 + | '\x3C' .. '\x5B' -> true (* < = > ? @ A-Z [ *) 203 + | '\x5D' .. '\x7E' -> true (* ] ^ _ ` a-z { | } ~ *) 204 + | _ -> false 205 + 206 + (** Validate a cookie value per RFC 6265. 207 + 208 + Cookie values must contain only cookie-octets, optionally wrapped in 209 + double quotes. Invalid characters include: control characters, space, 210 + double quote (except as wrapper), comma, semicolon, and backslash. 211 + 212 + @param value The cookie value to validate 213 + @return [Ok value] if valid, [Error message] with explanation if invalid 214 + 215 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 216 + RFC 6265 Section 4.1.1 *) 217 + let cookie_value value = 218 + (* Handle optional DQUOTE wrapper *) 219 + let len = String.length value in 220 + let inner_value, inner_len = 221 + if len >= 2 && value.[0] = '"' && value.[len - 1] = '"' then 222 + (String.sub value 1 (len - 2), len - 2) 223 + else (value, len) 224 + in 225 + let rec find_invalid i acc = 226 + if i >= inner_len then acc 227 + else 228 + let c = String.unsafe_get inner_value i in 229 + if is_cookie_octet c then find_invalid (i + 1) acc 230 + else find_invalid (i + 1) (c :: acc) 231 + in 232 + match find_invalid 0 [] with 233 + | [] -> Ok value 234 + | invalid_chars -> 235 + let chars_str = 236 + invalid_chars |> List.rev 237 + |> List.map (fun c -> 238 + match c with 239 + | '"' -> "double-quote (0x22)" 240 + | ',' -> "comma (0x2C)" 241 + | ';' -> "semicolon (0x3B)" 242 + | '\\' -> "backslash (0x5C)" 243 + | c when Char.code c < 0x20 -> 244 + Fmt.str "control char (0x%02X)" (Char.code c) 245 + | c -> Fmt.str "%C (0x%02X)" c (Char.code c)) 246 + |> String.concat ", " 247 + in 248 + Error 249 + (Fmt.str 250 + "Cookie value %S contains invalid characters: %s. RFC 6265 cookie \ 251 + values may only contain printable ASCII excluding double-quote, \ 252 + comma, semicolon, and backslash" 253 + value chars_str) 254 + 255 + (** Validate a domain attribute value. 256 + 257 + Domain values must be either: 258 + - A valid domain name per RFC 1034 Section 3.5 259 + - A valid IPv4 address 260 + - A valid IPv6 address 261 + 262 + @param domain The domain value to validate (leading dot is stripped first) 263 + @return [Ok domain] if valid, [Error message] with explanation if invalid 264 + 265 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> 266 + RFC 6265 Section 4.1.2.3 267 + @see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> 268 + RFC 1034 Section 3.5 *) 269 + let domain_value domain = 270 + (* Strip leading dot per RFC 6265 Section 5.2.3 *) 271 + let domain = 272 + if String.starts_with ~prefix:"." domain && String.length domain > 1 then 273 + String.sub domain 1 (String.length domain - 1) 274 + else domain 275 + in 276 + if String.length domain = 0 then Error "Domain attribute is empty" 277 + else 278 + (* First check if it's an IP address *) 279 + match Ipaddr.of_string domain with 280 + | Ok _ -> Ok domain (* Valid IP address *) 281 + | Error _ -> ( 282 + (* Not an IP, validate as domain name using domain-name library *) 283 + match Domain_name.of_string domain with 284 + | Ok _ -> Ok domain 285 + | Error (`Msg msg) -> 286 + Error 287 + (Fmt.str 288 + "Domain %S is not a valid domain name: %s. Domain names \ 289 + must follow RFC 1034: labels must start with a letter, \ 290 + contain only letters/digits/hyphens, not end with a \ 291 + hyphen, and be at most 63 characters each" 292 + domain msg)) 293 + 294 + (** Validate a path attribute value. 295 + 296 + Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except control 297 + characters and semicolon. 298 + 299 + @param path The path value to validate 300 + @return [Ok path] if valid, [Error message] with explanation if invalid 301 + 302 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 303 + RFC 6265 Section 4.1.1 *) 304 + let path_value path = 305 + let len = String.length path in 306 + let rec find_invalid i acc = 307 + if i >= len then acc 308 + else 309 + let c = String.unsafe_get path i in 310 + match c with 311 + | '\x00' .. '\x1F' | '\x7F' | ';' -> find_invalid (i + 1) (c :: acc) 312 + | _ -> find_invalid (i + 1) acc 313 + in 314 + match find_invalid 0 [] with 315 + | [] -> Ok path 316 + | invalid_chars -> 317 + let chars_str = 318 + invalid_chars |> List.rev 319 + |> List.map (fun c -> Fmt.str "0x%02X" (Char.code c)) 320 + |> String.concat ", " 321 + in 322 + Error 323 + (Fmt.str 324 + "Path %S contains invalid characters: %s. Paths may not contain \ 325 + control characters or semicolons" 326 + path chars_str) 327 + 328 + (** Validate a Max-Age attribute value. 329 + 330 + Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT. 331 + However, per Section 5.2.2, user agents should treat values <= 0 as 332 + "delete immediately". This function returns [Ok] for any integer since the 333 + parsing code handles negative values by converting to 0. 334 + 335 + @param seconds The Max-Age value in seconds 336 + @return [Ok seconds] always (negative values are handled in parsing) 337 + 338 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 339 + RFC 6265 Section 4.1.1 340 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> 341 + RFC 6265 Section 5.2.2 *) 342 + let max_age seconds = Ok seconds 343 + end 344 + 345 + (** {1 Public Suffix Validation} 346 + 347 + Per 348 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 349 + Section 5.3 Step 5}, cookies with Domain attributes that are public 350 + suffixes must be rejected to prevent domain-wide cookie attacks. 351 + 352 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 353 + RFC 6265 Section 5.3 - Storage Model 354 + @see <https://publicsuffix.org/list/> Public Suffix List *) 355 + 356 + (** Module-level Public Suffix List instance. 357 + 358 + Lazily initialized on first use. The PSL data is compiled into the 359 + publicsuffix library at build time from the Mozilla Public Suffix List. *) 360 + let psl = lazy (Publicsuffix.v ()) 361 + 362 + (** Validate that a cookie domain is not a public suffix. 363 + 364 + Per RFC 6265 Section 5.3 Step 5, user agents MUST reject cookies where the 365 + Domain attribute is a public suffix (e.g., ".com", ".co.uk") unless the 366 + request host exactly matches that domain. 367 + 368 + This prevents attackers from setting domain-wide cookies that would affect 369 + all sites under a TLD. 370 + 371 + @param request_domain The host from the HTTP request 372 + @param cookie_domain 373 + The Domain attribute value (already normalized, without leading dot) 374 + @return 375 + [Ok ()] if the domain is allowed, [Error msg] if it's a public suffix 376 + 377 + Examples: 378 + - Request from "www.example.com", Domain=".com" → Error (public suffix) 379 + - Request from "www.example.com", Domain=".example.com" → Ok (not public 380 + suffix) 381 + - Request from "com", Domain=".com" → Ok (request host matches domain 382 + exactly) 383 + 384 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 385 + RFC 6265 Section 5.3 *) 386 + let validate_not_public_suffix ~request_domain ~cookie_domain = 387 + (* IP addresses bypass PSL check per RFC 6265 Section 5.1.3 *) 388 + match Ipaddr.of_string cookie_domain with 389 + | Ok _ -> Ok () (* IP addresses are not subject to PSL rules *) 390 + | Error _ -> ( 391 + let psl = Lazy.force psl in 392 + match Publicsuffix.is_public_suffix psl cookie_domain with 393 + | Error _ | Ok false -> 394 + (* If PSL lookup fails (e.g., invalid domain) or not a public suffix, 395 + allow the cookie. Domain name validation is handled separately. *) 396 + Ok () 397 + | Ok true -> 398 + (* It's a public suffix - only allow if request host matches exactly. 399 + This allows a server that IS a public suffix (rare but possible with 400 + private domains like blogspot.com) to set cookies for itself. *) 401 + let request_lower = String.lowercase_ascii request_domain in 402 + let cookie_lower = String.lowercase_ascii cookie_domain in 403 + if request_lower = cookie_lower then Ok () 404 + else 405 + Error 406 + (Fmt.str 407 + "Domain %S is a public suffix; RFC 6265 Section 5.3 prohibits \ 408 + setting cookies for public suffixes to prevent domain-wide \ 409 + cookie attacks. The request host %S does not exactly match \ 410 + the domain." 411 + cookie_domain request_domain)) 412 + 413 + (** {1 Cookie Parsing Helpers} *) 414 + 415 + (** Normalize a domain by stripping the leading dot. 416 + 417 + Per 418 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 419 + Section 5.2.3}, if the first character of the Domain attribute value is 420 + ".", that character is ignored (the domain remains case-insensitive). 421 + 422 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> 423 + RFC 6265 Section 5.2.3 - The Domain Attribute *) 424 + let normalize_domain domain = 425 + match String.starts_with ~prefix:"." domain with 426 + | true when String.length domain > 1 -> 427 + String.sub domain 1 (String.length domain - 1) 428 + | _ -> domain 429 + 430 + (** {1 HTTP Date Parsing} 431 + 432 + Date parsing follows 433 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 434 + Section 5.1.1} which requires parsing dates in various HTTP formats. *) 435 + 436 + module Date_parser = struct 437 + (** Month name to number mapping (case-insensitive). 438 + 439 + Per 440 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 441 + Section 5.1.1}, month tokens are matched case-insensitively. 442 + 443 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> 444 + RFC 6265 Section 5.1.1 - Dates *) 445 + let month_of_string s = 446 + match String.lowercase_ascii s with 447 + | "jan" -> Some 1 448 + | "feb" -> Some 2 449 + | "mar" -> Some 3 450 + | "apr" -> Some 4 451 + | "may" -> Some 5 452 + | "jun" -> Some 6 453 + | "jul" -> Some 7 454 + | "aug" -> Some 8 455 + | "sep" -> Some 9 456 + | "oct" -> Some 10 457 + | "nov" -> Some 11 458 + | "dec" -> Some 12 459 + | _ -> None 460 + 461 + (** Normalize abbreviated years per RFC 6265. 462 + 463 + Per 464 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 465 + Section 5.1.1}: 466 + - Years 70-99 get 1900 added (e.g., 95 → 1995) 467 + - Years 0-69 get 2000 added (e.g., 25 → 2025) 468 + - Years >= 100 are returned as-is 469 + 470 + Note: This implementation treats year 69 as 1969 (adding 1900), which 471 + technically differs from the RFC's "70 and less than or equal to 99" rule. 472 + 473 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> 474 + RFC 6265 Section 5.1.1 - Dates *) 475 + let normalize_year year = 476 + if year >= 0 && year <= 68 then year + 2000 477 + else if year >= 69 && year <= 99 then year + 1900 478 + else year 479 + 480 + (** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *) 481 + let parse_fmt1 s = 482 + try 483 + Scanf.sscanf s "%s %d %s %d %d:%d:%d %s" 484 + (fun _wday day mon year hour min sec tz -> 485 + (* Check timezone is GMT (case-insensitive) *) 486 + if String.lowercase_ascii tz <> "gmt" then None 487 + else 488 + match month_of_string mon with 489 + | None -> None 490 + | Some month -> 491 + let year = normalize_year year in 492 + Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 493 + with Scanf.Scan_failure _ | Failure _ | End_of_file -> None 494 + 495 + (** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *) 496 + let parse_fmt2 s = 497 + try 498 + Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s" 499 + (fun _wday day mon year hour min sec tz -> 500 + (* Check timezone is GMT (case-insensitive) *) 501 + if String.lowercase_ascii tz <> "gmt" then None 502 + else 503 + match month_of_string mon with 504 + | None -> None 505 + | Some month -> 506 + let year = normalize_year year in 507 + Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 508 + with Scanf.Scan_failure _ | Failure _ | End_of_file -> None 509 + 510 + (** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *) 511 + let parse_fmt3 s = 512 + try 513 + Scanf.sscanf s "%s %s %d %d:%d:%d %d" 514 + (fun _wday mon day hour min sec year -> 515 + match month_of_string mon with 516 + | None -> None 517 + | Some month -> 518 + let year = normalize_year year in 519 + Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 520 + with Scanf.Scan_failure _ | Failure _ | End_of_file -> None 521 + 522 + (** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *) 523 + let parse_fmt4 s = 524 + try 525 + Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s" 526 + (fun _wday day mon year hour min sec tz -> 527 + (* Check timezone is GMT (case-insensitive) *) 528 + if String.lowercase_ascii tz <> "gmt" then None 529 + else 530 + match month_of_string mon with 531 + | None -> None 532 + | Some month -> 533 + let year = normalize_year year in 534 + Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 535 + with Scanf.Scan_failure _ | Failure _ | End_of_file -> None 536 + 537 + (** Parse HTTP date by trying all supported formats in sequence *) 538 + let parse_http_date s = 539 + let ( <|> ) a b = match a with Some _ -> a | None -> b () in 540 + parse_fmt1 s <|> fun () -> 541 + parse_fmt2 s <|> fun () -> 542 + parse_fmt3 s <|> fun () -> parse_fmt4 s 543 + 544 + (** Format a Ptime.t as an HTTP-date (rfc1123-date format). 545 + 546 + Per 547 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 548 + Section 4.1.1}, the Expires attribute uses sane-cookie-date which 549 + references 550 + {{:https://datatracker.ietf.org/doc/html/rfc1123#section-5.2.14} RFC 1123 551 + Section 5.2.14}. 552 + 553 + Format: "Sun, 06 Nov 1994 08:49:37 GMT" 554 + 555 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 556 + RFC 6265 Section 4.1.1 *) 557 + let format_http_date time = 558 + let (year, month, day), ((hour, min, sec), _tz_offset) = 559 + Ptime.to_date_time time 560 + in 561 + let weekday = 562 + match Ptime.weekday time with 563 + | `Sun -> "Sun" 564 + | `Mon -> "Mon" 565 + | `Tue -> "Tue" 566 + | `Wed -> "Wed" 567 + | `Thu -> "Thu" 568 + | `Fri -> "Fri" 569 + | `Sat -> "Sat" 570 + in 571 + let month_name = 572 + [| 573 + ""; 574 + "Jan"; 575 + "Feb"; 576 + "Mar"; 577 + "Apr"; 578 + "May"; 579 + "Jun"; 580 + "Jul"; 581 + "Aug"; 582 + "Sep"; 583 + "Oct"; 584 + "Nov"; 585 + "Dec"; 586 + |].(month) 587 + in 588 + Fmt.str "%s, %02d %s %04d %02d:%02d:%02d GMT" weekday day month_name year 589 + hour min sec 590 + end 591 + 592 + (** {1 Cookie Parsing} *) 593 + 594 + type cookie_attributes = { 595 + mutable domain : string option; 596 + mutable path : string option; 597 + mutable secure : bool; 598 + mutable http_only : bool; 599 + mutable partitioned : bool; 600 + mutable expires : Expiration.t option; 601 + mutable max_age : Ptime.Span.t option; 602 + mutable same_site : Same_site.t option; 603 + } 604 + (** Accumulated attributes from parsing Set-Cookie header *) 605 + 606 + (** Create empty attribute accumulator *) 607 + let empty_attributes () = 608 + { 609 + domain = None; 610 + path = None; 611 + secure = false; 612 + http_only = false; 613 + partitioned = false; 614 + expires = None; 615 + max_age = None; 616 + same_site = None; 617 + } 618 + 619 + (** Parse a single cookie attribute and update the accumulator in-place. 620 + 621 + Attribute parsing follows 622 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 623 + Section 5.2} which defines the grammar and semantics for each cookie 624 + attribute. 625 + 626 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> 627 + RFC 6265 Section 5.2 - The Set-Cookie Header *) 628 + let parse_attribute now attrs attr_name attr_value = 629 + let attr_lower = String.lowercase_ascii attr_name in 630 + match attr_lower with 631 + | "domain" -> attrs.domain <- Some (normalize_domain attr_value) 632 + | "path" -> attrs.path <- Some attr_value 633 + | "expires" -> ( 634 + if 635 + (* Special case: Expires=0 means session cookie *) 636 + attr_value = "0" 637 + then attrs.expires <- Some `Session 638 + else 639 + match Ptime.of_rfc3339 attr_value with 640 + | Ok (time, _, _) -> attrs.expires <- Some (`DateTime time) 641 + | Error (`RFC3339 (_, err)) -> ( 642 + (* Try HTTP date format as fallback *) 643 + match Date_parser.parse_http_date attr_value with 644 + | Some time -> attrs.expires <- Some (`DateTime time) 645 + | None -> 646 + Log.warn (fun m -> 647 + m "Failed to parse expires attribute '%s': %a" attr_value 648 + Ptime.pp_rfc3339_error err))) 649 + | "max-age" -> ( 650 + match int_of_string_opt attr_value with 651 + | Some seconds -> 652 + (* Handle negative values as 0 per RFC 6265 *) 653 + let seconds = max 0 seconds in 654 + let current_time = now () in 655 + (* Store the max-age as a Ptime.Span *) 656 + attrs.max_age <- Some (Ptime.Span.of_int_s seconds); 657 + (* Also compute and store expires as DateTime *) 658 + let expires = 659 + Ptime.add_span current_time (Ptime.Span.of_int_s seconds) 660 + in 661 + (match expires with 662 + | Some time -> attrs.expires <- Some (`DateTime time) 663 + | None -> ()); 664 + Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds) 665 + | None -> 666 + Log.warn (fun m -> 667 + m "Failed to parse max-age attribute '%s'" attr_value)) 668 + | "secure" -> attrs.secure <- true 669 + | "httponly" -> attrs.http_only <- true 670 + | "partitioned" -> attrs.partitioned <- true 671 + | "samesite" -> ( 672 + match String.lowercase_ascii attr_value with 673 + | "strict" -> attrs.same_site <- Some `Strict 674 + | "lax" -> attrs.same_site <- Some `Lax 675 + | "none" -> attrs.same_site <- Some `None 676 + | _ -> 677 + Log.warn (fun m -> 678 + m "Invalid samesite value '%s', ignoring" attr_value)) 679 + | _ -> 680 + Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name) 681 + 682 + (** Validate cookie attributes and log warnings for invalid combinations. 683 + 684 + Validates: 685 + - SameSite=None requires the Secure flag (per RFC 6265bis) 686 + - Partitioned requires the Secure flag (per CHIPS specification) 687 + 688 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> 689 + RFC 6265bis Section 5.4.7 - SameSite 690 + @see <https://developer.chrome.com/docs/privacy-sandbox/chips/> 691 + CHIPS - Cookies Having Independent Partitioned State *) 692 + let validate_attributes attrs = 693 + match (attrs.same_site, attrs.secure, attrs.partitioned) with 694 + | Some `None, false, _ -> 695 + Log.warn (fun m -> 696 + m 697 + "Cookie has SameSite=None but Secure flag is not set; this \ 698 + violates RFC requirements"); 699 + false 700 + | _, false, true -> 701 + Log.warn (fun m -> 702 + m 703 + "Cookie has Partitioned attribute but Secure flag is not set; this \ 704 + violates CHIPS requirements"); 705 + false 706 + | _ -> true 707 + 708 + (** Build final cookie from name/value and accumulated attributes. 709 + 710 + Per 711 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 712 + Section 5.3}: 713 + - If Domain attribute is present, host-only-flag = false, domain = attribute 714 + value 715 + - If Domain attribute is absent, host-only-flag = true, domain = request 716 + host 717 + 718 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 719 + RFC 6265 Section 5.3 - Storage Model *) 720 + let build_cookie ~request_domain ~request_path ~name ~value attrs ~now = 721 + let host_only, domain = 722 + match attrs.domain with 723 + | Some d -> (false, normalize_domain d) 724 + | None -> (true, request_domain) 725 + in 726 + let path = Option.value attrs.path ~default:request_path in 727 + v ~domain ~path ~name ~value ~secure:attrs.secure ~http_only:attrs.http_only 728 + ?expires:attrs.expires ?max_age:attrs.max_age ?same_site:attrs.same_site 729 + ~partitioned:attrs.partitioned ~host_only ~creation_time:now 730 + ~last_access:now () 731 + 732 + (** {1 Pretty Printing} *) 733 + 734 + let pp ppf cookie = 735 + Fmt.pf ppf 736 + "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \ 737 + http_only=%b;@ partitioned=%b;@ host_only=%b;@ expires=%a;@ max_age=%a;@ \ 738 + same_site=%a }@]" 739 + (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie) 740 + (http_only cookie) (partitioned cookie) (host_only cookie) 741 + Fmt.(option Expiration.pp) 742 + (expires cookie) 743 + Fmt.(option Ptime.Span.pp) 744 + (max_age cookie) 745 + Fmt.(option Same_site.pp) 746 + (same_site cookie) 747 + 748 + (** {1 Cookie Parsing} *) 749 + 750 + (** Parse a Set-Cookie HTTP response header. 751 + 752 + Parses the header according to 753 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 754 + Section 5.2}, extracting the cookie name, value, and all attributes. 755 + Returns [Error msg] if the cookie is invalid or fails validation, with a 756 + descriptive error message. 757 + 758 + @param now Function returning current time for Max-Age computation 759 + @param domain The request host (used as default domain) 760 + @param path The request path (used as default path) 761 + @param header_value The Set-Cookie header value string 762 + @return 763 + [Ok cookie] if parsing succeeds, [Error msg] with explanation if invalid 764 + 765 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> 766 + RFC 6265 Section 5.2 - The Set-Cookie Header *) 767 + let validate_attr_value attr_name attr_value errors = 768 + match String.lowercase_ascii attr_name with 769 + | "domain" -> ( 770 + match Validate.domain_value attr_value with 771 + | Error msg -> errors := msg :: !errors 772 + | Ok _ -> ()) 773 + | "path" -> ( 774 + match Validate.path_value attr_value with 775 + | Error msg -> errors := msg :: !errors 776 + | Ok _ -> ()) 777 + | "max-age" -> ( 778 + match int_of_string_opt attr_value with 779 + | Some seconds -> ( 780 + match Validate.max_age seconds with 781 + | Error msg -> errors := msg :: !errors 782 + | Ok _ -> ()) 783 + | None -> ()) 784 + | _ -> () 785 + 786 + let parse_attrs now attrs = 787 + let accumulated_attrs = empty_attributes () in 788 + let attr_errors = ref [] in 789 + List.iter 790 + (fun attr -> 791 + match String.index_opt attr '=' with 792 + | None -> parse_attribute now accumulated_attrs attr "" 793 + | Some eq -> 794 + let attr_name = String.sub attr 0 eq |> String.trim in 795 + let attr_value = 796 + String.sub attr (eq + 1) (String.length attr - eq - 1) 797 + |> String.trim 798 + in 799 + validate_attr_value attr_name attr_value attr_errors; 800 + parse_attribute now accumulated_attrs attr_name attr_value) 801 + attrs; 802 + (accumulated_attrs, !attr_errors) 803 + 804 + let validate_cookie ~request_domain ~request_path ~name ~cookie_value attrs 805 + ~now:current_time = 806 + if not (validate_attributes attrs) then 807 + Error 808 + "Cookie validation failed: SameSite=None requires Secure flag, and \ 809 + Partitioned requires Secure flag" 810 + else 811 + let psl_result = 812 + match attrs.domain with 813 + | None -> Ok () 814 + | Some cookie_domain -> 815 + let normalized = normalize_domain cookie_domain in 816 + validate_not_public_suffix ~request_domain ~cookie_domain:normalized 817 + in 818 + match psl_result with 819 + | Error msg -> Error msg 820 + | Ok () -> 821 + let cookie = 822 + build_cookie ~request_domain ~request_path ~name ~value:cookie_value 823 + attrs ~now:current_time 824 + in 825 + Log.debug (fun m -> 826 + m "Parsed cookie: %s (domain=%s, path=%s)" name cookie.domain 827 + cookie.path); 828 + Ok cookie 829 + 830 + let of_set_cookie_header ~now ~domain:request_domain ~path:request_path 831 + header_value = 832 + Log.debug (fun m -> 833 + m "Parsing Set-Cookie: %s" 834 + (match String.index_opt header_value '=' with 835 + | Some i -> 836 + let name = String.sub header_value 0 i in 837 + name ^ "=<redacted>" 838 + | None -> "<malformed>")); 839 + 840 + let parts = String.split_on_char ';' header_value |> List.map String.trim in 841 + 842 + match parts with 843 + | [] -> Error "Empty Set-Cookie header" 844 + | name_value :: attrs -> ( 845 + match String.index_opt name_value '=' with 846 + | None -> 847 + Error 848 + (Fmt.str 849 + "Set-Cookie header missing '=' separator in name-value pair: %S" 850 + name_value) 851 + | Some eq_pos -> ( 852 + let name = String.sub name_value 0 eq_pos |> String.trim in 853 + let cookie_value = 854 + String.sub name_value (eq_pos + 1) 855 + (String.length name_value - eq_pos - 1) 856 + |> String.trim 857 + in 858 + match Validate.cookie_name name with 859 + | Error msg -> Error msg 860 + | Ok name -> ( 861 + match Validate.cookie_value cookie_value with 862 + | Error msg -> Error msg 863 + | Ok cookie_value -> 864 + let current_time = now () in 865 + let accumulated_attrs, attr_errors = parse_attrs now attrs in 866 + if List.length attr_errors > 0 then 867 + Error (String.concat "; " (List.rev attr_errors)) 868 + else 869 + validate_cookie ~request_domain ~request_path ~name 870 + ~cookie_value accumulated_attrs ~now:current_time))) 871 + 872 + (** Parse a Cookie HTTP request header. 873 + 874 + Parses the header according to 875 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 876 + Section 4.2}. The Cookie header contains semicolon-separated name=value 877 + pairs. 878 + 879 + Validates cookie names and values per RFC 6265 and detects duplicate cookie 880 + names (which is an error per Section 4.2.1). 881 + 882 + Cookies parsed from the Cookie header have [host_only = true] since we 883 + cannot determine from the header alone whether they originally had a Domain 884 + attribute. 885 + 886 + @param now Function returning current time for timestamps 887 + @param domain The request host (assigned to all parsed cookies) 888 + @param path The request path (assigned to all parsed cookies) 889 + @param header_value The Cookie header value string 890 + @return 891 + [Ok cookies] if all cookies parse successfully with no duplicates, 892 + [Error msg] with explanation if validation fails 893 + 894 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> 895 + RFC 6265 Section 4.2 - The Cookie Header *) 896 + let parse_cookie_pair ~now ~domain ~path name_value seen_names = 897 + match String.index_opt name_value '=' with 898 + | None -> err_missing_separator name_value 899 + | Some eq_pos -> ( 900 + let cookie_name = String.sub name_value 0 eq_pos |> String.trim in 901 + match Validate.cookie_name cookie_name with 902 + | Error msg -> Error msg 903 + | Ok cookie_name -> ( 904 + if List.mem cookie_name seen_names then 905 + err_duplicate_cookie cookie_name 906 + else 907 + let cookie_value = 908 + String.sub name_value (eq_pos + 1) 909 + (String.length name_value - eq_pos - 1) 910 + |> String.trim 911 + in 912 + match Validate.cookie_value cookie_value with 913 + | Error msg -> Error msg 914 + | Ok cookie_value -> 915 + let current_time = now () in 916 + let cookie = 917 + v ~domain ~path ~name:cookie_name ~value:cookie_value 918 + ~secure:false ~http_only:false ~partitioned:false 919 + ~host_only:true ~creation_time:current_time 920 + ~last_access:current_time () 921 + in 922 + Ok (cookie, cookie_name))) 923 + 924 + let of_cookie_header ~now ~domain ~path header_value = 925 + Log.debug (fun m -> 926 + let names = 927 + String.split_on_char ';' header_value 928 + |> List.filter_map (fun part -> 929 + match String.index_opt (String.trim part) '=' with 930 + | Some i -> Some (String.sub (String.trim part) 0 i) 931 + | None -> None) 932 + in 933 + m "Parsing Cookie header: [%s]=<redacted>" (String.concat "; " names)); 934 + 935 + let parts = String.split_on_char ';' header_value |> List.map String.trim in 936 + let parts = List.filter (fun s -> String.length s > 0) parts in 937 + 938 + let results = 939 + List.fold_left 940 + (fun acc name_value -> 941 + match acc with 942 + | Error _ -> acc 943 + | Ok (cookies, seen_names) -> ( 944 + match 945 + parse_cookie_pair ~now ~domain ~path name_value seen_names 946 + with 947 + | Error msg -> Error msg 948 + | Ok (cookie, name) -> Ok (cookie :: cookies, name :: seen_names))) 949 + (Ok ([], [])) 950 + parts 951 + in 952 + match results with 953 + | Error msg -> Error msg 954 + | Ok (cookies, _) -> Ok (List.rev cookies) 955 + 956 + (** Generate a Cookie HTTP request header from a list of cookies. 957 + 958 + Formats cookies according to 959 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 960 + Section 4.2} as semicolon-separated name=value pairs. 961 + 962 + @param cookies List of cookies to include 963 + @return The Cookie header value string 964 + 965 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> 966 + RFC 6265 Section 4.2 - The Cookie Header *) 967 + let cookie_header cookies = 968 + cookies 969 + |> List.map (fun c -> Fmt.str "%s=%s" (name c) (value c)) 970 + |> String.concat "; " 971 + 972 + (** Generate a Set-Cookie HTTP response header from a cookie. 973 + 974 + Formats the cookie according to 975 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 976 + Section 4.1} including all attributes. 977 + 978 + Note: The Expires attribute is currently formatted using RFC 3339, which 979 + differs from the RFC-recommended rfc1123-date format. 980 + 981 + @param cookie The cookie to serialize 982 + @return The Set-Cookie header value string 983 + 984 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> 985 + RFC 6265 Section 4.1 - The Set-Cookie Header *) 986 + let set_cookie_header cookie = 987 + let buffer = Buffer.create 128 in 988 + Buffer.add_string buffer (Fmt.str "%s=%s" (name cookie) (value cookie)); 989 + 990 + (* Add Max-Age if present *) 991 + Option.iter 992 + (fun span -> 993 + Option.iter 994 + (fun seconds -> 995 + Buffer.add_string buffer (Fmt.str "; Max-Age=%d" seconds)) 996 + (Ptime.Span.to_int_s span)) 997 + (max_age cookie); 998 + 999 + (* Add Expires if present - using RFC 1123 date format per RFC 6265 Section 4.1.1 *) 1000 + Option.iter 1001 + (function 1002 + | `Session -> Buffer.add_string buffer "; Expires=0" 1003 + | `DateTime exp_time -> 1004 + let exp_str = Date_parser.format_http_date exp_time in 1005 + Buffer.add_string buffer (Fmt.str "; Expires=%s" exp_str)) 1006 + (expires cookie); 1007 + 1008 + (* Add Domain *) 1009 + Buffer.add_string buffer (Fmt.str "; Domain=%s" (domain cookie)); 1010 + 1011 + (* Add Path *) 1012 + Buffer.add_string buffer (Fmt.str "; Path=%s" (path cookie)); 1013 + 1014 + (* Add Secure flag *) 1015 + if secure cookie then Buffer.add_string buffer "; Secure"; 1016 + 1017 + (* Add HttpOnly flag *) 1018 + if http_only cookie then Buffer.add_string buffer "; HttpOnly"; 1019 + 1020 + (* Add Partitioned flag *) 1021 + if partitioned cookie then Buffer.add_string buffer "; Partitioned"; 1022 + 1023 + (* Add SameSite *) 1024 + Option.iter 1025 + (function 1026 + | `Strict -> Buffer.add_string buffer "; SameSite=Strict" 1027 + | `Lax -> Buffer.add_string buffer "; SameSite=Lax" 1028 + | `None -> Buffer.add_string buffer "; SameSite=None") 1029 + (same_site cookie); 1030 + 1031 + Buffer.contents buffer
+612
lib/core/cookie.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Cookie management library for OCaml 7 + 8 + HTTP cookies are a mechanism defined in 9 + {{:https://datatracker.ietf.org/doc/html/rfc6265} RFC 6265} that allows 10 + "server side connections to store and retrieve information on the client 11 + side." Originally designed to enable persistent client-side state for web 12 + applications, cookies are essential for storing user preferences, session 13 + data, shopping cart contents, and authentication tokens. 14 + 15 + This library provides a complete cookie implementation following RFC 6265 16 + while integrating Eio for efficient asynchronous operations. 17 + 18 + {2 Cookie Format and Structure} 19 + 20 + Cookies are set via the Set-Cookie HTTP response header 21 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} Section 4.1}) 22 + with the basic format: [NAME=VALUE] with optional attributes including: 23 + - [expires]: Cookie lifetime specification 24 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1} Section 25 + 5.2.1}) 26 + - [max-age]: Cookie lifetime in seconds 27 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2} Section 28 + 5.2.2}) 29 + - [domain]: Valid domains using tail matching 30 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} Section 31 + 5.2.3}) 32 + - [path]: URL subset for cookie validity 33 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4} Section 34 + 5.2.4}) 35 + - [secure]: Transmission over secure channels only 36 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5} Section 37 + 5.2.5}) 38 + - [httponly]: Not accessible to JavaScript 39 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6} Section 40 + 5.2.6}) 41 + - [samesite]: Cross-site request behavior (RFC 6265bis) 42 + - [partitioned]: CHIPS partitioned storage 43 + 44 + {2 Domain and Path Matching} 45 + 46 + The library implements standard domain and path matching rules from 47 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 48 + 5.1.3} and 49 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 50 + 5.1.4}: 51 + - Domain matching uses suffix matching for hostnames (e.g., "example.com" 52 + matches "sub.example.com") 53 + - IP addresses require exact match only 54 + - Path matching requires exact match or prefix with "/" separator 55 + 56 + @see <https://datatracker.ietf.org/doc/html/rfc6265> 57 + RFC 6265 - HTTP State Management Mechanism 58 + 59 + {2 Standards and References} 60 + 61 + This library implements and references the following IETF specifications: 62 + 63 + - {{:https://datatracker.ietf.org/doc/html/rfc6265}RFC 6265} - HTTP State 64 + Management Mechanism (April 2011) - Primary specification 65 + - {{:https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis}RFC 66 + 6265bis} - Cookies: HTTP State Management Mechanism (Draft) - SameSite 67 + attribute and modern updates 68 + - {{:https://datatracker.ietf.org/doc/html/rfc1034#section-3.5}RFC 1034 69 + Section 3.5} - Domain Names - Preferred Name Syntax for domain validation 70 + - {{:https://datatracker.ietf.org/doc/html/rfc2616#section-2.2}RFC 2616 71 + Section 2.2} - HTTP/1.1 - Token syntax definition 72 + - {{:https://datatracker.ietf.org/doc/html/rfc1123#section-5.2.14}RFC 1123 73 + Section 5.2.14} - Internet Host Requirements - Date format (rfc1123-date) 74 + 75 + Additional standards: 76 + - {{:https://publicsuffix.org/}Mozilla Public Suffix List} - Registry of 77 + public suffixes for cookie domain validation per RFC 6265 Section 5.3 Step 78 + 5 79 + 80 + {2 Related Libraries} 81 + 82 + - [Publicsuffix] - Public Suffix List lookup used for domain validation 83 + - [Cookie_jar] - Cookie jar storage with persistence support *) 84 + 85 + (** {1 Types} *) 86 + 87 + module Same_site : sig 88 + type t = [ `Strict | `Lax | `None ] 89 + (** Cookie same-site policy for controlling cross-site request behavior. 90 + 91 + Defined in RFC 6265bis draft. 92 + 93 + - [`Strict]: Cookie only sent for same-site requests, providing maximum 94 + protection 95 + - [`Lax]: Cookie sent for same-site requests and top-level navigation 96 + (default for modern browsers) 97 + - [`None]: Cookie sent for all cross-site requests (requires [secure] flag 98 + per RFC 6265bis) 99 + 100 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> 101 + RFC 6265bis Section 5.4.7 - The SameSite Attribute *) 102 + 103 + val equal : t -> t -> bool 104 + (** Equality function for same-site values. *) 105 + 106 + val pp : Format.formatter -> t -> unit 107 + (** Pretty printer for same-site values. *) 108 + end 109 + 110 + module Expiration : sig 111 + type t = [ `Session | `DateTime of Ptime.t ] 112 + (** Cookie expiration strategy. 113 + 114 + Per 115 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 116 + Section 5.3}: 117 + - [`Session]: Session cookie that expires when user agent session ends 118 + (persistent-flag = false) 119 + - [`DateTime time]: Persistent cookie that expires at specific time 120 + (persistent-flag = true) 121 + 122 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 123 + RFC 6265 Section 5.3 - Storage Model *) 124 + 125 + val equal : t -> t -> bool 126 + (** Equality function for expiration values. *) 127 + 128 + val pp : Format.formatter -> t -> unit 129 + (** Pretty printer for expiration values. *) 130 + end 131 + 132 + type t 133 + (** HTTP Cookie representation with all standard attributes. 134 + 135 + A cookie represents a name-value pair with associated metadata that controls 136 + its scope, security, and lifetime. Per 137 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 138 + Section 5.3}, cookies with the same [name], [domain], and [path] will 139 + overwrite each other when stored. 140 + 141 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 142 + RFC 6265 Section 5.3 - Storage Model *) 143 + 144 + (** {1 Cookie Accessors} *) 145 + 146 + val domain : t -> string 147 + (** Get the domain of a cookie. 148 + 149 + The domain is normalized per 150 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 151 + Section 5.2.3} (leading dots removed). *) 152 + 153 + val path : t -> string 154 + (** [path t] returns the path of a cookie. 155 + 156 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4> 157 + RFC 6265 Section 5.2.4 - The Path Attribute. *) 158 + 159 + val name : t -> string 160 + (** Get the name of a cookie. *) 161 + 162 + val value : t -> string 163 + (** Get the value of a cookie. *) 164 + 165 + val value_trimmed : t -> string 166 + (** [value_trimmed t] returns the cookie value with surrounding double-quotes 167 + removed if they form a matching pair. 168 + 169 + Only removes quotes when both opening and closing quotes are present. The 170 + raw value is always preserved in {!value}. This is useful for handling 171 + quoted cookie values. 172 + 173 + Examples: 174 + - ["value"] → ["value"] 175 + - ["\"value\""] → ["value"] 176 + - ["\"value"] → ["\"value"] (no matching pair) 177 + - ["\"val\"\""] → ["val\""] (removes outer pair only) *) 178 + 179 + val secure : t -> bool 180 + (** [secure t] checks if cookie has the Secure flag. 181 + 182 + Per 183 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5} RFC 6265 184 + Section 5.2.5}, Secure cookies are only sent over HTTPS connections. 185 + 186 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5> 187 + RFC 6265 Section 5.2.5 - The Secure Attribute. *) 188 + 189 + val http_only : t -> bool 190 + (** [http_only t] checks if cookie has the HttpOnly flag. 191 + 192 + Per 193 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6} RFC 6265 194 + Section 5.2.6}, HttpOnly cookies are not accessible to client-side scripts. 195 + 196 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6> 197 + RFC 6265 Section 5.2.6 - The HttpOnly Attribute. *) 198 + 199 + val partitioned : t -> bool 200 + (** [partitioned t] checks if cookie has the Partitioned attribute. 201 + 202 + Partitioned cookies are part of CHIPS (Cookies Having Independent 203 + Partitioned State) and are stored separately per top-level site, enabling 204 + privacy-preserving third-party cookie functionality. Partitioned cookies 205 + must always be Secure. 206 + 207 + @see <https://developer.chrome.com/docs/privacy-sandbox/chips/> 208 + CHIPS - Cookies Having Independent Partitioned State. *) 209 + 210 + val host_only : t -> bool 211 + (** [host_only t] checks if cookie has the host-only flag set. 212 + 213 + Per 214 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 215 + Section 5.3 Step 6}: 216 + - If the Set-Cookie header included a Domain attribute, host-only-flag is 217 + false and the cookie matches the domain and all subdomains. 218 + - If no Domain attribute was present, host-only-flag is true and the cookie 219 + only matches the exact request host. 220 + 221 + Example: 222 + - Cookie set on "example.com" with Domain=example.com: host_only=false, 223 + matches example.com and sub.example.com 224 + - Cookie set on "example.com" without Domain attribute: host_only=true, 225 + matches only example.com, not sub.example.com 226 + 227 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 228 + RFC 6265 Section 5.3 - Storage Model. *) 229 + 230 + val expires : t -> Expiration.t option 231 + (** [expires t] returns the expiration attribute if set. 232 + 233 + Per 234 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1} RFC 6265 235 + Section 5.2.1}: 236 + - [None]: No expiration specified (session cookie) 237 + - [Some `Session]: Session cookie (expires when user agent session ends) 238 + - [Some (`DateTime t)]: Expires at specific time [t] 239 + 240 + Both [max_age] and [expires] can be present simultaneously. This library 241 + stores both independently. 242 + 243 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1> 244 + RFC 6265 Section 5.2.1 - The Expires Attribute. *) 245 + 246 + val max_age : t -> Ptime.Span.t option 247 + (** Get the max-age attribute if set. 248 + 249 + Per 250 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2} RFC 6265 251 + Section 5.2.2}, Max-Age specifies the cookie lifetime in seconds. Both 252 + [max_age] and [expires] can be present simultaneously. When both are present 253 + in a Set-Cookie header, browsers prioritize [max_age] per 254 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} Section 5.3 255 + Step 3}. 256 + 257 + This library stores both independently and serializes both when present. 258 + 259 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> 260 + RFC 6265 Section 5.2.2 - The Max-Age Attribute. *) 261 + 262 + val same_site : t -> Same_site.t option 263 + (** Get the same-site policy of a cookie. 264 + 265 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> 266 + RFC 6265bis Section 5.4.7 - The SameSite Attribute. *) 267 + 268 + val creation_time : t -> Ptime.t 269 + (** Get the creation time of a cookie. 270 + 271 + Per 272 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 273 + Section 5.3}, this is set when the cookie is first received. *) 274 + 275 + val last_access : t -> Ptime.t 276 + (** Get the last access time of a cookie. 277 + 278 + Per 279 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 280 + Section 5.3}, this is updated each time the cookie is retrieved for a 281 + request. *) 282 + 283 + val v : 284 + domain:string -> 285 + path:string -> 286 + name:string -> 287 + value:string -> 288 + ?secure:bool -> 289 + ?http_only:bool -> 290 + ?expires:Expiration.t -> 291 + ?max_age:Ptime.Span.t -> 292 + ?same_site:Same_site.t -> 293 + ?partitioned:bool -> 294 + ?host_only:bool -> 295 + creation_time:Ptime.t -> 296 + last_access:Ptime.t -> 297 + unit -> 298 + t 299 + (** [v ~domain ~path ~name ~value ~creation_time ~last_access ()] creates a new 300 + cookie with the given attributes. 301 + 302 + @param domain The cookie domain (will be normalized) 303 + @param path The cookie path 304 + @param name The cookie name 305 + @param value The cookie value 306 + @param secure If true, cookie only sent over HTTPS (default: false) 307 + @param http_only If true, cookie not accessible to scripts (default: false) 308 + @param expires Expiration time 309 + @param max_age Lifetime in seconds 310 + @param same_site Cross-site request policy 311 + @param partitioned CHIPS partitioned storage (default: false) 312 + @param host_only 313 + If true, exact domain match only (default: false). Per 314 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 315 + Section 5.3}, this should be true when no Domain attribute was present in 316 + the Set-Cookie header. 317 + @param creation_time When the cookie was created 318 + @param last_access Last time the cookie was accessed 319 + 320 + Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid 321 + combinations will result in validation errors. 322 + 323 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 324 + RFC 6265 Section 5.3 - Storage Model. *) 325 + 326 + (** {1 RFC 6265 Validation} 327 + 328 + Validation functions for cookie names, values, and attributes per 329 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 330 + Section 4.1.1}. 331 + 332 + These functions implement the syntactic requirements from RFC 6265 to ensure 333 + cookies conform to the specification before being sent in HTTP headers. All 334 + validation failures return detailed error messages citing the specific RFC 335 + requirement that was violated. 336 + 337 + {2 Validation Philosophy} 338 + 339 + Per RFC 6265 Section 4, there is an important distinction between: 340 + - {b Server requirements} (Section 4.1): Strict syntax for generating 341 + Set-Cookie headers 342 + - {b User agent requirements} (Section 5): Lenient parsing for receiving 343 + Set-Cookie headers 344 + 345 + These validation functions enforce the {b server requirements}, ensuring 346 + that cookies generated by this library conform to RFC 6265 syntax. When 347 + parsing cookies from HTTP headers, the library may be more lenient to 348 + maximize interoperability with non-compliant servers. 349 + 350 + {2 Character Set Requirements} 351 + 352 + RFC 6265 restricts cookies to US-ASCII characters with specific exclusions: 353 + - Cookie names: RFC 2616 tokens (no CTLs, no separators) 354 + - Cookie values: cookie-octet characters plus space (0x20-0x21, 0x23-0x2B, 355 + 0x2D-0x3A, 0x3C-0x5B, 0x5D-0x7E) 356 + - Domain values: RFC 1034 domain name syntax or IP addresses 357 + - Path values: Any character except CTLs and semicolon 358 + 359 + These functions return [Ok value] on success or [Error msg] with a detailed 360 + explanation of why validation failed. 361 + 362 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 363 + RFC 6265 Section 4.1.1 - Syntax *) 364 + 365 + module Validate : sig 366 + val cookie_name : string -> (string, string) result 367 + (** Validate a cookie name per RFC 6265. 368 + 369 + Cookie names must be valid RFC 2616 tokens: one or more characters 370 + excluding control characters and separators. 371 + 372 + Per 373 + {{:https://datatracker.ietf.org/doc/html/rfc2616#section-2.2}RFC 2616 374 + Section 2.2}, a token is defined as: one or more characters excluding 375 + control characters and the following 19 separator characters: parentheses, 376 + angle brackets, at-sign, comma, semicolon, colon, backslash, double-quote, 377 + forward slash, square brackets, question mark, equals, curly braces, 378 + space, and horizontal tab. 379 + 380 + This means tokens consist of visible ASCII characters (33-126) excluding 381 + control characters (0-31, 127) and the separator characters listed above. 382 + 383 + @param name The cookie name to validate 384 + @return [Ok name] if valid, [Error message] with explanation if invalid 385 + 386 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 387 + RFC 6265 Section 4.1.1 388 + @see <https://datatracker.ietf.org/doc/html/rfc2616#section-2.2> 389 + RFC 2616 Section 2.2 - Basic Rules *) 390 + 391 + val cookie_value : string -> (string, string) result 392 + (** Validate a cookie value per RFC 6265. 393 + 394 + Cookie values must contain only cookie-octets (plus spaces), optionally 395 + wrapped in double quotes. Invalid characters include: control characters, 396 + double quote (except as wrapper), comma, semicolon, and backslash. 397 + 398 + Per 399 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1}RFC 6265 400 + Section 4.1.1}, cookie-value may be: 401 + - Zero or more cookie-octet characters, or 402 + - Double-quoted string containing cookie-octet characters 403 + 404 + Where cookie-octet excludes: CTLs (0x00-0x1F, 0x7F), double-quote (0x22), 405 + comma (0x2C), semicolon (0x3B), and backslash (0x5C). Spaces (0x20) are 406 + accepted despite not being in the RFC grammar, as real-world servers 407 + commonly use them (e.g. LinkedIn's "delete me" cookie values). 408 + 409 + Valid characters: 0x20-0x21, 0x23-0x2B, 0x2D-0x3A, 0x3C-0x5B, 0x5D-0x7E 410 + 411 + @param value The cookie value to validate 412 + @return [Ok value] if valid, [Error message] with explanation if invalid 413 + 414 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 415 + RFC 6265 Section 4.1.1 *) 416 + 417 + val domain_value : string -> (string, string) result 418 + (** Validate a domain attribute value. 419 + 420 + Domain values must be either: 421 + - A valid domain name per RFC 1034 Section 3.5 422 + - A valid IPv4 address 423 + - A valid IPv6 address 424 + 425 + Per 426 + {{:https://datatracker.ietf.org/doc/html/rfc1034#section-3.5}RFC 1034 427 + Section 3.5}, preferred domain name syntax requires: 428 + - Labels separated by dots 429 + - Labels must start with a letter 430 + - Labels must end with a letter or digit 431 + - Labels may contain letters, digits, and hyphens 432 + - Labels are case-insensitive 433 + - Total length limited to 255 octets 434 + 435 + Leading dots are stripped per RFC 6265 Section 5.2.3 before validation. 436 + 437 + @param domain The domain value to validate (leading dot is stripped first) 438 + @return [Ok domain] if valid, [Error message] with explanation if invalid 439 + 440 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> 441 + RFC 6265 Section 4.1.2.3 442 + @see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> 443 + RFC 1034 Section 3.5 *) 444 + 445 + val path_value : string -> (string, string) result 446 + (** Validate a path attribute value. 447 + 448 + Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except control 449 + characters and semicolon. 450 + 451 + @param path The path value to validate 452 + @return [Ok path] if valid, [Error message] with explanation if invalid 453 + 454 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 455 + RFC 6265 Section 4.1.1 *) 456 + 457 + val max_age : int -> (int, string) result 458 + (** Validate a Max-Age attribute value. 459 + 460 + Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT. 461 + However, per Section 5.2.2, user agents should treat values <= 0 as 462 + "delete immediately". This function returns [Ok] for any integer since the 463 + parsing code handles negative values by converting to 0. 464 + 465 + @param seconds The Max-Age value in seconds 466 + @return [Ok seconds] always (negative values are handled in parsing) 467 + 468 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> 469 + RFC 6265 Section 4.1.1 470 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> 471 + RFC 6265 Section 5.2.2 *) 472 + end 473 + 474 + (** {1 Cookie Creation and Parsing} *) 475 + 476 + val of_set_cookie_header : 477 + now:(unit -> Ptime.t) -> 478 + domain:string -> 479 + path:string -> 480 + string -> 481 + (t, string) result 482 + (** [of_set_cookie_header ~now ~domain ~path s] parses a Set-Cookie response 483 + header value into a cookie. 484 + 485 + Parses a Set-Cookie header following 486 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 487 + Section 5.2}: 488 + - Basic format: [NAME=VALUE; attribute1; attribute2=value2] 489 + - Supports all standard attributes: [expires], [max-age], [domain], [path], 490 + [secure], [httponly], [samesite], [partitioned] 491 + - Returns [Error msg] if parsing fails or cookie validation fails, with a 492 + detailed explanation of what was invalid 493 + - The [domain] and [path] parameters provide the request context for default 494 + values 495 + - The [now] parameter is used for calculating expiry times from [max-age] 496 + attributes and setting creation/access times 497 + 498 + Validation rules applied: 499 + - Cookie name must be a valid RFC 2616 token (no CTLs or separators) 500 + - Cookie value must contain only valid cookie-octets 501 + - Domain must be a valid domain name (RFC 1034) or IP address 502 + - Path must not contain control characters or semicolons 503 + - Max-Age must be non-negative 504 + - [SameSite=None] requires the [Secure] flag to be set (RFC 6265bis) 505 + - [Partitioned] requires the [Secure] flag to be set (CHIPS) 506 + - Domain must not be a public suffix per 507 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 508 + Section 5.3 Step 5} (unless the request host exactly matches the domain). 509 + This uses the 510 + {{:https://publicsuffix.org/list/} Mozilla Public Suffix List} to prevent 511 + domain-wide cookie attacks. 512 + 513 + {3 Public Suffix Validation} 514 + 515 + Cookies with Domain attributes that are public suffixes (e.g., [.com], 516 + [.co.uk], [.github.io]) are rejected to prevent a malicious site from 517 + setting cookies that would affect all sites under that TLD. 518 + 519 + Examples: 520 + - Request from [www.example.com], Domain=[.com] → rejected (public suffix) 521 + - Request from [www.example.com], Domain=[.example.com] → allowed 522 + - Request from [blogspot.com], Domain=[.blogspot.com] → allowed (request 523 + matches) 524 + 525 + Example: 526 + {[ 527 + of_set_cookie_header 528 + ~now:(fun () -> Ptime_clock.now ()) 529 + ~domain:"example.com" ~path:"/" "session=abc123; Secure; HttpOnly" 530 + ]} 531 + 532 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> 533 + RFC 6265 Section 5.2 - The Set-Cookie Header 534 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 535 + RFC 6265 Section 5.3 - Storage Model (public suffix check) 536 + @see <https://publicsuffix.org/list/> Public Suffix List. *) 537 + 538 + val of_cookie_header : 539 + now:(unit -> Ptime.t) -> 540 + domain:string -> 541 + path:string -> 542 + string -> 543 + (t list, string) result 544 + (** [of_cookie_header ~now ~domain ~path s] parses a Cookie request header 545 + containing semicolon-separated name=value pairs. 546 + 547 + Parses a Cookie header following 548 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 549 + Section 4.2}. Cookie headers contain only name=value pairs without 550 + attributes: ["name1=value1; name2=value2; name3=value3"] 551 + 552 + Validates each cookie name and value per RFC 6265 and detects duplicate 553 + cookie names (which is forbidden per Section 4.2.1). 554 + 555 + Creates cookies with: 556 + - Provided [domain] and [path] from request context 557 + - All security flags set to [false] (defaults) 558 + - All optional attributes set to [None] 559 + - [host_only = true] (since we cannot determine from the header alone 560 + whether cookies originally had a Domain attribute) 561 + - [creation_time] and [last_access] set to current time from [now] 562 + 563 + Returns [Ok cookies] if all cookies parse successfully with no duplicates, 564 + or [Error msg] if any validation fails. 565 + 566 + Example: 567 + {[ 568 + of_cookie_header 569 + ~now:(fun () -> Ptime_clock.now ()) 570 + ~domain:"example.com" ~path:"/" "session=abc; theme=dark" 571 + ]} 572 + 573 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> 574 + RFC 6265 Section 4.2 - The Cookie Header. *) 575 + 576 + val cookie_header : t list -> string 577 + (** [cookie_header cookies] creates a Cookie header value from cookies. 578 + 579 + Formats a list of cookies into a Cookie header value suitable for HTTP 580 + requests per 581 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 582 + Section 4.2}. 583 + - Format: [name1=value1; name2=value2; name3=value3] 584 + - Only includes cookie names and values, not attributes 585 + - Cookies should already be filtered for the target domain/path 586 + 587 + Example: [cookie_header cookies] might return ["session=abc123; theme=dark"] 588 + 589 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> 590 + RFC 6265 Section 4.2 - The Cookie Header. *) 591 + 592 + val set_cookie_header : t -> string 593 + (** [set_cookie_header t] creates a Set-Cookie header value from a cookie. 594 + 595 + Formats a cookie into a Set-Cookie header value suitable for HTTP responses 596 + per 597 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 598 + Section 4.1}. Includes all cookie attributes: Max-Age, Expires, Domain, 599 + Path, Secure, HttpOnly, Partitioned, and SameSite. 600 + 601 + The Expires attribute uses rfc1123-date format ("Sun, 06 Nov 1994 08:49:37 602 + GMT") as specified in 603 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} Section 604 + 4.1.1}. 605 + 606 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> 607 + RFC 6265 Section 4.1 - The Set-Cookie Header. *) 608 + 609 + (** {1 Pretty Printing} *) 610 + 611 + val pp : Format.formatter -> t -> unit 612 + (** Pretty print a cookie. *)
+4
lib/core/dune
··· 1 + (library 2 + (name cookie) 3 + (public_name cookie) 4 + (libraries fmt logs ptime ipaddr domain-name publicsuffix))
+568
lib/jar/cookie_jar.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let src = Logs.Src.create "cookie_jar" ~doc:"Cookie jar management" 7 + 8 + module Log = (val Logs.src_log src : Logs.LOG) 9 + 10 + type t = { 11 + mutable original_cookies : Cookie.t list; (* from client *) 12 + mutable delta_cookies : Cookie.t list; (* to send back *) 13 + mutex : Eio.Mutex.t; 14 + } 15 + (** Cookie jar for storing and managing cookies *) 16 + 17 + (** {1 Cookie Jar Creation} *) 18 + 19 + let v () = 20 + Log.debug (fun m -> m "Creating new empty cookie jar"); 21 + { original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () } 22 + 23 + (** {1 Cookie Matching Helpers} *) 24 + 25 + (** Two cookies are considered identical if they have the same name, domain, and 26 + path. This is used when replacing or removing cookies. 27 + 28 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 29 + RFC 6265 Section 5.3 - Storage Model *) 30 + let cookie_identity_matches c1 c2 = 31 + Cookie.name c1 = Cookie.name c2 32 + && Cookie.domain c1 = Cookie.domain c2 33 + && Cookie.path c1 = Cookie.path c2 34 + 35 + (** Normalize a domain by stripping the leading dot. 36 + 37 + Per RFC 6265, the Domain attribute value is canonicalized by removing any 38 + leading dot before storage. 39 + 40 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> 41 + RFC 6265 Section 5.2.3 - The Domain Attribute *) 42 + let normalize_domain domain = 43 + match String.starts_with ~prefix:"." domain with 44 + | true when String.length domain > 1 -> 45 + String.sub domain 1 (String.length domain - 1) 46 + | _ -> domain 47 + 48 + (** Remove duplicate cookies, keeping the last occurrence. 49 + 50 + Used to deduplicate combined cookie lists where delta cookies should take 51 + precedence over original cookies. *) 52 + let dedup_by_identity cookies = 53 + let rec aux acc = function 54 + | [] -> List.rev acc 55 + | c :: rest -> 56 + let has_duplicate = 57 + List.exists (fun c2 -> cookie_identity_matches c c2) rest 58 + in 59 + if has_duplicate then aux acc rest else aux (c :: acc) rest 60 + in 61 + aux [] cookies 62 + 63 + (** Check if a string is an IP address (IPv4 or IPv6). 64 + 65 + Per RFC 6265 Section 5.1.3, domain matching should only apply to hostnames, 66 + not IP addresses. IP addresses require exact match only. 67 + 68 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> 69 + RFC 6265 Section 5.1.3 - Domain Matching *) 70 + let is_ip_address domain = Result.is_ok (Ipaddr.of_string domain) 71 + 72 + (** Check if a cookie domain matches a request domain. 73 + 74 + Per RFC 6265 Section 5.1.3, a string domain-matches a given domain string 75 + if: 76 + - The domain string and the string are identical, OR 77 + - All of the following are true: 78 + - The domain string is a suffix of the string 79 + - The last character of the string not in the domain string is "." 80 + - The string is a host name (i.e., not an IP address) 81 + 82 + Additionally, per Section 5.3 Step 6, if the cookie has the host-only-flag 83 + set, only exact matches are allowed. 84 + 85 + @param host_only If true, only exact domain match is allowed 86 + @param cookie_domain The domain stored in the cookie (without leading dot) 87 + @param request_domain The domain from the HTTP request 88 + @return true if the cookie should be sent for this domain 89 + 90 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> 91 + RFC 6265 Section 5.1.3 - Domain Matching 92 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 93 + RFC 6265 Section 5.3 - Storage Model (host-only-flag) *) 94 + let domain_matches ~host_only cookie_domain request_domain = 95 + request_domain = cookie_domain 96 + || (not (is_ip_address request_domain || host_only)) 97 + && String.ends_with ~suffix:("." ^ cookie_domain) request_domain 98 + 99 + (** Check if a cookie path matches a request path. 100 + 101 + Per RFC 6265 Section 5.1.4, a request-path path-matches a given cookie-path 102 + if: 103 + - The cookie-path and the request-path are identical, OR 104 + - The cookie-path is a prefix of the request-path, AND either: 105 + - The last character of the cookie-path is "/", OR 106 + - The first character of the request-path that is not included in the 107 + cookie-path is "/" 108 + 109 + @param cookie_path The path stored in the cookie 110 + @param request_path The path from the HTTP request 111 + @return true if the cookie should be sent for this path 112 + 113 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4> 114 + RFC 6265 Section 5.1.4 - Paths and Path-Match *) 115 + let path_matches cookie_path request_path = 116 + if cookie_path = request_path then true 117 + else if String.starts_with ~prefix:cookie_path request_path then 118 + let cookie_len = String.length cookie_path in 119 + String.ends_with ~suffix:"/" cookie_path 120 + || String.length request_path > cookie_len 121 + && request_path.[cookie_len] = '/' 122 + else false 123 + 124 + (** {1 Cookie Expiration} *) 125 + 126 + (** Check if a cookie has expired based on its expiry-time. 127 + 128 + Per RFC 6265 Section 5.3, a cookie is expired if the current date and time 129 + is past the expiry-time. Session cookies (with no Expires or Max-Age) never 130 + expire via this check - they expire when the "session" ends. 131 + 132 + @param cookie The cookie to check 133 + @param clock The Eio clock for current time 134 + @return true if the cookie has expired 135 + 136 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 137 + RFC 6265 Section 5.3 - Storage Model *) 138 + let is_expired cookie clock = 139 + match Cookie.expires cookie with 140 + | None -> false (* No expiration *) 141 + | Some `Session -> 142 + false (* Session cookie - not expired until browser closes *) 143 + | Some (`DateTime exp_time) -> 144 + let now = 145 + Ptime.of_float_s (Eio.Time.now clock) 146 + |> Option.value ~default:Ptime.epoch 147 + in 148 + Ptime.compare now exp_time > 0 149 + 150 + let pp ppf jar = 151 + let original, delta = 152 + Eio.Mutex.use_ro jar.mutex (fun () -> 153 + (jar.original_cookies, jar.delta_cookies)) 154 + in 155 + let all_cookies = original @ delta in 156 + Fmt.pf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@," 157 + (List.length all_cookies) (List.length original) (List.length delta); 158 + List.iter (fun cookie -> Fmt.pf ppf " %a@," Cookie.pp cookie) all_cookies; 159 + Fmt.pf ppf "@]" 160 + 161 + (** {1 Cookie Management} *) 162 + 163 + (** Preserve creation time from an existing cookie when replacing. 164 + 165 + Per RFC 6265 Section 5.3, Step 11.3: "If the newly created cookie was 166 + received from a 'non-HTTP' API and the old-cookie's http-only-flag is true, 167 + abort these steps and ignore the newly created cookie entirely." Step 11.3 168 + also states: "Update the creation-time of the old-cookie to match the 169 + creation-time of the newly created cookie." 170 + 171 + However, the common interpretation (and browser behavior) is to preserve the 172 + original creation-time when updating a cookie. This matches what Step 3 of 173 + Section 5.4 uses for ordering (creation-time stability). 174 + 175 + @param old_cookie The existing cookie being replaced (if any) 176 + @param new_cookie The new cookie to add 177 + @return 178 + The new cookie with creation_time preserved from old_cookie if present 179 + 180 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 181 + RFC 6265 Section 5.3 - Storage Model *) 182 + let preserve_creation_time old_cookie_opt new_cookie = 183 + match old_cookie_opt with 184 + | None -> new_cookie 185 + | Some old_cookie -> 186 + Cookie.v ~domain:(Cookie.domain new_cookie) ~path:(Cookie.path new_cookie) 187 + ~name:(Cookie.name new_cookie) ~value:(Cookie.value new_cookie) 188 + ~secure:(Cookie.secure new_cookie) 189 + ~http_only:(Cookie.http_only new_cookie) 190 + ?expires:(Cookie.expires new_cookie) 191 + ?max_age:(Cookie.max_age new_cookie) 192 + ?same_site:(Cookie.same_site new_cookie) 193 + ~partitioned:(Cookie.partitioned new_cookie) 194 + ~host_only:(Cookie.host_only new_cookie) 195 + ~creation_time:(Cookie.creation_time old_cookie) 196 + ~last_access:(Cookie.last_access new_cookie) 197 + () 198 + 199 + let add_cookie jar cookie = 200 + Log.debug (fun m -> 201 + m "Adding cookie to delta: %s=<redacted> for domain %s" 202 + (Cookie.name cookie) (Cookie.domain cookie)); 203 + 204 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 205 + (* Find existing cookie with same identity to preserve creation_time 206 + per RFC 6265 Section 5.3, Step 11.3 *) 207 + let existing = 208 + List.find_opt 209 + (fun c -> cookie_identity_matches c cookie) 210 + jar.delta_cookies 211 + in 212 + let existing = 213 + match existing with 214 + | Some _ -> existing 215 + | None -> 216 + (* Also check original cookies for creation time preservation *) 217 + List.find_opt 218 + (fun c -> cookie_identity_matches c cookie) 219 + jar.original_cookies 220 + in 221 + 222 + let cookie = preserve_creation_time existing cookie in 223 + 224 + (* Remove existing cookie with same identity from delta *) 225 + jar.delta_cookies <- 226 + List.filter 227 + (fun c -> not (cookie_identity_matches c cookie)) 228 + jar.delta_cookies; 229 + jar.delta_cookies <- cookie :: jar.delta_cookies) 230 + 231 + let add_original jar cookie = 232 + Log.debug (fun m -> 233 + m "Adding original cookie: %s=<redacted> for domain %s" 234 + (Cookie.name cookie) (Cookie.domain cookie)); 235 + 236 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 237 + (* Find existing cookie with same identity to preserve creation_time 238 + per RFC 6265 Section 5.3, Step 11.3 *) 239 + let existing = 240 + List.find_opt 241 + (fun c -> cookie_identity_matches c cookie) 242 + jar.original_cookies 243 + in 244 + 245 + let cookie = preserve_creation_time existing cookie in 246 + 247 + (* Remove existing cookie with same identity from original *) 248 + jar.original_cookies <- 249 + List.filter 250 + (fun c -> not (cookie_identity_matches c cookie)) 251 + jar.original_cookies; 252 + jar.original_cookies <- cookie :: jar.original_cookies) 253 + 254 + let delta jar = 255 + let result = Eio.Mutex.use_ro jar.mutex (fun () -> jar.delta_cookies) in 256 + Log.debug (fun m -> m "Returning %d delta cookies" (List.length result)); 257 + result 258 + 259 + (** Create a removal cookie for deleting a cookie from the client. 260 + 261 + Per RFC 6265 Section 5.3, to remove a cookie, the server sends a Set-Cookie 262 + header with an expiry date in the past. We also set Max-Age=0 and an empty 263 + value for maximum compatibility. 264 + 265 + @param cookie The cookie to create a removal for 266 + @param clock The Eio clock for timestamps 267 + @return A new cookie configured to cause deletion 268 + 269 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 270 + RFC 6265 Section 5.3 - Storage Model *) 271 + let removal_cookie cookie ~clock = 272 + let now = 273 + Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 274 + in 275 + (* Create a cookie with Max-Age=0 and past expiration (1 year ago) *) 276 + let past_expiry = 277 + Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60)) 278 + |> Option.value ~default:Ptime.epoch 279 + in 280 + Cookie.v ~domain:(Cookie.domain cookie) ~path:(Cookie.path cookie) 281 + ~name:(Cookie.name cookie) ~value:"" ~secure:(Cookie.secure cookie) 282 + ~http_only:(Cookie.http_only cookie) ~expires:(`DateTime past_expiry) 283 + ~max_age:(Ptime.Span.of_int_s 0) ?same_site:(Cookie.same_site cookie) 284 + ~partitioned:(Cookie.partitioned cookie) 285 + ~host_only:(Cookie.host_only cookie) ~creation_time:now ~last_access:now () 286 + 287 + let remove jar ~clock cookie = 288 + Log.debug (fun m -> 289 + m "Removing cookie: %s=%s for domain %s" (Cookie.name cookie) 290 + (Cookie.value cookie) (Cookie.domain cookie)); 291 + 292 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 293 + (* Check if this cookie exists in original_cookies *) 294 + let in_original = 295 + List.exists 296 + (fun c -> cookie_identity_matches c cookie) 297 + jar.original_cookies 298 + in 299 + 300 + if in_original then ( 301 + (* Create a removal cookie and add it to delta *) 302 + let removal = removal_cookie cookie ~clock in 303 + jar.delta_cookies <- 304 + List.filter 305 + (fun c -> not (cookie_identity_matches c removal)) 306 + jar.delta_cookies; 307 + jar.delta_cookies <- removal :: jar.delta_cookies; 308 + Log.debug (fun m -> 309 + m "Created removal cookie in delta for original cookie")) 310 + else ( 311 + (* Just remove from delta if it exists there *) 312 + jar.delta_cookies <- 313 + List.filter 314 + (fun c -> not (cookie_identity_matches c cookie)) 315 + jar.delta_cookies; 316 + Log.debug (fun m -> m "Removed cookie from delta"))) 317 + 318 + (** Compare cookies for ordering per RFC 6265 Section 5.4, Step 2. 319 + 320 + Cookies SHOULD be sorted: 1. Cookies with longer paths listed first 2. Among 321 + equal-length paths, cookies with earlier creation-times first 322 + 323 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> 324 + RFC 6265 Section 5.4 - The Cookie Header *) 325 + let compare_cookie_order c1 c2 = 326 + let path1_len = String.length (Cookie.path c1) in 327 + let path2_len = String.length (Cookie.path c2) in 328 + (* Longer paths first (descending order) *) 329 + match Int.compare path2_len path1_len with 330 + | 0 -> 331 + (* Equal path lengths: earlier creation time first (ascending order) *) 332 + Ptime.compare (Cookie.creation_time c1) (Cookie.creation_time c2) 333 + | n -> n 334 + 335 + (** Retrieve cookies that should be sent for a given request. 336 + 337 + Per RFC 6265 Section 5.4, the user agent should include a Cookie header 338 + containing cookies that match the request-uri's domain, path, and security 339 + context. This function also updates the last-access-time for matched 340 + cookies. 341 + 342 + Cookies are sorted per Section 5.4, Step 2: 1. Cookies with longer paths 343 + listed first 2. Among equal-length paths, earlier creation-times listed 344 + first 345 + 346 + @param jar The cookie jar to search 347 + @param clock The Eio clock for timestamp updates 348 + @param domain The request domain (hostname or IP address) 349 + @param path The request path 350 + @param is_secure Whether the request is over a secure channel (HTTPS) 351 + @return List of cookies that should be included in the Cookie header, sorted 352 + 353 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> 354 + RFC 6265 Section 5.4 - The Cookie Header *) 355 + let touch_accessed ~applicable ~now cookies = 356 + List.map 357 + (fun c -> 358 + if List.exists (fun a -> cookie_identity_matches a c) applicable then 359 + Cookie.v ~domain:(Cookie.domain c) ~path:(Cookie.path c) 360 + ~name:(Cookie.name c) ~value:(Cookie.value c) 361 + ~secure:(Cookie.secure c) ~http_only:(Cookie.http_only c) 362 + ?expires:(Cookie.expires c) ?max_age:(Cookie.max_age c) 363 + ?same_site:(Cookie.same_site c) ~partitioned:(Cookie.partitioned c) 364 + ~host_only:(Cookie.host_only c) 365 + ~creation_time:(Cookie.creation_time c) ~last_access:now () 366 + else c) 367 + cookies 368 + 369 + let cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure = 370 + Log.debug (fun m -> 371 + m "Getting cookies for domain=%s path=%s secure=%b" request_domain 372 + request_path is_secure); 373 + 374 + let sorted = 375 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 376 + let all_cookies = jar.original_cookies @ jar.delta_cookies in 377 + let unique_cookies = dedup_by_identity all_cookies in 378 + 379 + let applicable = 380 + List.filter 381 + (fun cookie -> 382 + Cookie.value cookie <> "" 383 + && (not (is_expired cookie clock)) 384 + && domain_matches ~host_only:(Cookie.host_only cookie) 385 + (Cookie.domain cookie) request_domain 386 + && path_matches (Cookie.path cookie) request_path 387 + && ((not (Cookie.secure cookie)) || is_secure)) 388 + unique_cookies 389 + in 390 + 391 + let sorted = List.sort compare_cookie_order applicable in 392 + 393 + let now = 394 + Ptime.of_float_s (Eio.Time.now clock) 395 + |> Option.value ~default:Ptime.epoch 396 + in 397 + jar.original_cookies <- 398 + touch_accessed ~applicable ~now jar.original_cookies; 399 + jar.delta_cookies <- touch_accessed ~applicable ~now jar.delta_cookies; 400 + sorted) 401 + in 402 + 403 + Log.debug (fun m -> m "Found %d applicable cookies" (List.length sorted)); 404 + sorted 405 + 406 + let clear jar = 407 + Log.info (fun m -> m "Clearing all cookies"); 408 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 409 + jar.original_cookies <- []; 410 + jar.delta_cookies <- []) 411 + 412 + let clear_expired jar ~clock = 413 + let removed = 414 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 415 + let before_count = 416 + List.length jar.original_cookies + List.length jar.delta_cookies 417 + in 418 + jar.original_cookies <- 419 + List.filter (fun c -> not (is_expired c clock)) jar.original_cookies; 420 + jar.delta_cookies <- 421 + List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies; 422 + before_count 423 + - (List.length jar.original_cookies + List.length jar.delta_cookies)) 424 + in 425 + Log.info (fun m -> m "Cleared %d expired cookies" removed) 426 + 427 + let clear_session_cookies jar = 428 + let removed = 429 + Eio.Mutex.use_rw ~protect:true jar.mutex (fun () -> 430 + let before_count = 431 + List.length jar.original_cookies + List.length jar.delta_cookies 432 + in 433 + (* Keep only cookies that are NOT session cookies *) 434 + let is_not_session c = 435 + match Cookie.expires c with 436 + | Some `Session -> false (* This is a session cookie, remove it *) 437 + | None | Some (`DateTime _) -> true (* Keep these *) 438 + in 439 + jar.original_cookies <- List.filter is_not_session jar.original_cookies; 440 + jar.delta_cookies <- List.filter is_not_session jar.delta_cookies; 441 + before_count 442 + - (List.length jar.original_cookies + List.length jar.delta_cookies)) 443 + in 444 + Log.info (fun m -> m "Cleared %d session cookies" removed) 445 + 446 + let count jar = 447 + Eio.Mutex.use_ro jar.mutex (fun () -> 448 + let all_cookies = jar.original_cookies @ jar.delta_cookies in 449 + let unique = dedup_by_identity all_cookies in 450 + List.length unique) 451 + 452 + let all_cookies jar = 453 + Eio.Mutex.use_ro jar.mutex (fun () -> 454 + let all_cookies = jar.original_cookies @ jar.delta_cookies in 455 + dedup_by_identity all_cookies) 456 + 457 + let is_empty jar = 458 + Eio.Mutex.use_ro jar.mutex (fun () -> 459 + jar.original_cookies = [] && jar.delta_cookies = []) 460 + 461 + (** {1 Mozilla Format} *) 462 + 463 + let to_mozilla_format_internal jar = 464 + let buffer = Buffer.create 1024 in 465 + Buffer.add_string buffer "# Netscape HTTP Cookie File\n"; 466 + Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n"; 467 + 468 + (* Combine and deduplicate cookies *) 469 + let all_cookies = jar.original_cookies @ jar.delta_cookies in 470 + let unique = dedup_by_identity all_cookies in 471 + 472 + List.iter 473 + (fun cookie -> 474 + (* Mozilla format: include_subdomains=TRUE means host_only=false *) 475 + let include_subdomains = 476 + if Cookie.host_only cookie then "FALSE" else "TRUE" 477 + in 478 + let secure_flag = if Cookie.secure cookie then "TRUE" else "FALSE" in 479 + let expires_str = 480 + match Cookie.expires cookie with 481 + | None -> "0" (* No expiration *) 482 + | Some `Session -> "0" (* Session cookie *) 483 + | Some (`DateTime t) -> 484 + let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in 485 + epoch 486 + in 487 + 488 + Buffer.add_string buffer 489 + (Fmt.str "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (Cookie.domain cookie) 490 + include_subdomains (Cookie.path cookie) secure_flag expires_str 491 + (Cookie.name cookie) (Cookie.value cookie))) 492 + unique; 493 + 494 + Buffer.contents buffer 495 + 496 + let to_mozilla_format jar = 497 + Eio.Mutex.use_ro jar.mutex (fun () -> to_mozilla_format_internal jar) 498 + 499 + let from_mozilla_format ~clock content = 500 + Log.debug (fun m -> m "Parsing Mozilla format cookies"); 501 + let jar = v () in 502 + 503 + let lines = String.split_on_char '\n' content in 504 + List.iter 505 + (fun line -> 506 + let line = String.trim line in 507 + if line <> "" && not (String.starts_with ~prefix:"#" line) then 508 + match String.split_on_char '\t' line with 509 + | [ domain; include_subdomains; path; secure; expires; name; value ] -> 510 + let now = 511 + Ptime.of_float_s (Eio.Time.now clock) 512 + |> Option.value ~default:Ptime.epoch 513 + in 514 + let expires = 515 + match int_of_string_opt expires with 516 + | Some exp_int when exp_int <> 0 -> 517 + Option.map 518 + (fun t -> `DateTime t) 519 + (Ptime.of_float_s (float_of_int exp_int)) 520 + | _ -> None 521 + in 522 + (* Mozilla format: include_subdomains=TRUE means host_only=false *) 523 + let host_only = include_subdomains <> "TRUE" in 524 + 525 + let cookie = 526 + Cookie.v ~domain:(normalize_domain domain) ~path ~name ~value 527 + ~secure:(secure = "TRUE") ~http_only:false ?expires 528 + ?max_age:None ?same_site:None ~partitioned:false ~host_only 529 + ~creation_time:now ~last_access:now () 530 + in 531 + add_original jar cookie; 532 + Log.debug (fun m -> m "Loaded cookie: %s=<redacted>" name) 533 + | _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line)) 534 + lines; 535 + 536 + Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies)); 537 + jar 538 + 539 + (** {1 File Operations} *) 540 + 541 + let load ~clock path = 542 + Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path); 543 + 544 + try 545 + let content = Eio.Path.load path in 546 + from_mozilla_format ~clock content 547 + with 548 + | Eio.Io _ -> 549 + Log.info (fun m -> m "Cookie file not found, creating empty jar"); 550 + v () 551 + | exn -> 552 + Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn)); 553 + v () 554 + 555 + let save path jar = 556 + let total_cookies = 557 + Eio.Mutex.use_ro jar.mutex (fun () -> 558 + List.length jar.original_cookies + List.length jar.delta_cookies) 559 + in 560 + Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path); 561 + 562 + let content = to_mozilla_format jar in 563 + 564 + try 565 + Eio.Path.save ~create:(`Or_truncate 0o600) path content; 566 + Log.debug (fun m -> m "Cookies saved successfully") 567 + with exn -> 568 + Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+253
lib/jar/cookie_jar.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Cookie jar for storing and managing HTTP cookies. 7 + 8 + This module provides a complete cookie jar implementation following 9 + {{:https://datatracker.ietf.org/doc/html/rfc6265} RFC 6265} while 10 + integrating Eio for efficient asynchronous operations. 11 + 12 + A cookie jar maintains a collection of cookies with automatic cleanup of 13 + expired entries. It implements the standard browser behavior for cookie 14 + storage, including: 15 + - Automatic removal of expired cookies 16 + - Domain and path-based cookie retrieval per 17 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4} Section 5.4} 18 + - Delta tracking for Set-Cookie headers 19 + - Mozilla format persistence for cross-tool compatibility 20 + 21 + @see <https://datatracker.ietf.org/doc/html/rfc6265> 22 + RFC 6265 - HTTP State Management Mechanism 23 + 24 + {2 Standards and References} 25 + 26 + This cookie jar implements the storage model from: 27 + 28 + - {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3}RFC 6265 29 + Section 5.3} - Storage Model - Cookie insertion, replacement, and 30 + expiration 31 + - {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4}RFC 6265 32 + Section 5.4} - The Cookie Header - Cookie retrieval and ordering 33 + 34 + Key RFC 6265 requirements implemented: 35 + - Domain matching per 36 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3}Section 37 + 5.1.3} 38 + - Path matching per 39 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4}Section 40 + 5.1.4} 41 + - Cookie ordering per 42 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4}Section 5.4 43 + Step 2} 44 + - Creation time preservation per 45 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3}Section 5.3 46 + Step 11.3} 47 + 48 + {2 Related Libraries} 49 + 50 + - {!Cookie} - HTTP cookie parsing, validation, and serialization 51 + - [Requests] - HTTP client that uses this jar for cookie persistence 52 + - [Xdge] - XDG Base Directory support for cookie file paths *) 53 + 54 + type t 55 + (** Cookie jar for storing and managing cookies. 56 + 57 + A cookie jar maintains a collection of cookies with automatic cleanup of 58 + expired entries and enforcement of storage limits. It implements the 59 + standard browser behavior for cookie storage per 60 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 61 + Section 5.3}. *) 62 + 63 + (** {1 Cookie Jar Creation and Loading} *) 64 + 65 + val v : unit -> t 66 + (** Create an empty cookie jar. *) 67 + 68 + val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t 69 + (** Load cookies from Mozilla format file. 70 + 71 + Loads cookies from a file in Mozilla format, using the provided clock to set 72 + creation and last access times. Returns an empty jar if the file doesn't 73 + exist or cannot be loaded. *) 74 + 75 + val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit 76 + (** Save cookies to Mozilla format file. *) 77 + 78 + (** {1 Cookie Jar Management} *) 79 + 80 + val add_cookie : t -> Cookie.t -> unit 81 + (** [add_cookie t cookie] adds a cookie to the jar. 82 + 83 + The cookie is added to the delta, meaning it will appear in Set-Cookie 84 + headers when calling {!delta}. If a cookie with the same name/domain/path 85 + exists, it will be replaced per 86 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 87 + Section 5.3}. 88 + 89 + Per Section 5.3, Step 11.3, when replacing an existing cookie, the original 90 + creation-time is preserved. This ensures stable cookie ordering per Section 91 + 5.4, Step 2. 92 + 93 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 94 + RFC 6265 Section 5.3 - Storage Model. *) 95 + 96 + val add_original : t -> Cookie.t -> unit 97 + (** [add_original t cookie] adds an original cookie to the jar. 98 + 99 + Original cookies are those received from the client (via Cookie header). 100 + They do not appear in the delta. This method should be used when loading 101 + cookies from incoming HTTP requests. 102 + 103 + Per Section 5.3, Step 11.3, when replacing an existing cookie, the original 104 + creation-time is preserved. 105 + 106 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 107 + RFC 6265 Section 5.3 - Storage Model. *) 108 + 109 + val delta : t -> Cookie.t list 110 + (** [delta t] returns cookies that need to be sent in Set-Cookie headers. 111 + 112 + Returns cookies that have been added via {!add_cookie} and removal cookies 113 + for original cookies that have been removed. Does not include original 114 + cookies that were added via {!add_original}. 115 + 116 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> 117 + RFC 6265 Section 4.1 - Set-Cookie. *) 118 + 119 + val remove : t -> clock:_ Eio.Time.clock -> Cookie.t -> unit 120 + (** [remove t ~clock cookie] removes a cookie from the jar. 121 + 122 + If an original cookie with the same name/domain/path exists, creates a 123 + removal cookie (empty value, Max-Age=0, past expiration) that appears in the 124 + delta. If only a delta cookie exists, simply removes it from the delta. 125 + 126 + Per 127 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 128 + Section 5.3}, cookies are removed by sending a Set-Cookie with an expiry 129 + date in the past. 130 + 131 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 132 + RFC 6265 Section 5.3 - Storage Model. *) 133 + 134 + val cookies : 135 + t -> 136 + clock:_ Eio.Time.clock -> 137 + domain:string -> 138 + path:string -> 139 + is_secure:bool -> 140 + Cookie.t list 141 + (** [cookies t ~clock ~domain ~path ~is_secure] returns cookies applicable for a 142 + URL. 143 + 144 + Implements the cookie retrieval algorithm from 145 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4}RFC 6265 146 + Section 5.4} for generating the Cookie header. 147 + 148 + {3 Algorithm} 149 + 150 + Per RFC 6265 Section 5.4, the user agent should: 1. Filter cookies by domain 151 + matching (Section 5.1.3) 2. Filter cookies by path matching (Section 5.1.4) 152 + 3. Filter out cookies with Secure attribute when request is non-secure 4. 153 + Filter out expired cookies 5. Sort remaining cookies (longer paths first, 154 + then by creation time) 6. Update last-access-time for retrieved cookies 155 + 156 + This function implements all these steps, combining original and delta 157 + cookies with delta taking precedence. Excludes: 158 + - Removal cookies (empty value) 159 + - Expired cookies (expiry-time in the past per Section 5.3) 160 + - Secure cookies when [is_secure = false] 161 + 162 + {3 Cookie Ordering} 163 + 164 + Cookies are sorted per Section 5.4, Step 2: 165 + - Cookies with longer paths are listed before cookies with shorter paths 166 + - Among cookies with equal-length paths, cookies with earlier creation-times 167 + are listed first 168 + 169 + This ordering ensures more specific cookies take precedence. 170 + 171 + {3 Matching Rules} 172 + 173 + Domain matching follows 174 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 175 + 5.1.3}: 176 + - IP addresses require exact match only 177 + - Hostnames support subdomain matching unless host-only flag is set 178 + 179 + Path matching follows 180 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 181 + 5.1.4}. 182 + 183 + @param t Cookie jar 184 + @param clock Clock for updating last-access-time 185 + @param domain Request domain 186 + @param path Request path 187 + @param is_secure Whether the request is over a secure channel (HTTPS) 188 + @return List of matching cookies, sorted per RFC 6265 189 + 190 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> 191 + RFC 6265 Section 5.3 - Storage Model (expiry) 192 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> 193 + RFC 6265 Section 5.4 - The Cookie Header. *) 194 + 195 + val clear : t -> unit 196 + (** Clear all cookies. *) 197 + 198 + val clear_expired : t -> clock:_ Eio.Time.clock -> unit 199 + (** Clear expired cookies. 200 + 201 + Removes cookies whose expiry-time is in the past per 202 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 203 + Section 5.3}. *) 204 + 205 + val clear_session_cookies : t -> unit 206 + (** Clear session cookies. 207 + 208 + Removes cookies that have no Expires or Max-Age attribute (session cookies). 209 + Per 210 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 211 + Section 5.3}, these cookies are normally removed when the user agent 212 + "session" ends. *) 213 + 214 + val count : t -> int 215 + (** Get the number of unique cookies in the jar. *) 216 + 217 + val all_cookies : t -> Cookie.t list 218 + (** Get all cookies in the jar. 219 + 220 + Returns all cookies including expired ones (for inspection/debugging). Use 221 + {!cookies} with appropriate domain/path for filtered results that exclude 222 + expired cookies, or call {!clear_expired} first. *) 223 + 224 + val is_empty : t -> bool 225 + (** Check if the jar is empty. *) 226 + 227 + (** {1 Pretty Printing} *) 228 + 229 + val pp : Format.formatter -> t -> unit 230 + (** Pretty print a cookie jar. *) 231 + 232 + (** {1 Mozilla Format} *) 233 + 234 + val to_mozilla_format : t -> string 235 + (** [to_mozilla_format t] serializes cookies in Mozilla/Netscape cookie format. 236 + 237 + The Mozilla format uses tab-separated fields: 238 + {[ 239 + domain \t include_subdomains \t path \t secure \t expires \t name \t value 240 + ]} 241 + 242 + The [include_subdomains] field corresponds to the inverse of the 243 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} 244 + host-only-flag} in RFC 6265. *) 245 + 246 + val from_mozilla_format : clock:_ Eio.Time.clock -> string -> t 247 + (** [from_mozilla_format ~clock s] parses Mozilla format cookies. 248 + 249 + Creates a cookie jar from a string in Mozilla cookie format, using the 250 + provided clock to set creation and last access times. The 251 + [include_subdomains] field is mapped to the host-only-flag per 252 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 253 + Section 5.3}. *)
+4
lib/jar/dune
··· 1 + (library 2 + (name cookie_jar) 3 + (public_name cookie.jar) 4 + (libraries cookie eio fmt logs ptime unix ipaddr))
+2075
spec/rfc6265.txt
··· 1 + 2 + 3 + 4 + 5 + 6 + 7 + Internet Engineering Task Force (IETF) A. Barth 8 + Request for Comments: 6265 U.C. Berkeley 9 + Obsoletes: 2965 April 2011 10 + Category: Standards Track 11 + ISSN: 2070-1721 12 + 13 + 14 + HTTP State Management Mechanism 15 + 16 + Abstract 17 + 18 + This document defines the HTTP Cookie and Set-Cookie header fields. 19 + These header fields can be used by HTTP servers to store state 20 + (called cookies) at HTTP user agents, letting the servers maintain a 21 + stateful session over the mostly stateless HTTP protocol. Although 22 + cookies have many historical infelicities that degrade their security 23 + and privacy, the Cookie and Set-Cookie header fields are widely used 24 + on the Internet. This document obsoletes RFC 2965. 25 + 26 + Status of This Memo 27 + 28 + This is an Internet Standards Track document. 29 + 30 + This document is a product of the Internet Engineering Task Force 31 + (IETF). It represents the consensus of the IETF community. It has 32 + received public review and has been approved for publication by the 33 + Internet Engineering Steering Group (IESG). Further information on 34 + Internet Standards is available in Section 2 of RFC 5741. 35 + 36 + Information about the current status of this document, any errata, 37 + and how to provide feedback on it may be obtained at 38 + http://www.rfc-editor.org/info/rfc6265. 39 + 40 + Copyright Notice 41 + 42 + Copyright (c) 2011 IETF Trust and the persons identified as the 43 + document authors. All rights reserved. 44 + 45 + This document is subject to BCP 78 and the IETF Trust's Legal 46 + Provisions Relating to IETF Documents 47 + (http://trustee.ietf.org/license-info) in effect on the date of 48 + publication of this document. Please review these documents 49 + carefully, as they describe your rights and restrictions with respect 50 + to this document. Code Components extracted from this document must 51 + include Simplified BSD License text as described in Section 4.e of 52 + the Trust Legal Provisions and are provided without warranty as 53 + described in the Simplified BSD License. 54 + 55 + 56 + 57 + 58 + Barth Standards Track [Page 1] 59 + 60 + RFC 6265 HTTP State Management Mechanism April 2011 61 + 62 + 63 + This document may contain material from IETF Documents or IETF 64 + Contributions published or made publicly available before November 65 + 10, 2008. The person(s) controlling the copyright in some of this 66 + material may not have granted the IETF Trust the right to allow 67 + modifications of such material outside the IETF Standards Process. 68 + Without obtaining an adequate license from the person(s) controlling 69 + the copyright in such materials, this document may not be modified 70 + outside the IETF Standards Process, and derivative works of it may 71 + not be created outside the IETF Standards Process, except to format 72 + it for publication as an RFC or to translate it into languages other 73 + than English. 74 + 75 + Table of Contents 76 + 77 + 1. Introduction ....................................................3 78 + 2. Conventions .....................................................4 79 + 2.1. Conformance Criteria .......................................4 80 + 2.2. Syntax Notation ............................................5 81 + 2.3. Terminology ................................................5 82 + 3. Overview ........................................................6 83 + 3.1. Examples ...................................................6 84 + 4. Server Requirements .............................................8 85 + 4.1. Set-Cookie .................................................8 86 + 4.1.1. Syntax ..............................................8 87 + 4.1.2. Semantics (Non-Normative) ..........................10 88 + 4.2. Cookie ....................................................13 89 + 4.2.1. Syntax .............................................13 90 + 4.2.2. Semantics ..........................................13 91 + 5. User Agent Requirements ........................................14 92 + 5.1. Subcomponent Algorithms ...................................14 93 + 5.1.1. Dates ..............................................14 94 + 5.1.2. Canonicalized Host Names ...........................16 95 + 5.1.3. Domain Matching ....................................16 96 + 5.1.4. Paths and Path-Match ...............................16 97 + 5.2. The Set-Cookie Header .....................................17 98 + 5.2.1. The Expires Attribute ..............................19 99 + 5.2.2. The Max-Age Attribute ..............................20 100 + 5.2.3. The Domain Attribute ...............................20 101 + 5.2.4. The Path Attribute .................................21 102 + 5.2.5. The Secure Attribute ...............................21 103 + 5.2.6. The HttpOnly Attribute .............................21 104 + 5.3. Storage Model .............................................21 105 + 5.4. The Cookie Header .........................................25 106 + 6. Implementation Considerations ..................................27 107 + 6.1. Limits ....................................................27 108 + 6.2. Application Programming Interfaces ........................27 109 + 6.3. IDNA Dependency and Migration .............................27 110 + 7. Privacy Considerations .........................................28 111 + 112 + 113 + 114 + Barth Standards Track [Page 2] 115 + 116 + RFC 6265 HTTP State Management Mechanism April 2011 117 + 118 + 119 + 7.1. Third-Party Cookies .......................................28 120 + 7.2. User Controls .............................................28 121 + 7.3. Expiration Dates ..........................................29 122 + 8. Security Considerations ........................................29 123 + 8.1. Overview ..................................................29 124 + 8.2. Ambient Authority .........................................30 125 + 8.3. Clear Text ................................................30 126 + 8.4. Session Identifiers .......................................31 127 + 8.5. Weak Confidentiality ......................................32 128 + 8.6. Weak Integrity ............................................32 129 + 8.7. Reliance on DNS ...........................................33 130 + 9. IANA Considerations ............................................33 131 + 9.1. Cookie ....................................................34 132 + 9.2. Set-Cookie ................................................34 133 + 9.3. Cookie2 ...................................................34 134 + 9.4. Set-Cookie2 ...............................................34 135 + 10. References ....................................................35 136 + 10.1. Normative References .....................................35 137 + 10.2. Informative References ...................................35 138 + Appendix A. Acknowledgements ......................................37 139 + 140 + 1. Introduction 141 + 142 + This document defines the HTTP Cookie and Set-Cookie header fields. 143 + Using the Set-Cookie header field, an HTTP server can pass name/value 144 + pairs and associated metadata (called cookies) to a user agent. When 145 + the user agent makes subsequent requests to the server, the user 146 + agent uses the metadata and other information to determine whether to 147 + return the name/value pairs in the Cookie header. 148 + 149 + Although simple on their surface, cookies have a number of 150 + complexities. For example, the server indicates a scope for each 151 + cookie when sending it to the user agent. The scope indicates the 152 + maximum amount of time in which the user agent should return the 153 + cookie, the servers to which the user agent should return the cookie, 154 + and the URI schemes for which the cookie is applicable. 155 + 156 + For historical reasons, cookies contain a number of security and 157 + privacy infelicities. For example, a server can indicate that a 158 + given cookie is intended for "secure" connections, but the Secure 159 + attribute does not provide integrity in the presence of an active 160 + network attacker. Similarly, cookies for a given host are shared 161 + across all the ports on that host, even though the usual "same-origin 162 + policy" used by web browsers isolates content retrieved via different 163 + ports. 164 + 165 + There are two audiences for this specification: developers of cookie- 166 + generating servers and developers of cookie-consuming user agents. 167 + 168 + 169 + 170 + Barth Standards Track [Page 3] 171 + 172 + RFC 6265 HTTP State Management Mechanism April 2011 173 + 174 + 175 + To maximize interoperability with user agents, servers SHOULD limit 176 + themselves to the well-behaved profile defined in Section 4 when 177 + generating cookies. 178 + 179 + User agents MUST implement the more liberal processing rules defined 180 + in Section 5, in order to maximize interoperability with existing 181 + servers that do not conform to the well-behaved profile defined in 182 + Section 4. 183 + 184 + This document specifies the syntax and semantics of these headers as 185 + they are actually used on the Internet. In particular, this document 186 + does not create new syntax or semantics beyond those in use today. 187 + The recommendations for cookie generation provided in Section 4 188 + represent a preferred subset of current server behavior, and even the 189 + more liberal cookie processing algorithm provided in Section 5 does 190 + not recommend all of the syntactic and semantic variations in use 191 + today. Where some existing software differs from the recommended 192 + protocol in significant ways, the document contains a note explaining 193 + the difference. 194 + 195 + Prior to this document, there were at least three descriptions of 196 + cookies: the so-called "Netscape cookie specification" [Netscape], 197 + RFC 2109 [RFC2109], and RFC 2965 [RFC2965]. However, none of these 198 + documents describe how the Cookie and Set-Cookie headers are actually 199 + used on the Internet (see [Kri2001] for historical context). In 200 + relation to previous IETF specifications of HTTP state management 201 + mechanisms, this document requests the following actions: 202 + 203 + 1. Change the status of [RFC2109] to Historic (it has already been 204 + obsoleted by [RFC2965]). 205 + 206 + 2. Change the status of [RFC2965] to Historic. 207 + 208 + 3. Indicate that [RFC2965] has been obsoleted by this document. 209 + 210 + In particular, in moving RFC 2965 to Historic and obsoleting it, this 211 + document deprecates the use of the Cookie2 and Set-Cookie2 header 212 + fields. 213 + 214 + 2. Conventions 215 + 216 + 2.1. Conformance Criteria 217 + 218 + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", 219 + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this 220 + document are to be interpreted as described in [RFC2119]. 221 + 222 + 223 + 224 + 225 + 226 + Barth Standards Track [Page 4] 227 + 228 + RFC 6265 HTTP State Management Mechanism April 2011 229 + 230 + 231 + Requirements phrased in the imperative as part of algorithms (such as 232 + "strip any leading space characters" or "return false and abort these 233 + steps") are to be interpreted with the meaning of the key word 234 + ("MUST", "SHOULD", "MAY", etc.) used in introducing the algorithm. 235 + 236 + Conformance requirements phrased as algorithms or specific steps can 237 + be implemented in any manner, so long as the end result is 238 + equivalent. In particular, the algorithms defined in this 239 + specification are intended to be easy to understand and are not 240 + intended to be performant. 241 + 242 + 2.2. Syntax Notation 243 + 244 + This specification uses the Augmented Backus-Naur Form (ABNF) 245 + notation of [RFC5234]. 246 + 247 + The following core rules are included by reference, as defined in 248 + [RFC5234], Appendix B.1: ALPHA (letters), CR (carriage return), CRLF 249 + (CR LF), CTLs (controls), DIGIT (decimal 0-9), DQUOTE (double quote), 250 + HEXDIG (hexadecimal 0-9/A-F/a-f), LF (line feed), NUL (null octet), 251 + OCTET (any 8-bit sequence of data except NUL), SP (space), HTAB 252 + (horizontal tab), CHAR (any [USASCII] character), VCHAR (any visible 253 + [USASCII] character), and WSP (whitespace). 254 + 255 + The OWS (optional whitespace) rule is used where zero or more linear 256 + whitespace characters MAY appear: 257 + 258 + OWS = *( [ obs-fold ] WSP ) 259 + ; "optional" whitespace 260 + obs-fold = CRLF 261 + 262 + OWS SHOULD either not be produced or be produced as a single SP 263 + character. 264 + 265 + 2.3. Terminology 266 + 267 + The terms user agent, client, server, proxy, and origin server have 268 + the same meaning as in the HTTP/1.1 specification ([RFC2616], Section 269 + 1.3). 270 + 271 + The request-host is the name of the host, as known by the user agent, 272 + to which the user agent is sending an HTTP request or from which it 273 + is receiving an HTTP response (i.e., the name of the host to which it 274 + sent the corresponding HTTP request). 275 + 276 + The term request-uri is defined in Section 5.1.2 of [RFC2616]. 277 + 278 + 279 + 280 + 281 + 282 + Barth Standards Track [Page 5] 283 + 284 + RFC 6265 HTTP State Management Mechanism April 2011 285 + 286 + 287 + Two sequences of octets are said to case-insensitively match each 288 + other if and only if they are equivalent under the i;ascii-casemap 289 + collation defined in [RFC4790]. 290 + 291 + The term string means a sequence of non-NUL octets. 292 + 293 + 3. Overview 294 + 295 + This section outlines a way for an origin server to send state 296 + information to a user agent and for the user agent to return the 297 + state information to the origin server. 298 + 299 + To store state, the origin server includes a Set-Cookie header in an 300 + HTTP response. In subsequent requests, the user agent returns a 301 + Cookie request header to the origin server. The Cookie header 302 + contains cookies the user agent received in previous Set-Cookie 303 + headers. The origin server is free to ignore the Cookie header or 304 + use its contents for an application-defined purpose. 305 + 306 + Origin servers MAY send a Set-Cookie response header with any 307 + response. User agents MAY ignore Set-Cookie headers contained in 308 + responses with 100-level status codes but MUST process Set-Cookie 309 + headers contained in other responses (including responses with 400- 310 + and 500-level status codes). An origin server can include multiple 311 + Set-Cookie header fields in a single response. The presence of a 312 + Cookie or a Set-Cookie header field does not preclude HTTP caches 313 + from storing and reusing a response. 314 + 315 + Origin servers SHOULD NOT fold multiple Set-Cookie header fields into 316 + a single header field. The usual mechanism for folding HTTP headers 317 + fields (i.e., as defined in [RFC2616]) might change the semantics of 318 + the Set-Cookie header field because the %x2C (",") character is used 319 + by Set-Cookie in a way that conflicts with such folding. 320 + 321 + 3.1. Examples 322 + 323 + Using the Set-Cookie header, a server can send the user agent a short 324 + string in an HTTP response that the user agent will return in future 325 + HTTP requests that are within the scope of the cookie. For example, 326 + the server can send the user agent a "session identifier" named SID 327 + with the value 31d4d96e407aad42. The user agent then returns the 328 + session identifier in subsequent requests. 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + Barth Standards Track [Page 6] 339 + 340 + RFC 6265 HTTP State Management Mechanism April 2011 341 + 342 + 343 + == Server -> User Agent == 344 + 345 + Set-Cookie: SID=31d4d96e407aad42 346 + 347 + == User Agent -> Server == 348 + 349 + Cookie: SID=31d4d96e407aad42 350 + 351 + The server can alter the default scope of the cookie using the Path 352 + and Domain attributes. For example, the server can instruct the user 353 + agent to return the cookie to every path and every subdomain of 354 + example.com. 355 + 356 + == Server -> User Agent == 357 + 358 + Set-Cookie: SID=31d4d96e407aad42; Path=/; Domain=example.com 359 + 360 + == User Agent -> Server == 361 + 362 + Cookie: SID=31d4d96e407aad42 363 + 364 + As shown in the next example, the server can store multiple cookies 365 + at the user agent. For example, the server can store a session 366 + identifier as well as the user's preferred language by returning two 367 + Set-Cookie header fields. Notice that the server uses the Secure and 368 + HttpOnly attributes to provide additional security protections for 369 + the more sensitive session identifier (see Section 4.1.2.) 370 + 371 + == Server -> User Agent == 372 + 373 + Set-Cookie: SID=31d4d96e407aad42; Path=/; Secure; HttpOnly 374 + Set-Cookie: lang=en-US; Path=/; Domain=example.com 375 + 376 + == User Agent -> Server == 377 + 378 + Cookie: SID=31d4d96e407aad42; lang=en-US 379 + 380 + Notice that the Cookie header above contains two cookies, one named 381 + SID and one named lang. If the server wishes the user agent to 382 + persist the cookie over multiple "sessions" (e.g., user agent 383 + restarts), the server can specify an expiration date in the Expires 384 + attribute. Note that the user agent might delete the cookie before 385 + the expiration date if the user agent's cookie store exceeds its 386 + quota or if the user manually deletes the server's cookie. 387 + 388 + 389 + 390 + 391 + 392 + 393 + 394 + Barth Standards Track [Page 7] 395 + 396 + RFC 6265 HTTP State Management Mechanism April 2011 397 + 398 + 399 + == Server -> User Agent == 400 + 401 + Set-Cookie: lang=en-US; Expires=Wed, 09 Jun 2021 10:18:14 GMT 402 + 403 + == User Agent -> Server == 404 + 405 + Cookie: SID=31d4d96e407aad42; lang=en-US 406 + 407 + Finally, to remove a cookie, the server returns a Set-Cookie header 408 + with an expiration date in the past. The server will be successful 409 + in removing the cookie only if the Path and the Domain attribute in 410 + the Set-Cookie header match the values used when the cookie was 411 + created. 412 + 413 + == Server -> User Agent == 414 + 415 + Set-Cookie: lang=; Expires=Sun, 06 Nov 1994 08:49:37 GMT 416 + 417 + == User Agent -> Server == 418 + 419 + Cookie: SID=31d4d96e407aad42 420 + 421 + 4. Server Requirements 422 + 423 + This section describes the syntax and semantics of a well-behaved 424 + profile of the Cookie and Set-Cookie headers. 425 + 426 + 4.1. Set-Cookie 427 + 428 + The Set-Cookie HTTP response header is used to send cookies from the 429 + server to the user agent. 430 + 431 + 4.1.1. Syntax 432 + 433 + Informally, the Set-Cookie response header contains the header name 434 + "Set-Cookie" followed by a ":" and a cookie. Each cookie begins with 435 + a name-value-pair, followed by zero or more attribute-value pairs. 436 + Servers SHOULD NOT send Set-Cookie headers that fail to conform to 437 + the following grammar: 438 + 439 + 440 + 441 + 442 + 443 + 444 + 445 + 446 + 447 + 448 + 449 + 450 + Barth Standards Track [Page 8] 451 + 452 + RFC 6265 HTTP State Management Mechanism April 2011 453 + 454 + 455 + set-cookie-header = "Set-Cookie:" SP set-cookie-string 456 + set-cookie-string = cookie-pair *( ";" SP cookie-av ) 457 + cookie-pair = cookie-name "=" cookie-value 458 + cookie-name = token 459 + cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) 460 + cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E 461 + ; US-ASCII characters excluding CTLs, 462 + ; whitespace DQUOTE, comma, semicolon, 463 + ; and backslash 464 + token = <token, defined in [RFC2616], Section 2.2> 465 + 466 + cookie-av = expires-av / max-age-av / domain-av / 467 + path-av / secure-av / httponly-av / 468 + extension-av 469 + expires-av = "Expires=" sane-cookie-date 470 + sane-cookie-date = <rfc1123-date, defined in [RFC2616], Section 3.3.1> 471 + max-age-av = "Max-Age=" non-zero-digit *DIGIT 472 + ; In practice, both expires-av and max-age-av 473 + ; are limited to dates representable by the 474 + ; user agent. 475 + non-zero-digit = %x31-39 476 + ; digits 1 through 9 477 + domain-av = "Domain=" domain-value 478 + domain-value = <subdomain> 479 + ; defined in [RFC1034], Section 3.5, as 480 + ; enhanced by [RFC1123], Section 2.1 481 + path-av = "Path=" path-value 482 + path-value = <any CHAR except CTLs or ";"> 483 + secure-av = "Secure" 484 + httponly-av = "HttpOnly" 485 + extension-av = <any CHAR except CTLs or ";"> 486 + 487 + Note that some of the grammatical terms above reference documents 488 + that use different grammatical notations than this document (which 489 + uses ABNF from [RFC5234]). 490 + 491 + The semantics of the cookie-value are not defined by this document. 492 + 493 + To maximize compatibility with user agents, servers that wish to 494 + store arbitrary data in a cookie-value SHOULD encode that data, for 495 + example, using Base64 [RFC4648]. 496 + 497 + The portions of the set-cookie-string produced by the cookie-av term 498 + are known as attributes. To maximize compatibility with user agents, 499 + servers SHOULD NOT produce two attributes with the same name in the 500 + same set-cookie-string. (See Section 5.3 for how user agents handle 501 + this case.) 502 + 503 + 504 + 505 + 506 + Barth Standards Track [Page 9] 507 + 508 + RFC 6265 HTTP State Management Mechanism April 2011 509 + 510 + 511 + Servers SHOULD NOT include more than one Set-Cookie header field in 512 + the same response with the same cookie-name. (See Section 5.2 for 513 + how user agents handle this case.) 514 + 515 + If a server sends multiple responses containing Set-Cookie headers 516 + concurrently to the user agent (e.g., when communicating with the 517 + user agent over multiple sockets), these responses create a "race 518 + condition" that can lead to unpredictable behavior. 519 + 520 + NOTE: Some existing user agents differ in their interpretation of 521 + two-digit years. To avoid compatibility issues, servers SHOULD use 522 + the rfc1123-date format, which requires a four-digit year. 523 + 524 + NOTE: Some user agents store and process dates in cookies as 32-bit 525 + UNIX time_t values. Implementation bugs in the libraries supporting 526 + time_t processing on some systems might cause such user agents to 527 + process dates after the year 2038 incorrectly. 528 + 529 + 4.1.2. Semantics (Non-Normative) 530 + 531 + This section describes simplified semantics of the Set-Cookie header. 532 + These semantics are detailed enough to be useful for understanding 533 + the most common uses of cookies by servers. The full semantics are 534 + described in Section 5. 535 + 536 + When the user agent receives a Set-Cookie header, the user agent 537 + stores the cookie together with its attributes. Subsequently, when 538 + the user agent makes an HTTP request, the user agent includes the 539 + applicable, non-expired cookies in the Cookie header. 540 + 541 + If the user agent receives a new cookie with the same cookie-name, 542 + domain-value, and path-value as a cookie that it has already stored, 543 + the existing cookie is evicted and replaced with the new cookie. 544 + Notice that servers can delete cookies by sending the user agent a 545 + new cookie with an Expires attribute with a value in the past. 546 + 547 + Unless the cookie's attributes indicate otherwise, the cookie is 548 + returned only to the origin server (and not, for example, to any 549 + subdomains), and it expires at the end of the current session (as 550 + defined by the user agent). User agents ignore unrecognized cookie 551 + attributes (but not the entire cookie). 552 + 553 + 554 + 555 + 556 + 557 + 558 + 559 + 560 + 561 + 562 + Barth Standards Track [Page 10] 563 + 564 + RFC 6265 HTTP State Management Mechanism April 2011 565 + 566 + 567 + 4.1.2.1. The Expires Attribute 568 + 569 + The Expires attribute indicates the maximum lifetime of the cookie, 570 + represented as the date and time at which the cookie expires. The 571 + user agent is not required to retain the cookie until the specified 572 + date has passed. In fact, user agents often evict cookies due to 573 + memory pressure or privacy concerns. 574 + 575 + 4.1.2.2. The Max-Age Attribute 576 + 577 + The Max-Age attribute indicates the maximum lifetime of the cookie, 578 + represented as the number of seconds until the cookie expires. The 579 + user agent is not required to retain the cookie for the specified 580 + duration. In fact, user agents often evict cookies due to memory 581 + pressure or privacy concerns. 582 + 583 + NOTE: Some existing user agents do not support the Max-Age 584 + attribute. User agents that do not support the Max-Age attribute 585 + ignore the attribute. 586 + 587 + If a cookie has both the Max-Age and the Expires attribute, the Max- 588 + Age attribute has precedence and controls the expiration date of the 589 + cookie. If a cookie has neither the Max-Age nor the Expires 590 + attribute, the user agent will retain the cookie until "the current 591 + session is over" (as defined by the user agent). 592 + 593 + 4.1.2.3. The Domain Attribute 594 + 595 + The Domain attribute specifies those hosts to which the cookie will 596 + be sent. For example, if the value of the Domain attribute is 597 + "example.com", the user agent will include the cookie in the Cookie 598 + header when making HTTP requests to example.com, www.example.com, and 599 + www.corp.example.com. (Note that a leading %x2E ("."), if present, 600 + is ignored even though that character is not permitted, but a 601 + trailing %x2E ("."), if present, will cause the user agent to ignore 602 + the attribute.) If the server omits the Domain attribute, the user 603 + agent will return the cookie only to the origin server. 604 + 605 + WARNING: Some existing user agents treat an absent Domain 606 + attribute as if the Domain attribute were present and contained 607 + the current host name. For example, if example.com returns a Set- 608 + Cookie header without a Domain attribute, these user agents will 609 + erroneously send the cookie to www.example.com as well. 610 + 611 + 612 + 613 + 614 + 615 + 616 + 617 + 618 + Barth Standards Track [Page 11] 619 + 620 + RFC 6265 HTTP State Management Mechanism April 2011 621 + 622 + 623 + The user agent will reject cookies unless the Domain attribute 624 + specifies a scope for the cookie that would include the origin 625 + server. For example, the user agent will accept a cookie with a 626 + Domain attribute of "example.com" or of "foo.example.com" from 627 + foo.example.com, but the user agent will not accept a cookie with a 628 + Domain attribute of "bar.example.com" or of "baz.foo.example.com". 629 + 630 + NOTE: For security reasons, many user agents are configured to reject 631 + Domain attributes that correspond to "public suffixes". For example, 632 + some user agents will reject Domain attributes of "com" or "co.uk". 633 + (See Section 5.3 for more information.) 634 + 635 + 4.1.2.4. The Path Attribute 636 + 637 + The scope of each cookie is limited to a set of paths, controlled by 638 + the Path attribute. If the server omits the Path attribute, the user 639 + agent will use the "directory" of the request-uri's path component as 640 + the default value. (See Section 5.1.4 for more details.) 641 + 642 + The user agent will include the cookie in an HTTP request only if the 643 + path portion of the request-uri matches (or is a subdirectory of) the 644 + cookie's Path attribute, where the %x2F ("/") character is 645 + interpreted as a directory separator. 646 + 647 + Although seemingly useful for isolating cookies between different 648 + paths within a given host, the Path attribute cannot be relied upon 649 + for security (see Section 8). 650 + 651 + 4.1.2.5. The Secure Attribute 652 + 653 + The Secure attribute limits the scope of the cookie to "secure" 654 + channels (where "secure" is defined by the user agent). When a 655 + cookie has the Secure attribute, the user agent will include the 656 + cookie in an HTTP request only if the request is transmitted over a 657 + secure channel (typically HTTP over Transport Layer Security (TLS) 658 + [RFC2818]). 659 + 660 + Although seemingly useful for protecting cookies from active network 661 + attackers, the Secure attribute protects only the cookie's 662 + confidentiality. An active network attacker can overwrite Secure 663 + cookies from an insecure channel, disrupting their integrity (see 664 + Section 8.6 for more details). 665 + 666 + 667 + 668 + 669 + 670 + 671 + 672 + 673 + 674 + Barth Standards Track [Page 12] 675 + 676 + RFC 6265 HTTP State Management Mechanism April 2011 677 + 678 + 679 + 4.1.2.6. The HttpOnly Attribute 680 + 681 + The HttpOnly attribute limits the scope of the cookie to HTTP 682 + requests. In particular, the attribute instructs the user agent to 683 + omit the cookie when providing access to cookies via "non-HTTP" APIs 684 + (such as a web browser API that exposes cookies to scripts). 685 + 686 + Note that the HttpOnly attribute is independent of the Secure 687 + attribute: a cookie can have both the HttpOnly and the Secure 688 + attribute. 689 + 690 + 4.2. Cookie 691 + 692 + 4.2.1. Syntax 693 + 694 + The user agent sends stored cookies to the origin server in the 695 + Cookie header. If the server conforms to the requirements in 696 + Section 4.1 (and the user agent conforms to the requirements in 697 + Section 5), the user agent will send a Cookie header that conforms to 698 + the following grammar: 699 + 700 + cookie-header = "Cookie:" OWS cookie-string OWS 701 + cookie-string = cookie-pair *( ";" SP cookie-pair ) 702 + 703 + 4.2.2. Semantics 704 + 705 + Each cookie-pair represents a cookie stored by the user agent. The 706 + cookie-pair contains the cookie-name and cookie-value the user agent 707 + received in the Set-Cookie header. 708 + 709 + Notice that the cookie attributes are not returned. In particular, 710 + the server cannot determine from the Cookie header alone when a 711 + cookie will expire, for which hosts the cookie is valid, for which 712 + paths the cookie is valid, or whether the cookie was set with the 713 + Secure or HttpOnly attributes. 714 + 715 + The semantics of individual cookies in the Cookie header are not 716 + defined by this document. Servers are expected to imbue these 717 + cookies with application-specific semantics. 718 + 719 + Although cookies are serialized linearly in the Cookie header, 720 + servers SHOULD NOT rely upon the serialization order. In particular, 721 + if the Cookie header contains two cookies with the same name (e.g., 722 + that were set with different Path or Domain attributes), servers 723 + SHOULD NOT rely upon the order in which these cookies appear in the 724 + header. 725 + 726 + 727 + 728 + 729 + 730 + Barth Standards Track [Page 13] 731 + 732 + RFC 6265 HTTP State Management Mechanism April 2011 733 + 734 + 735 + 5. User Agent Requirements 736 + 737 + This section specifies the Cookie and Set-Cookie headers in 738 + sufficient detail that a user agent implementing these requirements 739 + precisely can interoperate with existing servers (even those that do 740 + not conform to the well-behaved profile described in Section 4). 741 + 742 + A user agent could enforce more restrictions than those specified 743 + herein (e.g., for the sake of improved security); however, 744 + experiments have shown that such strictness reduces the likelihood 745 + that a user agent will be able to interoperate with existing servers. 746 + 747 + 5.1. Subcomponent Algorithms 748 + 749 + This section defines some algorithms used by user agents to process 750 + specific subcomponents of the Cookie and Set-Cookie headers. 751 + 752 + 5.1.1. Dates 753 + 754 + The user agent MUST use an algorithm equivalent to the following 755 + algorithm to parse a cookie-date. Note that the various boolean 756 + flags defined as a part of the algorithm (i.e., found-time, found- 757 + day-of-month, found-month, found-year) are initially "not set". 758 + 759 + 1. Using the grammar below, divide the cookie-date into date-tokens. 760 + 761 + cookie-date = *delimiter date-token-list *delimiter 762 + date-token-list = date-token *( 1*delimiter date-token ) 763 + date-token = 1*non-delimiter 764 + 765 + delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E 766 + non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA / %x7F-FF 767 + non-digit = %x00-2F / %x3A-FF 768 + 769 + day-of-month = 1*2DIGIT ( non-digit *OCTET ) 770 + month = ( "jan" / "feb" / "mar" / "apr" / 771 + "may" / "jun" / "jul" / "aug" / 772 + "sep" / "oct" / "nov" / "dec" ) *OCTET 773 + year = 2*4DIGIT ( non-digit *OCTET ) 774 + time = hms-time ( non-digit *OCTET ) 775 + hms-time = time-field ":" time-field ":" time-field 776 + time-field = 1*2DIGIT 777 + 778 + 2. Process each date-token sequentially in the order the date-tokens 779 + appear in the cookie-date: 780 + 781 + 782 + 783 + 784 + 785 + 786 + Barth Standards Track [Page 14] 787 + 788 + RFC 6265 HTTP State Management Mechanism April 2011 789 + 790 + 791 + 1. If the found-time flag is not set and the token matches the 792 + time production, set the found-time flag and set the hour- 793 + value, minute-value, and second-value to the numbers denoted 794 + by the digits in the date-token, respectively. Skip the 795 + remaining sub-steps and continue to the next date-token. 796 + 797 + 2. If the found-day-of-month flag is not set and the date-token 798 + matches the day-of-month production, set the found-day-of- 799 + month flag and set the day-of-month-value to the number 800 + denoted by the date-token. Skip the remaining sub-steps and 801 + continue to the next date-token. 802 + 803 + 3. If the found-month flag is not set and the date-token matches 804 + the month production, set the found-month flag and set the 805 + month-value to the month denoted by the date-token. Skip the 806 + remaining sub-steps and continue to the next date-token. 807 + 808 + 4. If the found-year flag is not set and the date-token matches 809 + the year production, set the found-year flag and set the 810 + year-value to the number denoted by the date-token. Skip the 811 + remaining sub-steps and continue to the next date-token. 812 + 813 + 3. If the year-value is greater than or equal to 70 and less than or 814 + equal to 99, increment the year-value by 1900. 815 + 816 + 4. If the year-value is greater than or equal to 0 and less than or 817 + equal to 69, increment the year-value by 2000. 818 + 819 + 1. NOTE: Some existing user agents interpret two-digit years 820 + differently. 821 + 822 + 5. Abort these steps and fail to parse the cookie-date if: 823 + 824 + * at least one of the found-day-of-month, found-month, found- 825 + year, or found-time flags is not set, 826 + 827 + * the day-of-month-value is less than 1 or greater than 31, 828 + 829 + * the year-value is less than 1601, 830 + 831 + * the hour-value is greater than 23, 832 + 833 + * the minute-value is greater than 59, or 834 + 835 + * the second-value is greater than 59. 836 + 837 + (Note that leap seconds cannot be represented in this syntax.) 838 + 839 + 840 + 841 + 842 + Barth Standards Track [Page 15] 843 + 844 + RFC 6265 HTTP State Management Mechanism April 2011 845 + 846 + 847 + 6. Let the parsed-cookie-date be the date whose day-of-month, month, 848 + year, hour, minute, and second (in UTC) are the day-of-month- 849 + value, the month-value, the year-value, the hour-value, the 850 + minute-value, and the second-value, respectively. If no such 851 + date exists, abort these steps and fail to parse the cookie-date. 852 + 853 + 7. Return the parsed-cookie-date as the result of this algorithm. 854 + 855 + 5.1.2. Canonicalized Host Names 856 + 857 + A canonicalized host name is the string generated by the following 858 + algorithm: 859 + 860 + 1. Convert the host name to a sequence of individual domain name 861 + labels. 862 + 863 + 2. Convert each label that is not a Non-Reserved LDH (NR-LDH) label, 864 + to an A-label (see Section 2.3.2.1 of [RFC5890] for the former 865 + and latter), or to a "punycode label" (a label resulting from the 866 + "ToASCII" conversion in Section 4 of [RFC3490]), as appropriate 867 + (see Section 6.3 of this specification). 868 + 869 + 3. Concatenate the resulting labels, separated by a %x2E (".") 870 + character. 871 + 872 + 5.1.3. Domain Matching 873 + 874 + A string domain-matches a given domain string if at least one of the 875 + following conditions hold: 876 + 877 + o The domain string and the string are identical. (Note that both 878 + the domain string and the string will have been canonicalized to 879 + lower case at this point.) 880 + 881 + o All of the following conditions hold: 882 + 883 + * The domain string is a suffix of the string. 884 + 885 + * The last character of the string that is not included in the 886 + domain string is a %x2E (".") character. 887 + 888 + * The string is a host name (i.e., not an IP address). 889 + 890 + 5.1.4. Paths and Path-Match 891 + 892 + The user agent MUST use an algorithm equivalent to the following 893 + algorithm to compute the default-path of a cookie: 894 + 895 + 896 + 897 + 898 + Barth Standards Track [Page 16] 899 + 900 + RFC 6265 HTTP State Management Mechanism April 2011 901 + 902 + 903 + 1. Let uri-path be the path portion of the request-uri if such a 904 + portion exists (and empty otherwise). For example, if the 905 + request-uri contains just a path (and optional query string), 906 + then the uri-path is that path (without the %x3F ("?") character 907 + or query string), and if the request-uri contains a full 908 + absoluteURI, the uri-path is the path component of that URI. 909 + 910 + 2. If the uri-path is empty or if the first character of the uri- 911 + path is not a %x2F ("/") character, output %x2F ("/") and skip 912 + the remaining steps. 913 + 914 + 3. If the uri-path contains no more than one %x2F ("/") character, 915 + output %x2F ("/") and skip the remaining step. 916 + 917 + 4. Output the characters of the uri-path from the first character up 918 + to, but not including, the right-most %x2F ("/"). 919 + 920 + A request-path path-matches a given cookie-path if at least one of 921 + the following conditions holds: 922 + 923 + o The cookie-path and the request-path are identical. 924 + 925 + o The cookie-path is a prefix of the request-path, and the last 926 + character of the cookie-path is %x2F ("/"). 927 + 928 + o The cookie-path is a prefix of the request-path, and the first 929 + character of the request-path that is not included in the cookie- 930 + path is a %x2F ("/") character. 931 + 932 + 5.2. The Set-Cookie Header 933 + 934 + When a user agent receives a Set-Cookie header field in an HTTP 935 + response, the user agent MAY ignore the Set-Cookie header field in 936 + its entirety. For example, the user agent might wish to block 937 + responses to "third-party" requests from setting cookies (see 938 + Section 7.1). 939 + 940 + If the user agent does not ignore the Set-Cookie header field in its 941 + entirety, the user agent MUST parse the field-value of the Set-Cookie 942 + header field as a set-cookie-string (defined below). 943 + 944 + NOTE: The algorithm below is more permissive than the grammar in 945 + Section 4.1. For example, the algorithm strips leading and trailing 946 + whitespace from the cookie name and value (but maintains internal 947 + whitespace), whereas the grammar in Section 4.1 forbids whitespace in 948 + these positions. User agents use this algorithm so as to 949 + interoperate with servers that do not follow the recommendations in 950 + Section 4. 951 + 952 + 953 + 954 + Barth Standards Track [Page 17] 955 + 956 + RFC 6265 HTTP State Management Mechanism April 2011 957 + 958 + 959 + A user agent MUST use an algorithm equivalent to the following 960 + algorithm to parse a "set-cookie-string": 961 + 962 + 1. If the set-cookie-string contains a %x3B (";") character: 963 + 964 + The name-value-pair string consists of the characters up to, 965 + but not including, the first %x3B (";"), and the unparsed- 966 + attributes consist of the remainder of the set-cookie-string 967 + (including the %x3B (";") in question). 968 + 969 + Otherwise: 970 + 971 + The name-value-pair string consists of all the characters 972 + contained in the set-cookie-string, and the unparsed- 973 + attributes is the empty string. 974 + 975 + 2. If the name-value-pair string lacks a %x3D ("=") character, 976 + ignore the set-cookie-string entirely. 977 + 978 + 3. The (possibly empty) name string consists of the characters up 979 + to, but not including, the first %x3D ("=") character, and the 980 + (possibly empty) value string consists of the characters after 981 + the first %x3D ("=") character. 982 + 983 + 4. Remove any leading or trailing WSP characters from the name 984 + string and the value string. 985 + 986 + 5. If the name string is empty, ignore the set-cookie-string 987 + entirely. 988 + 989 + 6. The cookie-name is the name string, and the cookie-value is the 990 + value string. 991 + 992 + The user agent MUST use an algorithm equivalent to the following 993 + algorithm to parse the unparsed-attributes: 994 + 995 + 1. If the unparsed-attributes string is empty, skip the rest of 996 + these steps. 997 + 998 + 2. Discard the first character of the unparsed-attributes (which 999 + will be a %x3B (";") character). 1000 + 1001 + 3. If the remaining unparsed-attributes contains a %x3B (";") 1002 + character: 1003 + 1004 + Consume the characters of the unparsed-attributes up to, but 1005 + not including, the first %x3B (";") character. 1006 + 1007 + 1008 + 1009 + 1010 + Barth Standards Track [Page 18] 1011 + 1012 + RFC 6265 HTTP State Management Mechanism April 2011 1013 + 1014 + 1015 + Otherwise: 1016 + 1017 + Consume the remainder of the unparsed-attributes. 1018 + 1019 + Let the cookie-av string be the characters consumed in this step. 1020 + 1021 + 4. If the cookie-av string contains a %x3D ("=") character: 1022 + 1023 + The (possibly empty) attribute-name string consists of the 1024 + characters up to, but not including, the first %x3D ("=") 1025 + character, and the (possibly empty) attribute-value string 1026 + consists of the characters after the first %x3D ("=") 1027 + character. 1028 + 1029 + Otherwise: 1030 + 1031 + The attribute-name string consists of the entire cookie-av 1032 + string, and the attribute-value string is empty. 1033 + 1034 + 5. Remove any leading or trailing WSP characters from the attribute- 1035 + name string and the attribute-value string. 1036 + 1037 + 6. Process the attribute-name and attribute-value according to the 1038 + requirements in the following subsections. (Notice that 1039 + attributes with unrecognized attribute-names are ignored.) 1040 + 1041 + 7. Return to Step 1 of this algorithm. 1042 + 1043 + When the user agent finishes parsing the set-cookie-string, the user 1044 + agent is said to "receive a cookie" from the request-uri with name 1045 + cookie-name, value cookie-value, and attributes cookie-attribute- 1046 + list. (See Section 5.3 for additional requirements triggered by 1047 + receiving a cookie.) 1048 + 1049 + 5.2.1. The Expires Attribute 1050 + 1051 + If the attribute-name case-insensitively matches the string 1052 + "Expires", the user agent MUST process the cookie-av as follows. 1053 + 1054 + Let the expiry-time be the result of parsing the attribute-value as 1055 + cookie-date (see Section 5.1.1). 1056 + 1057 + If the attribute-value failed to parse as a cookie date, ignore the 1058 + cookie-av. 1059 + 1060 + If the expiry-time is later than the last date the user agent can 1061 + represent, the user agent MAY replace the expiry-time with the last 1062 + representable date. 1063 + 1064 + 1065 + 1066 + Barth Standards Track [Page 19] 1067 + 1068 + RFC 6265 HTTP State Management Mechanism April 2011 1069 + 1070 + 1071 + If the expiry-time is earlier than the earliest date the user agent 1072 + can represent, the user agent MAY replace the expiry-time with the 1073 + earliest representable date. 1074 + 1075 + Append an attribute to the cookie-attribute-list with an attribute- 1076 + name of Expires and an attribute-value of expiry-time. 1077 + 1078 + 5.2.2. The Max-Age Attribute 1079 + 1080 + If the attribute-name case-insensitively matches the string "Max- 1081 + Age", the user agent MUST process the cookie-av as follows. 1082 + 1083 + If the first character of the attribute-value is not a DIGIT or a "-" 1084 + character, ignore the cookie-av. 1085 + 1086 + If the remainder of attribute-value contains a non-DIGIT character, 1087 + ignore the cookie-av. 1088 + 1089 + Let delta-seconds be the attribute-value converted to an integer. 1090 + 1091 + If delta-seconds is less than or equal to zero (0), let expiry-time 1092 + be the earliest representable date and time. Otherwise, let the 1093 + expiry-time be the current date and time plus delta-seconds seconds. 1094 + 1095 + Append an attribute to the cookie-attribute-list with an attribute- 1096 + name of Max-Age and an attribute-value of expiry-time. 1097 + 1098 + 5.2.3. The Domain Attribute 1099 + 1100 + If the attribute-name case-insensitively matches the string "Domain", 1101 + the user agent MUST process the cookie-av as follows. 1102 + 1103 + If the attribute-value is empty, the behavior is undefined. However, 1104 + the user agent SHOULD ignore the cookie-av entirely. 1105 + 1106 + If the first character of the attribute-value string is %x2E ("."): 1107 + 1108 + Let cookie-domain be the attribute-value without the leading %x2E 1109 + (".") character. 1110 + 1111 + Otherwise: 1112 + 1113 + Let cookie-domain be the entire attribute-value. 1114 + 1115 + Convert the cookie-domain to lower case. 1116 + 1117 + Append an attribute to the cookie-attribute-list with an attribute- 1118 + name of Domain and an attribute-value of cookie-domain. 1119 + 1120 + 1121 + 1122 + Barth Standards Track [Page 20] 1123 + 1124 + RFC 6265 HTTP State Management Mechanism April 2011 1125 + 1126 + 1127 + 5.2.4. The Path Attribute 1128 + 1129 + If the attribute-name case-insensitively matches the string "Path", 1130 + the user agent MUST process the cookie-av as follows. 1131 + 1132 + If the attribute-value is empty or if the first character of the 1133 + attribute-value is not %x2F ("/"): 1134 + 1135 + Let cookie-path be the default-path. 1136 + 1137 + Otherwise: 1138 + 1139 + Let cookie-path be the attribute-value. 1140 + 1141 + Append an attribute to the cookie-attribute-list with an attribute- 1142 + name of Path and an attribute-value of cookie-path. 1143 + 1144 + 5.2.5. The Secure Attribute 1145 + 1146 + If the attribute-name case-insensitively matches the string "Secure", 1147 + the user agent MUST append an attribute to the cookie-attribute-list 1148 + with an attribute-name of Secure and an empty attribute-value. 1149 + 1150 + 5.2.6. The HttpOnly Attribute 1151 + 1152 + If the attribute-name case-insensitively matches the string 1153 + "HttpOnly", the user agent MUST append an attribute to the cookie- 1154 + attribute-list with an attribute-name of HttpOnly and an empty 1155 + attribute-value. 1156 + 1157 + 5.3. Storage Model 1158 + 1159 + The user agent stores the following fields about each cookie: name, 1160 + value, expiry-time, domain, path, creation-time, last-access-time, 1161 + persistent-flag, host-only-flag, secure-only-flag, and http-only- 1162 + flag. 1163 + 1164 + When the user agent "receives a cookie" from a request-uri with name 1165 + cookie-name, value cookie-value, and attributes cookie-attribute- 1166 + list, the user agent MUST process the cookie as follows: 1167 + 1168 + 1. A user agent MAY ignore a received cookie in its entirety. For 1169 + example, the user agent might wish to block receiving cookies 1170 + from "third-party" responses or the user agent might not wish to 1171 + store cookies that exceed some size. 1172 + 1173 + 1174 + 1175 + 1176 + 1177 + 1178 + Barth Standards Track [Page 21] 1179 + 1180 + RFC 6265 HTTP State Management Mechanism April 2011 1181 + 1182 + 1183 + 2. Create a new cookie with name cookie-name, value cookie-value. 1184 + Set the creation-time and the last-access-time to the current 1185 + date and time. 1186 + 1187 + 3. If the cookie-attribute-list contains an attribute with an 1188 + attribute-name of "Max-Age": 1189 + 1190 + Set the cookie's persistent-flag to true. 1191 + 1192 + Set the cookie's expiry-time to attribute-value of the last 1193 + attribute in the cookie-attribute-list with an attribute-name 1194 + of "Max-Age". 1195 + 1196 + Otherwise, if the cookie-attribute-list contains an attribute 1197 + with an attribute-name of "Expires" (and does not contain an 1198 + attribute with an attribute-name of "Max-Age"): 1199 + 1200 + Set the cookie's persistent-flag to true. 1201 + 1202 + Set the cookie's expiry-time to attribute-value of the last 1203 + attribute in the cookie-attribute-list with an attribute-name 1204 + of "Expires". 1205 + 1206 + Otherwise: 1207 + 1208 + Set the cookie's persistent-flag to false. 1209 + 1210 + Set the cookie's expiry-time to the latest representable 1211 + date. 1212 + 1213 + 4. If the cookie-attribute-list contains an attribute with an 1214 + attribute-name of "Domain": 1215 + 1216 + Let the domain-attribute be the attribute-value of the last 1217 + attribute in the cookie-attribute-list with an attribute-name 1218 + of "Domain". 1219 + 1220 + Otherwise: 1221 + 1222 + Let the domain-attribute be the empty string. 1223 + 1224 + 5. If the user agent is configured to reject "public suffixes" and 1225 + the domain-attribute is a public suffix: 1226 + 1227 + If the domain-attribute is identical to the canonicalized 1228 + request-host: 1229 + 1230 + Let the domain-attribute be the empty string. 1231 + 1232 + 1233 + 1234 + Barth Standards Track [Page 22] 1235 + 1236 + RFC 6265 HTTP State Management Mechanism April 2011 1237 + 1238 + 1239 + Otherwise: 1240 + 1241 + Ignore the cookie entirely and abort these steps. 1242 + 1243 + NOTE: A "public suffix" is a domain that is controlled by a 1244 + public registry, such as "com", "co.uk", and "pvt.k12.wy.us". 1245 + This step is essential for preventing attacker.com from 1246 + disrupting the integrity of example.com by setting a cookie 1247 + with a Domain attribute of "com". Unfortunately, the set of 1248 + public suffixes (also known as "registry controlled domains") 1249 + changes over time. If feasible, user agents SHOULD use an 1250 + up-to-date public suffix list, such as the one maintained by 1251 + the Mozilla project at <http://publicsuffix.org/>. 1252 + 1253 + 6. If the domain-attribute is non-empty: 1254 + 1255 + If the canonicalized request-host does not domain-match the 1256 + domain-attribute: 1257 + 1258 + Ignore the cookie entirely and abort these steps. 1259 + 1260 + Otherwise: 1261 + 1262 + Set the cookie's host-only-flag to false. 1263 + 1264 + Set the cookie's domain to the domain-attribute. 1265 + 1266 + Otherwise: 1267 + 1268 + Set the cookie's host-only-flag to true. 1269 + 1270 + Set the cookie's domain to the canonicalized request-host. 1271 + 1272 + 7. If the cookie-attribute-list contains an attribute with an 1273 + attribute-name of "Path", set the cookie's path to attribute- 1274 + value of the last attribute in the cookie-attribute-list with an 1275 + attribute-name of "Path". Otherwise, set the cookie's path to 1276 + the default-path of the request-uri. 1277 + 1278 + 8. If the cookie-attribute-list contains an attribute with an 1279 + attribute-name of "Secure", set the cookie's secure-only-flag to 1280 + true. Otherwise, set the cookie's secure-only-flag to false. 1281 + 1282 + 9. If the cookie-attribute-list contains an attribute with an 1283 + attribute-name of "HttpOnly", set the cookie's http-only-flag to 1284 + true. Otherwise, set the cookie's http-only-flag to false. 1285 + 1286 + 1287 + 1288 + 1289 + 1290 + Barth Standards Track [Page 23] 1291 + 1292 + RFC 6265 HTTP State Management Mechanism April 2011 1293 + 1294 + 1295 + 10. If the cookie was received from a "non-HTTP" API and the 1296 + cookie's http-only-flag is set, abort these steps and ignore the 1297 + cookie entirely. 1298 + 1299 + 11. If the cookie store contains a cookie with the same name, 1300 + domain, and path as the newly created cookie: 1301 + 1302 + 1. Let old-cookie be the existing cookie with the same name, 1303 + domain, and path as the newly created cookie. (Notice that 1304 + this algorithm maintains the invariant that there is at most 1305 + one such cookie.) 1306 + 1307 + 2. If the newly created cookie was received from a "non-HTTP" 1308 + API and the old-cookie's http-only-flag is set, abort these 1309 + steps and ignore the newly created cookie entirely. 1310 + 1311 + 3. Update the creation-time of the newly created cookie to 1312 + match the creation-time of the old-cookie. 1313 + 1314 + 4. Remove the old-cookie from the cookie store. 1315 + 1316 + 12. Insert the newly created cookie into the cookie store. 1317 + 1318 + A cookie is "expired" if the cookie has an expiry date in the past. 1319 + 1320 + The user agent MUST evict all expired cookies from the cookie store 1321 + if, at any time, an expired cookie exists in the cookie store. 1322 + 1323 + At any time, the user agent MAY "remove excess cookies" from the 1324 + cookie store if the number of cookies sharing a domain field exceeds 1325 + some implementation-defined upper bound (such as 50 cookies). 1326 + 1327 + At any time, the user agent MAY "remove excess cookies" from the 1328 + cookie store if the cookie store exceeds some predetermined upper 1329 + bound (such as 3000 cookies). 1330 + 1331 + When the user agent removes excess cookies from the cookie store, the 1332 + user agent MUST evict cookies in the following priority order: 1333 + 1334 + 1. Expired cookies. 1335 + 1336 + 2. Cookies that share a domain field with more than a predetermined 1337 + number of other cookies. 1338 + 1339 + 3. All cookies. 1340 + 1341 + If two cookies have the same removal priority, the user agent MUST 1342 + evict the cookie with the earliest last-access date first. 1343 + 1344 + 1345 + 1346 + Barth Standards Track [Page 24] 1347 + 1348 + RFC 6265 HTTP State Management Mechanism April 2011 1349 + 1350 + 1351 + When "the current session is over" (as defined by the user agent), 1352 + the user agent MUST remove from the cookie store all cookies with the 1353 + persistent-flag set to false. 1354 + 1355 + 5.4. The Cookie Header 1356 + 1357 + The user agent includes stored cookies in the Cookie HTTP request 1358 + header. 1359 + 1360 + When the user agent generates an HTTP request, the user agent MUST 1361 + NOT attach more than one Cookie header field. 1362 + 1363 + A user agent MAY omit the Cookie header in its entirety. For 1364 + example, the user agent might wish to block sending cookies during 1365 + "third-party" requests from setting cookies (see Section 7.1). 1366 + 1367 + If the user agent does attach a Cookie header field to an HTTP 1368 + request, the user agent MUST send the cookie-string (defined below) 1369 + as the value of the header field. 1370 + 1371 + The user agent MUST use an algorithm equivalent to the following 1372 + algorithm to compute the "cookie-string" from a cookie store and a 1373 + request-uri: 1374 + 1375 + 1. Let cookie-list be the set of cookies from the cookie store that 1376 + meets all of the following requirements: 1377 + 1378 + * Either: 1379 + 1380 + The cookie's host-only-flag is true and the canonicalized 1381 + request-host is identical to the cookie's domain. 1382 + 1383 + Or: 1384 + 1385 + The cookie's host-only-flag is false and the canonicalized 1386 + request-host domain-matches the cookie's domain. 1387 + 1388 + * The request-uri's path path-matches the cookie's path. 1389 + 1390 + * If the cookie's secure-only-flag is true, then the request- 1391 + uri's scheme must denote a "secure" protocol (as defined by 1392 + the user agent). 1393 + 1394 + NOTE: The notion of a "secure" protocol is not defined by 1395 + this document. Typically, user agents consider a protocol 1396 + secure if the protocol makes use of transport-layer 1397 + 1398 + 1399 + 1400 + 1401 + 1402 + Barth Standards Track [Page 25] 1403 + 1404 + RFC 6265 HTTP State Management Mechanism April 2011 1405 + 1406 + 1407 + security, such as SSL or TLS. For example, most user 1408 + agents consider "https" to be a scheme that denotes a 1409 + secure protocol. 1410 + 1411 + * If the cookie's http-only-flag is true, then exclude the 1412 + cookie if the cookie-string is being generated for a "non- 1413 + HTTP" API (as defined by the user agent). 1414 + 1415 + 2. The user agent SHOULD sort the cookie-list in the following 1416 + order: 1417 + 1418 + * Cookies with longer paths are listed before cookies with 1419 + shorter paths. 1420 + 1421 + * Among cookies that have equal-length path fields, cookies with 1422 + earlier creation-times are listed before cookies with later 1423 + creation-times. 1424 + 1425 + NOTE: Not all user agents sort the cookie-list in this order, but 1426 + this order reflects common practice when this document was 1427 + written, and, historically, there have been servers that 1428 + (erroneously) depended on this order. 1429 + 1430 + 3. Update the last-access-time of each cookie in the cookie-list to 1431 + the current date and time. 1432 + 1433 + 4. Serialize the cookie-list into a cookie-string by processing each 1434 + cookie in the cookie-list in order: 1435 + 1436 + 1. Output the cookie's name, the %x3D ("=") character, and the 1437 + cookie's value. 1438 + 1439 + 2. If there is an unprocessed cookie in the cookie-list, output 1440 + the characters %x3B and %x20 ("; "). 1441 + 1442 + NOTE: Despite its name, the cookie-string is actually a sequence of 1443 + octets, not a sequence of characters. To convert the cookie-string 1444 + (or components thereof) into a sequence of characters (e.g., for 1445 + presentation to the user), the user agent might wish to try using the 1446 + UTF-8 character encoding [RFC3629] to decode the octet sequence. 1447 + This decoding might fail, however, because not every sequence of 1448 + octets is valid UTF-8. 1449 + 1450 + 1451 + 1452 + 1453 + 1454 + 1455 + 1456 + 1457 + 1458 + Barth Standards Track [Page 26] 1459 + 1460 + RFC 6265 HTTP State Management Mechanism April 2011 1461 + 1462 + 1463 + 6. Implementation Considerations 1464 + 1465 + 6.1. Limits 1466 + 1467 + Practical user agent implementations have limits on the number and 1468 + size of cookies that they can store. General-use user agents SHOULD 1469 + provide each of the following minimum capabilities: 1470 + 1471 + o At least 4096 bytes per cookie (as measured by the sum of the 1472 + length of the cookie's name, value, and attributes). 1473 + 1474 + o At least 50 cookies per domain. 1475 + 1476 + o At least 3000 cookies total. 1477 + 1478 + Servers SHOULD use as few and as small cookies as possible to avoid 1479 + reaching these implementation limits and to minimize network 1480 + bandwidth due to the Cookie header being included in every request. 1481 + 1482 + Servers SHOULD gracefully degrade if the user agent fails to return 1483 + one or more cookies in the Cookie header because the user agent might 1484 + evict any cookie at any time on orders from the user. 1485 + 1486 + 6.2. Application Programming Interfaces 1487 + 1488 + One reason the Cookie and Set-Cookie headers use such esoteric syntax 1489 + is that many platforms (both in servers and user agents) provide a 1490 + string-based application programming interface (API) to cookies, 1491 + requiring application-layer programmers to generate and parse the 1492 + syntax used by the Cookie and Set-Cookie headers, which many 1493 + programmers have done incorrectly, resulting in interoperability 1494 + problems. 1495 + 1496 + Instead of providing string-based APIs to cookies, platforms would be 1497 + well-served by providing more semantic APIs. It is beyond the scope 1498 + of this document to recommend specific API designs, but there are 1499 + clear benefits to accepting an abstract "Date" object instead of a 1500 + serialized date string. 1501 + 1502 + 6.3. IDNA Dependency and Migration 1503 + 1504 + IDNA2008 [RFC5890] supersedes IDNA2003 [RFC3490]. However, there are 1505 + differences between the two specifications, and thus there can be 1506 + differences in processing (e.g., converting) domain name labels that 1507 + have been registered under one from those registered under the other. 1508 + There will be a transition period of some time during which IDNA2003- 1509 + based domain name labels will exist in the wild. User agents SHOULD 1510 + implement IDNA2008 [RFC5890] and MAY implement [UTS46] or [RFC5895] 1511 + 1512 + 1513 + 1514 + Barth Standards Track [Page 27] 1515 + 1516 + RFC 6265 HTTP State Management Mechanism April 2011 1517 + 1518 + 1519 + in order to facilitate their IDNA transition. If a user agent does 1520 + not implement IDNA2008, the user agent MUST implement IDNA2003 1521 + [RFC3490]. 1522 + 1523 + 7. Privacy Considerations 1524 + 1525 + Cookies are often criticized for letting servers track users. For 1526 + example, a number of "web analytics" companies use cookies to 1527 + recognize when a user returns to a web site or visits another web 1528 + site. Although cookies are not the only mechanism servers can use to 1529 + track users across HTTP requests, cookies facilitate tracking because 1530 + they are persistent across user agent sessions and can be shared 1531 + between hosts. 1532 + 1533 + 7.1. Third-Party Cookies 1534 + 1535 + Particularly worrisome are so-called "third-party" cookies. In 1536 + rendering an HTML document, a user agent often requests resources 1537 + from other servers (such as advertising networks). These third-party 1538 + servers can use cookies to track the user even if the user never 1539 + visits the server directly. For example, if a user visits a site 1540 + that contains content from a third party and then later visits 1541 + another site that contains content from the same third party, the 1542 + third party can track the user between the two sites. 1543 + 1544 + Some user agents restrict how third-party cookies behave. For 1545 + example, some of these user agents refuse to send the Cookie header 1546 + in third-party requests. Others refuse to process the Set-Cookie 1547 + header in responses to third-party requests. User agents vary widely 1548 + in their third-party cookie policies. This document grants user 1549 + agents wide latitude to experiment with third-party cookie policies 1550 + that balance the privacy and compatibility needs of their users. 1551 + However, this document does not endorse any particular third-party 1552 + cookie policy. 1553 + 1554 + Third-party cookie blocking policies are often ineffective at 1555 + achieving their privacy goals if servers attempt to work around their 1556 + restrictions to track users. In particular, two collaborating 1557 + servers can often track users without using cookies at all by 1558 + injecting identifying information into dynamic URLs. 1559 + 1560 + 7.2. User Controls 1561 + 1562 + User agents SHOULD provide users with a mechanism for managing the 1563 + cookies stored in the cookie store. For example, a user agent might 1564 + let users delete all cookies received during a specified time period 1565 + 1566 + 1567 + 1568 + 1569 + 1570 + Barth Standards Track [Page 28] 1571 + 1572 + RFC 6265 HTTP State Management Mechanism April 2011 1573 + 1574 + 1575 + or all the cookies related to a particular domain. In addition, many 1576 + user agents include a user interface element that lets users examine 1577 + the cookies stored in their cookie store. 1578 + 1579 + User agents SHOULD provide users with a mechanism for disabling 1580 + cookies. When cookies are disabled, the user agent MUST NOT include 1581 + a Cookie header in outbound HTTP requests and the user agent MUST NOT 1582 + process Set-Cookie headers in inbound HTTP responses. 1583 + 1584 + Some user agents provide users the option of preventing persistent 1585 + storage of cookies across sessions. When configured thusly, user 1586 + agents MUST treat all received cookies as if the persistent-flag were 1587 + set to false. Some popular user agents expose this functionality via 1588 + "private browsing" mode [Aggarwal2010]. 1589 + 1590 + Some user agents provide users with the ability to approve individual 1591 + writes to the cookie store. In many common usage scenarios, these 1592 + controls generate a large number of prompts. However, some privacy- 1593 + conscious users find these controls useful nonetheless. 1594 + 1595 + 7.3. Expiration Dates 1596 + 1597 + Although servers can set the expiration date for cookies to the 1598 + distant future, most user agents do not actually retain cookies for 1599 + multiple decades. Rather than choosing gratuitously long expiration 1600 + periods, servers SHOULD promote user privacy by selecting reasonable 1601 + cookie expiration periods based on the purpose of the cookie. For 1602 + example, a typical session identifier might reasonably be set to 1603 + expire in two weeks. 1604 + 1605 + 8. Security Considerations 1606 + 1607 + 8.1. Overview 1608 + 1609 + Cookies have a number of security pitfalls. This section overviews a 1610 + few of the more salient issues. 1611 + 1612 + In particular, cookies encourage developers to rely on ambient 1613 + authority for authentication, often becoming vulnerable to attacks 1614 + such as cross-site request forgery [CSRF]. Also, when storing 1615 + session identifiers in cookies, developers often create session 1616 + fixation vulnerabilities. 1617 + 1618 + Transport-layer encryption, such as that employed in HTTPS, is 1619 + insufficient to prevent a network attacker from obtaining or altering 1620 + a victim's cookies because the cookie protocol itself has various 1621 + vulnerabilities (see "Weak Confidentiality" and "Weak Integrity", 1622 + 1623 + 1624 + 1625 + 1626 + Barth Standards Track [Page 29] 1627 + 1628 + RFC 6265 HTTP State Management Mechanism April 2011 1629 + 1630 + 1631 + below). In addition, by default, cookies do not provide 1632 + confidentiality or integrity from network attackers, even when used 1633 + in conjunction with HTTPS. 1634 + 1635 + 8.2. Ambient Authority 1636 + 1637 + A server that uses cookies to authenticate users can suffer security 1638 + vulnerabilities because some user agents let remote parties issue 1639 + HTTP requests from the user agent (e.g., via HTTP redirects or HTML 1640 + forms). When issuing those requests, user agents attach cookies even 1641 + if the remote party does not know the contents of the cookies, 1642 + potentially letting the remote party exercise authority at an unwary 1643 + server. 1644 + 1645 + Although this security concern goes by a number of names (e.g., 1646 + cross-site request forgery, confused deputy), the issue stems from 1647 + cookies being a form of ambient authority. Cookies encourage server 1648 + operators to separate designation (in the form of URLs) from 1649 + authorization (in the form of cookies). Consequently, the user agent 1650 + might supply the authorization for a resource designated by the 1651 + attacker, possibly causing the server or its clients to undertake 1652 + actions designated by the attacker as though they were authorized by 1653 + the user. 1654 + 1655 + Instead of using cookies for authorization, server operators might 1656 + wish to consider entangling designation and authorization by treating 1657 + URLs as capabilities. Instead of storing secrets in cookies, this 1658 + approach stores secrets in URLs, requiring the remote entity to 1659 + supply the secret itself. Although this approach is not a panacea, 1660 + judicious application of these principles can lead to more robust 1661 + security. 1662 + 1663 + 8.3. Clear Text 1664 + 1665 + Unless sent over a secure channel (such as TLS), the information in 1666 + the Cookie and Set-Cookie headers is transmitted in the clear. 1667 + 1668 + 1. All sensitive information conveyed in these headers is exposed to 1669 + an eavesdropper. 1670 + 1671 + 2. A malicious intermediary could alter the headers as they travel 1672 + in either direction, with unpredictable results. 1673 + 1674 + 3. A malicious client could alter the Cookie header before 1675 + transmission, with unpredictable results. 1676 + 1677 + 1678 + 1679 + 1680 + 1681 + 1682 + Barth Standards Track [Page 30] 1683 + 1684 + RFC 6265 HTTP State Management Mechanism April 2011 1685 + 1686 + 1687 + Servers SHOULD encrypt and sign the contents of cookies (using 1688 + whatever format the server desires) when transmitting them to the 1689 + user agent (even when sending the cookies over a secure channel). 1690 + However, encrypting and signing cookie contents does not prevent an 1691 + attacker from transplanting a cookie from one user agent to another 1692 + or from replaying the cookie at a later time. 1693 + 1694 + In addition to encrypting and signing the contents of every cookie, 1695 + servers that require a higher level of security SHOULD use the Cookie 1696 + and Set-Cookie headers only over a secure channel. When using 1697 + cookies over a secure channel, servers SHOULD set the Secure 1698 + attribute (see Section 4.1.2.5) for every cookie. If a server does 1699 + not set the Secure attribute, the protection provided by the secure 1700 + channel will be largely moot. 1701 + 1702 + For example, consider a webmail server that stores a session 1703 + identifier in a cookie and is typically accessed over HTTPS. If the 1704 + server does not set the Secure attribute on its cookies, an active 1705 + network attacker can intercept any outbound HTTP request from the 1706 + user agent and redirect that request to the webmail server over HTTP. 1707 + Even if the webmail server is not listening for HTTP connections, the 1708 + user agent will still include cookies in the request. The active 1709 + network attacker can intercept these cookies, replay them against the 1710 + server, and learn the contents of the user's email. If, instead, the 1711 + server had set the Secure attribute on its cookies, the user agent 1712 + would not have included the cookies in the clear-text request. 1713 + 1714 + 8.4. Session Identifiers 1715 + 1716 + Instead of storing session information directly in a cookie (where it 1717 + might be exposed to or replayed by an attacker), servers commonly 1718 + store a nonce (or "session identifier") in a cookie. When the server 1719 + receives an HTTP request with a nonce, the server can look up state 1720 + information associated with the cookie using the nonce as a key. 1721 + 1722 + Using session identifier cookies limits the damage an attacker can 1723 + cause if the attacker learns the contents of a cookie because the 1724 + nonce is useful only for interacting with the server (unlike non- 1725 + nonce cookie content, which might itself be sensitive). Furthermore, 1726 + using a single nonce prevents an attacker from "splicing" together 1727 + cookie content from two interactions with the server, which could 1728 + cause the server to behave unexpectedly. 1729 + 1730 + Using session identifiers is not without risk. For example, the 1731 + server SHOULD take care to avoid "session fixation" vulnerabilities. 1732 + A session fixation attack proceeds in three steps. First, the 1733 + attacker transplants a session identifier from his or her user agent 1734 + to the victim's user agent. Second, the victim uses that session 1735 + 1736 + 1737 + 1738 + Barth Standards Track [Page 31] 1739 + 1740 + RFC 6265 HTTP State Management Mechanism April 2011 1741 + 1742 + 1743 + identifier to interact with the server, possibly imbuing the session 1744 + identifier with the user's credentials or confidential information. 1745 + Third, the attacker uses the session identifier to interact with 1746 + server directly, possibly obtaining the user's authority or 1747 + confidential information. 1748 + 1749 + 8.5. Weak Confidentiality 1750 + 1751 + Cookies do not provide isolation by port. If a cookie is readable by 1752 + a service running on one port, the cookie is also readable by a 1753 + service running on another port of the same server. If a cookie is 1754 + writable by a service on one port, the cookie is also writable by a 1755 + service running on another port of the same server. For this reason, 1756 + servers SHOULD NOT both run mutually distrusting services on 1757 + different ports of the same host and use cookies to store security- 1758 + sensitive information. 1759 + 1760 + Cookies do not provide isolation by scheme. Although most commonly 1761 + used with the http and https schemes, the cookies for a given host 1762 + might also be available to other schemes, such as ftp and gopher. 1763 + Although this lack of isolation by scheme is most apparent in non- 1764 + HTTP APIs that permit access to cookies (e.g., HTML's document.cookie 1765 + API), the lack of isolation by scheme is actually present in 1766 + requirements for processing cookies themselves (e.g., consider 1767 + retrieving a URI with the gopher scheme via HTTP). 1768 + 1769 + Cookies do not always provide isolation by path. Although the 1770 + network-level protocol does not send cookies stored for one path to 1771 + another, some user agents expose cookies via non-HTTP APIs, such as 1772 + HTML's document.cookie API. Because some of these user agents (e.g., 1773 + web browsers) do not isolate resources received from different paths, 1774 + a resource retrieved from one path might be able to access cookies 1775 + stored for another path. 1776 + 1777 + 8.6. Weak Integrity 1778 + 1779 + Cookies do not provide integrity guarantees for sibling domains (and 1780 + their subdomains). For example, consider foo.example.com and 1781 + bar.example.com. The foo.example.com server can set a cookie with a 1782 + Domain attribute of "example.com" (possibly overwriting an existing 1783 + "example.com" cookie set by bar.example.com), and the user agent will 1784 + include that cookie in HTTP requests to bar.example.com. In the 1785 + worst case, bar.example.com will be unable to distinguish this cookie 1786 + from a cookie it set itself. The foo.example.com server might be 1787 + able to leverage this ability to mount an attack against 1788 + bar.example.com. 1789 + 1790 + 1791 + 1792 + 1793 + 1794 + Barth Standards Track [Page 32] 1795 + 1796 + RFC 6265 HTTP State Management Mechanism April 2011 1797 + 1798 + 1799 + Even though the Set-Cookie header supports the Path attribute, the 1800 + Path attribute does not provide any integrity protection because the 1801 + user agent will accept an arbitrary Path attribute in a Set-Cookie 1802 + header. For example, an HTTP response to a request for 1803 + http://example.com/foo/bar can set a cookie with a Path attribute of 1804 + "/qux". Consequently, servers SHOULD NOT both run mutually 1805 + distrusting services on different paths of the same host and use 1806 + cookies to store security-sensitive information. 1807 + 1808 + An active network attacker can also inject cookies into the Cookie 1809 + header sent to https://example.com/ by impersonating a response from 1810 + http://example.com/ and injecting a Set-Cookie header. The HTTPS 1811 + server at example.com will be unable to distinguish these cookies 1812 + from cookies that it set itself in an HTTPS response. An active 1813 + network attacker might be able to leverage this ability to mount an 1814 + attack against example.com even if example.com uses HTTPS 1815 + exclusively. 1816 + 1817 + Servers can partially mitigate these attacks by encrypting and 1818 + signing the contents of their cookies. However, using cryptography 1819 + does not mitigate the issue completely because an attacker can replay 1820 + a cookie he or she received from the authentic example.com server in 1821 + the user's session, with unpredictable results. 1822 + 1823 + Finally, an attacker might be able to force the user agent to delete 1824 + cookies by storing a large number of cookies. Once the user agent 1825 + reaches its storage limit, the user agent will be forced to evict 1826 + some cookies. Servers SHOULD NOT rely upon user agents retaining 1827 + cookies. 1828 + 1829 + 8.7. Reliance on DNS 1830 + 1831 + Cookies rely upon the Domain Name System (DNS) for security. If the 1832 + DNS is partially or fully compromised, the cookie protocol might fail 1833 + to provide the security properties required by applications. 1834 + 1835 + 9. IANA Considerations 1836 + 1837 + The permanent message header field registry (see [RFC3864]) has been 1838 + updated with the following registrations. 1839 + 1840 + 1841 + 1842 + 1843 + 1844 + 1845 + 1846 + 1847 + 1848 + 1849 + 1850 + Barth Standards Track [Page 33] 1851 + 1852 + RFC 6265 HTTP State Management Mechanism April 2011 1853 + 1854 + 1855 + 9.1. Cookie 1856 + 1857 + Header field name: Cookie 1858 + 1859 + Applicable protocol: http 1860 + 1861 + Status: standard 1862 + 1863 + Author/Change controller: IETF 1864 + 1865 + Specification document: this specification (Section 5.4) 1866 + 1867 + 9.2. Set-Cookie 1868 + 1869 + Header field name: Set-Cookie 1870 + 1871 + Applicable protocol: http 1872 + 1873 + Status: standard 1874 + 1875 + Author/Change controller: IETF 1876 + 1877 + Specification document: this specification (Section 5.2) 1878 + 1879 + 9.3. Cookie2 1880 + 1881 + Header field name: Cookie2 1882 + 1883 + Applicable protocol: http 1884 + 1885 + Status: obsoleted 1886 + 1887 + Author/Change controller: IETF 1888 + 1889 + Specification document: [RFC2965] 1890 + 1891 + 9.4. Set-Cookie2 1892 + 1893 + Header field name: Set-Cookie2 1894 + 1895 + Applicable protocol: http 1896 + 1897 + Status: obsoleted 1898 + 1899 + Author/Change controller: IETF 1900 + 1901 + Specification document: [RFC2965] 1902 + 1903 + 1904 + 1905 + 1906 + Barth Standards Track [Page 34] 1907 + 1908 + RFC 6265 HTTP State Management Mechanism April 2011 1909 + 1910 + 1911 + 10. References 1912 + 1913 + 10.1. Normative References 1914 + 1915 + [RFC1034] Mockapetris, P., "Domain names - concepts and facilities", 1916 + STD 13, RFC 1034, November 1987. 1917 + 1918 + [RFC1123] Braden, R., "Requirements for Internet Hosts - Application 1919 + and Support", STD 3, RFC 1123, October 1989. 1920 + 1921 + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate 1922 + Requirement Levels", BCP 14, RFC 2119, March 1997. 1923 + 1924 + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., 1925 + Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext 1926 + Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999. 1927 + 1928 + [RFC3490] Faltstrom, P., Hoffman, P., and A. Costello, 1929 + "Internationalizing Domain Names in Applications (IDNA)", 1930 + RFC 3490, March 2003. 1931 + 1932 + See Section 6.3 for an explanation why the normative 1933 + reference to an obsoleted specification is needed. 1934 + 1935 + [RFC4790] Newman, C., Duerst, M., and A. Gulbrandsen, "Internet 1936 + Application Protocol Collation Registry", RFC 4790, 1937 + March 2007. 1938 + 1939 + [RFC5234] Crocker, D., Ed. and P. Overell, "Augmented BNF for Syntax 1940 + Specifications: ABNF", STD 68, RFC 5234, January 2008. 1941 + 1942 + [RFC5890] Klensin, J., "Internationalized Domain Names for 1943 + Applications (IDNA): Definitions and Document Framework", 1944 + RFC 5890, August 2010. 1945 + 1946 + [USASCII] American National Standards Institute, "Coded Character 1947 + Set -- 7-bit American Standard Code for Information 1948 + Interchange", ANSI X3.4, 1986. 1949 + 1950 + 10.2. Informative References 1951 + 1952 + [RFC2109] Kristol, D. and L. Montulli, "HTTP State Management 1953 + Mechanism", RFC 2109, February 1997. 1954 + 1955 + [RFC2965] Kristol, D. and L. Montulli, "HTTP State Management 1956 + Mechanism", RFC 2965, October 2000. 1957 + 1958 + 1959 + 1960 + 1961 + 1962 + Barth Standards Track [Page 35] 1963 + 1964 + RFC 6265 HTTP State Management Mechanism April 2011 1965 + 1966 + 1967 + [RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, May 2000. 1968 + 1969 + [Netscape] Netscape Communications Corp., "Persistent Client State -- 1970 + HTTP Cookies", 1999, <http://web.archive.org/web/ 1971 + 20020803110822/http://wp.netscape.com/newsref/std/ 1972 + cookie_spec.html>. 1973 + 1974 + [Kri2001] Kristol, D., "HTTP Cookies: Standards, Privacy, and 1975 + Politics", ACM Transactions on Internet Technology Vol. 1, 1976 + #2, November 2001, <http://arxiv.org/abs/cs.SE/0105018>. 1977 + 1978 + [RFC3629] Yergeau, F., "UTF-8, a transformation format of ISO 1979 + 10646", STD 63, RFC 3629, November 2003. 1980 + 1981 + [RFC4648] Josefsson, S., "The Base16, Base32, and Base64 Data 1982 + Encodings", RFC 4648, October 2006. 1983 + 1984 + [RFC3864] Klyne, G., Nottingham, M., and J. Mogul, "Registration 1985 + Procedures for Message Header Fields", BCP 90, RFC 3864, 1986 + September 2004. 1987 + 1988 + [RFC5895] Resnick, P. and P. Hoffman, "Mapping Characters for 1989 + Internationalized Domain Names in Applications (IDNA) 1990 + 2008", RFC 5895, September 2010. 1991 + 1992 + [UTS46] Davis, M. and M. Suignard, "Unicode IDNA Compatibility 1993 + Processing", Unicode Technical Standards # 46, 2010, 1994 + <http://unicode.org/reports/tr46/>. 1995 + 1996 + [CSRF] Barth, A., Jackson, C., and J. Mitchell, "Robust Defenses 1997 + for Cross-Site Request Forgery", 2008, 1998 + <http://portal.acm.org/citation.cfm?id=1455770.1455782>. 1999 + 2000 + [Aggarwal2010] 2001 + Aggarwal, G., Burzstein, E., Jackson, C., and D. Boneh, 2002 + "An Analysis of Private Browsing Modes in Modern 2003 + Browsers", 2010, <http://www.usenix.org/events/sec10/tech/ 2004 + full_papers/Aggarwal.pdf>. 2005 + 2006 + 2007 + 2008 + 2009 + 2010 + 2011 + 2012 + 2013 + 2014 + 2015 + 2016 + 2017 + 2018 + Barth Standards Track [Page 36] 2019 + 2020 + RFC 6265 HTTP State Management Mechanism April 2011 2021 + 2022 + 2023 + Appendix A. Acknowledgements 2024 + 2025 + This document borrows heavily from RFC 2109 [RFC2109]. We are 2026 + indebted to David M. Kristol and Lou Montulli for their efforts to 2027 + specify cookies. David M. Kristol, in particular, provided 2028 + invaluable advice on navigating the IETF process. We would also like 2029 + to thank Thomas Broyer, Tyler Close, Alissa Cooper, Bil Corry, 2030 + corvid, Lisa Dusseault, Roy T. Fielding, Blake Frantz, Anne van 2031 + Kesteren, Eran Hammer-Lahav, Jeff Hodges, Bjoern Hoehrmann, Achim 2032 + Hoffmann, Georg Koppen, Dean McNamee, Alexey Melnikov, Mark Miller, 2033 + Mark Pauley, Yngve N. Pettersen, Julian Reschke, Peter Saint-Andre, 2034 + Mark Seaborn, Maciej Stachowiak, Daniel Stenberg, Tatsuhiro 2035 + Tsujikawa, David Wagner, Dan Winship, and Dan Witte for their 2036 + valuable feedback on this document. 2037 + 2038 + Author's Address 2039 + 2040 + Adam Barth 2041 + University of California, Berkeley 2042 + 2043 + EMail: abarth@eecs.berkeley.edu 2044 + URI: http://www.adambarth.com/ 2045 + 2046 + 2047 + 2048 + 2049 + 2050 + 2051 + 2052 + 2053 + 2054 + 2055 + 2056 + 2057 + 2058 + 2059 + 2060 + 2061 + 2062 + 2063 + 2064 + 2065 + 2066 + 2067 + 2068 + 2069 + 2070 + 2071 + 2072 + 2073 + 2074 + Barth Standards Track [Page 37] 2075 +
+11
test/cookies.txt
··· 1 + # Netscape HTTP Cookie File 2 + # http://curl.haxx.se/rfc/cookie_spec.html 3 + # This is a generated file! Do not edit. 4 + 5 + example.com FALSE /foo/ FALSE 0 cookie-1 v$1 6 + .example.com TRUE /foo/ FALSE 0 cookie-2 v$2 7 + example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3 8 + example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4 9 + example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5 10 + #HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6 11 + #HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
+14
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries 4 + cookie 5 + cookie_jar 6 + alcotest 7 + eio 8 + eio.unix 9 + eio_main 10 + eio.mock 11 + fmt 12 + ptime 13 + re) 14 + (deps cookies.txt))
+6
test/test.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let () = Alcotest.run "cookie" [ Test_cookie.suite; Test_cookie_jar.suite ]
+3187
test/test_cookie.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Cookie 7 + open Cookie_jar 8 + 9 + let contains_substring s sub = Re.execp (Re.compile (Re.str sub)) s 10 + 11 + (* Testable helpers for Priority 2 types *) 12 + let expiration_testable : Cookie.Expiration.t Alcotest.testable = 13 + Alcotest.testable Cookie.Expiration.pp Cookie.Expiration.equal 14 + 15 + let span_testable : Ptime.Span.t Alcotest.testable = 16 + Alcotest.testable Ptime.Span.pp Ptime.Span.equal 17 + 18 + let same_site_testable : Cookie.Same_site.t Alcotest.testable = 19 + Alcotest.testable Cookie.Same_site.pp Cookie.Same_site.equal 20 + 21 + let cookie_testable : Cookie.t Alcotest.testable = 22 + Alcotest.testable 23 + (fun ppf c -> 24 + Fmt.pf ppf 25 + "{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \ 26 + partitioned=%b; expires=%a; max_age=%a; same_site=%a }" 27 + (Cookie.name c) (Cookie.value c) (Cookie.domain c) (Cookie.path c) 28 + (Cookie.secure c) (Cookie.http_only c) (Cookie.partitioned c) 29 + (Fmt.option (fun ppf e -> 30 + match e with 31 + | `Session -> Fmt.string ppf "Session" 32 + | `DateTime t -> Fmt.pf ppf "DateTime(%a)" Ptime.pp t)) 33 + (Cookie.expires c) (Fmt.option Ptime.Span.pp) (Cookie.max_age c) 34 + (Fmt.option (fun ppf -> function 35 + | `Strict -> Fmt.string ppf "Strict" 36 + | `Lax -> Fmt.string ppf "Lax" 37 + | `None -> Fmt.string ppf "None")) 38 + (Cookie.same_site c)) 39 + (fun c1 c2 -> 40 + let expires_equal e1 e2 = 41 + match (e1, e2) with 42 + | None, None -> true 43 + | Some `Session, Some `Session -> true 44 + | Some (`DateTime t1), Some (`DateTime t2) -> Ptime.equal t1 t2 45 + | _ -> false 46 + in 47 + Cookie.name c1 = Cookie.name c2 48 + && Cookie.value c1 = Cookie.value c2 49 + && Cookie.domain c1 = Cookie.domain c2 50 + && Cookie.path c1 = Cookie.path c2 51 + && Cookie.secure c1 = Cookie.secure c2 52 + && Cookie.http_only c1 = Cookie.http_only c2 53 + && Cookie.partitioned c1 = Cookie.partitioned c2 54 + && expires_equal (Cookie.expires c1) (Cookie.expires c2) 55 + && Option.equal Ptime.Span.equal (Cookie.max_age c1) (Cookie.max_age c2) 56 + && Option.equal ( = ) (Cookie.same_site c1) (Cookie.same_site c2)) 57 + 58 + let load_mozilla_cookies () = 59 + Eio_main.run @@ fun env -> 60 + let clock = Eio.Stdenv.clock env in 61 + let content = 62 + {|# Netscape HTTP Cookie File 63 + # http://curl.haxx.se/rfc/cookie_spec.html 64 + # This is a generated file! Do not edit. 65 + 66 + example.com FALSE /foo/ FALSE 0 cookie-1 v$1 67 + .example.com TRUE /foo/ FALSE 0 cookie-2 v$2 68 + example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3 69 + example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4 70 + example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5 71 + #HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6 72 + #HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7 73 + |} 74 + in 75 + let jar = from_mozilla_format ~clock content in 76 + let cookies = all_cookies jar in 77 + 78 + (* Check total number of cookies (should skip commented lines) *) 79 + Alcotest.(check int) "cookie count" 5 (List.length cookies); 80 + Alcotest.(check int) "count function" 5 (count jar); 81 + Alcotest.(check bool) "not empty" false (is_empty jar); 82 + 83 + let find_cookie name = List.find (fun c -> Cookie.name c = name) cookies in 84 + 85 + (* Test cookie-1: session cookie on exact domain *) 86 + let cookie1 = find_cookie "cookie-1" in 87 + Alcotest.(check string) 88 + "cookie-1 domain" "example.com" (Cookie.domain cookie1); 89 + Alcotest.(check string) "cookie-1 path" "/foo/" (Cookie.path cookie1); 90 + Alcotest.(check string) "cookie-1 name" "cookie-1" (Cookie.name cookie1); 91 + Alcotest.(check string) "cookie-1 value" "v$1" (Cookie.value cookie1); 92 + Alcotest.(check bool) "cookie-1 secure" false (Cookie.secure cookie1); 93 + Alcotest.(check bool) "cookie-1 http_only" false (Cookie.http_only cookie1); 94 + Alcotest.(check (option expiration_testable)) 95 + "cookie-1 expires" None (Cookie.expires cookie1); 96 + Alcotest.( 97 + check 98 + (option 99 + (Alcotest.testable 100 + (fun ppf -> function 101 + | `Strict -> Format.pp_print_string ppf "Strict" 102 + | `Lax -> Format.pp_print_string ppf "Lax" 103 + | `None -> Format.pp_print_string ppf "None") 104 + ( = )))) 105 + "cookie-1 same_site" None (Cookie.same_site cookie1); 106 + 107 + (* Test cookie-2: session cookie on subdomain pattern *) 108 + let cookie2 = find_cookie "cookie-2" in 109 + Alcotest.(check string) 110 + "cookie-2 domain" "example.com" (Cookie.domain cookie2); 111 + Alcotest.(check string) "cookie-2 path" "/foo/" (Cookie.path cookie2); 112 + Alcotest.(check string) "cookie-2 name" "cookie-2" (Cookie.name cookie2); 113 + Alcotest.(check string) "cookie-2 value" "v$2" (Cookie.value cookie2); 114 + Alcotest.(check bool) "cookie-2 secure" false (Cookie.secure cookie2); 115 + Alcotest.(check bool) "cookie-2 http_only" false (Cookie.http_only cookie2); 116 + Alcotest.(check (option expiration_testable)) 117 + "cookie-2 expires" None (Cookie.expires cookie2); 118 + 119 + (* Test cookie-3: non-session cookie with expiry *) 120 + let cookie3 = find_cookie "cookie-3" in 121 + let expected_expiry = Ptime.of_float_s 1257894000.0 in 122 + Alcotest.(check string) 123 + "cookie-3 domain" "example.com" (Cookie.domain cookie3); 124 + Alcotest.(check string) "cookie-3 path" "/foo/" (Cookie.path cookie3); 125 + Alcotest.(check string) "cookie-3 name" "cookie-3" (Cookie.name cookie3); 126 + Alcotest.(check string) "cookie-3 value" "v$3" (Cookie.value cookie3); 127 + Alcotest.(check bool) "cookie-3 secure" false (Cookie.secure cookie3); 128 + Alcotest.(check bool) "cookie-3 http_only" false (Cookie.http_only cookie3); 129 + begin match expected_expiry with 130 + | Some t -> 131 + Alcotest.(check (option expiration_testable)) 132 + "cookie-3 expires" 133 + (Some (`DateTime t)) 134 + (Cookie.expires cookie3) 135 + | None -> Alcotest.fail "Expected expiry time for cookie-3" 136 + end; 137 + 138 + (* Test cookie-4: another non-session cookie *) 139 + let cookie4 = find_cookie "cookie-4" in 140 + Alcotest.(check string) 141 + "cookie-4 domain" "example.com" (Cookie.domain cookie4); 142 + Alcotest.(check string) "cookie-4 path" "/foo/" (Cookie.path cookie4); 143 + Alcotest.(check string) "cookie-4 name" "cookie-4" (Cookie.name cookie4); 144 + Alcotest.(check string) "cookie-4 value" "v$4" (Cookie.value cookie4); 145 + Alcotest.(check bool) "cookie-4 secure" false (Cookie.secure cookie4); 146 + Alcotest.(check bool) "cookie-4 http_only" false (Cookie.http_only cookie4); 147 + begin match expected_expiry with 148 + | Some t -> 149 + Alcotest.(check (option expiration_testable)) 150 + "cookie-4 expires" 151 + (Some (`DateTime t)) 152 + (Cookie.expires cookie4) 153 + | None -> Alcotest.fail "Expected expiry time for cookie-4" 154 + end; 155 + 156 + (* Test cookie-5: secure cookie *) 157 + let cookie5 = find_cookie "cookie-5" in 158 + Alcotest.(check string) 159 + "cookie-5 domain" "example.com" (Cookie.domain cookie5); 160 + Alcotest.(check string) "cookie-5 path" "/foo/" (Cookie.path cookie5); 161 + Alcotest.(check string) "cookie-5 name" "cookie-5" (Cookie.name cookie5); 162 + Alcotest.(check string) "cookie-5 value" "v$5" (Cookie.value cookie5); 163 + Alcotest.(check bool) "cookie-5 secure" true (Cookie.secure cookie5); 164 + Alcotest.(check bool) "cookie-5 http_only" false (Cookie.http_only cookie5); 165 + begin match expected_expiry with 166 + | Some t -> 167 + Alcotest.(check (option expiration_testable)) 168 + "cookie-5 expires" 169 + (Some (`DateTime t)) 170 + (Cookie.expires cookie5) 171 + | None -> Alcotest.fail "Expected expiry time for cookie-5" 172 + end 173 + 174 + let load_from_file () = 175 + Eio_main.run @@ fun env -> 176 + (* This test loads from the actual test/cookies.txt file using the load function *) 177 + let clock = Eio.Stdenv.clock env in 178 + let cwd = Eio.Stdenv.cwd env in 179 + let cookie_path = Eio.Path.(cwd / "cookies.txt") in 180 + let jar = load ~clock cookie_path in 181 + let cookies = all_cookies jar in 182 + 183 + (* Should have the same 5 cookies as the string test *) 184 + Alcotest.(check int) "file load cookie count" 5 (List.length cookies); 185 + 186 + let find_cookie name = List.find (fun c -> Cookie.name c = name) cookies in 187 + 188 + (* Verify a few key cookies are loaded correctly *) 189 + let cookie1 = find_cookie "cookie-1" in 190 + Alcotest.(check string) "file cookie-1 value" "v$1" (Cookie.value cookie1); 191 + Alcotest.(check string) 192 + "file cookie-1 domain" "example.com" (Cookie.domain cookie1); 193 + Alcotest.(check bool) "file cookie-1 secure" false (Cookie.secure cookie1); 194 + Alcotest.(check (option expiration_testable)) 195 + "file cookie-1 expires" None (Cookie.expires cookie1); 196 + 197 + let cookie5 = find_cookie "cookie-5" in 198 + Alcotest.(check string) "file cookie-5 value" "v$5" (Cookie.value cookie5); 199 + Alcotest.(check bool) "file cookie-5 secure" true (Cookie.secure cookie5); 200 + let expected_expiry = Ptime.of_float_s 1257894000.0 in 201 + begin match expected_expiry with 202 + | Some t -> 203 + Alcotest.(check (option expiration_testable)) 204 + "file cookie-5 expires" 205 + (Some (`DateTime t)) 206 + (Cookie.expires cookie5) 207 + | None -> Alcotest.fail "Expected expiry time for cookie-5" 208 + end; 209 + 210 + (* Verify subdomain cookie *) 211 + let cookie2 = find_cookie "cookie-2" in 212 + Alcotest.(check string) 213 + "file cookie-2 domain" "example.com" (Cookie.domain cookie2); 214 + Alcotest.(check (option expiration_testable)) 215 + "file cookie-2 expires" None (Cookie.expires cookie2) 216 + 217 + let cookie_matching () = 218 + Eio_main.run @@ fun env -> 219 + let clock = Eio.Stdenv.clock env in 220 + let jar = v () in 221 + 222 + (* Add test cookies with different domain patterns *) 223 + let exact_cookie = 224 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1" 225 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 226 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 227 + in 228 + let subdomain_cookie = 229 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"subdomain" ~value:"test2" 230 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 231 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 232 + in 233 + let secure_cookie = 234 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3" 235 + ~secure:true ~http_only:false ?expires:None ?same_site:None ?max_age:None 236 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 237 + in 238 + 239 + add_cookie jar exact_cookie; 240 + add_cookie jar subdomain_cookie; 241 + add_cookie jar secure_cookie; 242 + 243 + (* Test exact domain matching - all three cookies should match example.com *) 244 + let cookies_http = 245 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 246 + ~is_secure:false 247 + in 248 + Alcotest.(check int) "http cookies count" 2 (List.length cookies_http); 249 + 250 + let cookies_https = 251 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 252 + ~is_secure:true 253 + in 254 + Alcotest.(check int) "https cookies count" 3 (List.length cookies_https); 255 + 256 + (* Test subdomain matching - all cookies should match subdomains now *) 257 + let cookies_sub = 258 + Cookie_jar.cookies jar ~clock ~domain:"sub.example.com" ~path:"/" 259 + ~is_secure:false 260 + in 261 + Alcotest.(check int) "subdomain cookies count" 2 (List.length cookies_sub) 262 + 263 + let empty_jar () = 264 + Eio_main.run @@ fun env -> 265 + let clock = Eio.Stdenv.clock env in 266 + let jar = v () in 267 + Alcotest.(check bool) "empty jar" true (is_empty jar); 268 + Alcotest.(check int) "empty count" 0 (count jar); 269 + Alcotest.(check (list cookie_testable)) "empty cookies" [] (all_cookies jar); 270 + 271 + let cookies = 272 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 273 + ~is_secure:false 274 + in 275 + Alcotest.(check int) "no matching cookies" 0 (List.length cookies) 276 + 277 + let round_trip_mozilla_format () = 278 + Eio_main.run @@ fun env -> 279 + let clock = Eio.Stdenv.clock env in 280 + let jar = v () in 281 + 282 + let cookie = 283 + let expires = 284 + match Ptime.of_float_s 1257894000.0 with 285 + | Some t -> Some (`DateTime t) 286 + | None -> None 287 + in 288 + Cookie.v ~domain:"example.com" ~path:"/test/" ~name:"test" ~value:"value" 289 + ~secure:true ~http_only:false ?expires ~same_site:`Strict ?max_age:None 290 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 291 + in 292 + 293 + add_cookie jar cookie; 294 + 295 + (* Convert to Mozilla format and back *) 296 + let mozilla_format = to_mozilla_format jar in 297 + let jar2 = from_mozilla_format ~clock mozilla_format in 298 + let cookies2 = all_cookies jar2 in 299 + 300 + Alcotest.(check int) "round trip count" 1 (List.length cookies2); 301 + let cookie2 = List.hd cookies2 in 302 + Alcotest.(check string) "round trip name" "test" (Cookie.name cookie2); 303 + Alcotest.(check string) "round trip value" "value" (Cookie.value cookie2); 304 + Alcotest.(check string) 305 + "round trip domain" "example.com" (Cookie.domain cookie2); 306 + Alcotest.(check string) "round trip path" "/test/" (Cookie.path cookie2); 307 + Alcotest.(check bool) "round trip secure" true (Cookie.secure cookie2); 308 + (* Note: http_only and same_site are lost in Mozilla format *) 309 + begin match Ptime.of_float_s 1257894000.0 with 310 + | Some t -> 311 + Alcotest.(check (option expiration_testable)) 312 + "round trip expires" 313 + (Some (`DateTime t)) 314 + (Cookie.expires cookie2) 315 + | None -> Alcotest.fail "Expected expiry time" 316 + end 317 + 318 + let cookie_expiry_mock_clock () = 319 + Eio_mock.Backend.run @@ fun () -> 320 + let clock = Eio_mock.Clock.make () in 321 + 322 + (* Start at time 1000.0 for convenience *) 323 + Eio_mock.Clock.set_time clock 1000.0; 324 + 325 + let jar = v () in 326 + 327 + (* Add a cookie that expires at time 1500.0 (expires in 500 seconds) *) 328 + let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in 329 + let cookie1 = 330 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"expires_soon" 331 + ~value:"value1" ~secure:false ~http_only:false 332 + ~expires:(`DateTime expires_soon) ?same_site:None ?max_age:None 333 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 334 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 335 + () 336 + in 337 + 338 + (* Add a cookie that expires at time 2000.0 (expires in 1000 seconds) *) 339 + let expires_later = Ptime.of_float_s 2000.0 |> Option.get in 340 + let cookie2 = 341 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"expires_later" 342 + ~value:"value2" ~secure:false ~http_only:false 343 + ~expires:(`DateTime expires_later) ?same_site:None ?max_age:None 344 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 345 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 346 + () 347 + in 348 + 349 + (* Add a session cookie (no expiry) *) 350 + let cookie3 = 351 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3" 352 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 353 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 354 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 355 + () 356 + in 357 + 358 + add_cookie jar cookie1; 359 + add_cookie jar cookie2; 360 + add_cookie jar cookie3; 361 + 362 + Alcotest.(check int) "initial count" 3 (count jar); 363 + 364 + (* Advance time to 1600.0 - first cookie should expire *) 365 + Eio_mock.Clock.set_time clock 1600.0; 366 + clear_expired jar ~clock; 367 + 368 + Alcotest.(check int) "after first expiry" 2 (count jar); 369 + 370 + let cookies = all_cookies jar in 371 + let names = List.map Cookie.name cookies |> List.sort String.compare in 372 + Alcotest.(check (list string)) 373 + "remaining cookies after 1600s" 374 + [ "expires_later"; "session" ] 375 + names; 376 + 377 + (* Advance time to 2100.0 - second cookie should expire *) 378 + Eio_mock.Clock.set_time clock 2100.0; 379 + clear_expired jar ~clock; 380 + 381 + Alcotest.(check int) "after second expiry" 1 (count jar); 382 + 383 + let remaining = all_cookies jar in 384 + Alcotest.(check string) 385 + "only session cookie remains" "session" 386 + (Cookie.name (List.hd remaining)) 387 + 388 + let cookies_filters_expired () = 389 + Eio_mock.Backend.run @@ fun () -> 390 + let clock = Eio_mock.Clock.make () in 391 + Eio_mock.Clock.set_time clock 1000.0; 392 + 393 + let jar = v () in 394 + 395 + (* Add an expired cookie (expired at time 500) *) 396 + let expired = Ptime.of_float_s 500.0 |> Option.get in 397 + let cookie_expired = 398 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"expired" ~value:"old" 399 + ~secure:false ~http_only:false ~expires:(`DateTime expired) 400 + ~creation_time:(Ptime.of_float_s 100.0 |> Option.get) 401 + ~last_access:(Ptime.of_float_s 100.0 |> Option.get) 402 + () 403 + in 404 + 405 + (* Add a valid cookie (expires at time 2000) *) 406 + let valid_time = Ptime.of_float_s 2000.0 |> Option.get in 407 + let cookie_valid = 408 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"valid" ~value:"current" 409 + ~secure:false ~http_only:false ~expires:(`DateTime valid_time) 410 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 411 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 412 + () 413 + in 414 + 415 + (* Add a session cookie (no expiry) *) 416 + let cookie_session = 417 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"session" ~value:"sess" 418 + ~secure:false ~http_only:false 419 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 420 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 421 + () 422 + in 423 + 424 + add_cookie jar cookie_expired; 425 + add_cookie jar cookie_valid; 426 + add_cookie jar cookie_session; 427 + 428 + (* all_cookies returns all including expired (for inspection) *) 429 + Alcotest.(check int) 430 + "all_cookies includes expired" 3 431 + (List.length (all_cookies jar)); 432 + 433 + (* cookies should automatically filter out expired cookies *) 434 + let cookies = 435 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 436 + ~is_secure:false 437 + in 438 + Alcotest.(check int) "cookies filters expired" 2 (List.length cookies); 439 + 440 + let names = List.map Cookie.name cookies |> List.sort String.compare in 441 + Alcotest.(check (list string)) 442 + "only non-expired cookies returned" [ "session"; "valid" ] names 443 + 444 + let maxage_parsing_mockclock () = 445 + Eio_mock.Backend.run @@ fun () -> 446 + let clock = Eio_mock.Clock.make () in 447 + 448 + (* Start at a known time *) 449 + Eio_mock.Clock.set_time clock 5000.0; 450 + 451 + (* Parse a Set-Cookie header with Max-Age *) 452 + let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in 453 + let cookie_opt = 454 + of_set_cookie_header 455 + ~now:(fun () -> 456 + Ptime.of_float_s (Eio.Time.now clock) 457 + |> Option.value ~default:Ptime.epoch) 458 + ~domain:"example.com" ~path:"/" header 459 + in 460 + 461 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 462 + 463 + let cookie = Result.get_ok cookie_opt in 464 + Alcotest.(check string) "cookie name" "session" (Cookie.name cookie); 465 + Alcotest.(check string) "cookie value" "abc123" (Cookie.value cookie); 466 + Alcotest.(check bool) "cookie secure" true (Cookie.secure cookie); 467 + Alcotest.(check bool) "cookie http_only" true (Cookie.http_only cookie); 468 + 469 + (* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *) 470 + let expected_expiry = Ptime.of_float_s 8600.0 in 471 + begin match expected_expiry with 472 + | Some t -> 473 + Alcotest.(check (option expiration_testable)) 474 + "expires set from max-age" 475 + (Some (`DateTime t)) 476 + (Cookie.expires cookie) 477 + | None -> Alcotest.fail "Expected expiry time" 478 + end; 479 + 480 + (* Verify creation time matches clock time *) 481 + let expected_creation = Ptime.of_float_s 5000.0 in 482 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 483 + "creation time" expected_creation 484 + (Some (Cookie.creation_time cookie)) 485 + 486 + let last_access_time_mockclock () = 487 + Eio_mock.Backend.run @@ fun () -> 488 + let clock = Eio_mock.Clock.make () in 489 + 490 + (* Start at time 3000.0 *) 491 + Eio_mock.Clock.set_time clock 3000.0; 492 + 493 + let jar = v () in 494 + 495 + (* Add a cookie *) 496 + let cookie = 497 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 498 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 499 + ~creation_time:(Ptime.of_float_s 3000.0 |> Option.get) 500 + ~last_access:(Ptime.of_float_s 3000.0 |> Option.get) 501 + () 502 + in 503 + add_cookie jar cookie; 504 + 505 + (* Verify initial last access time *) 506 + let cookies1 = all_cookies jar in 507 + let cookie1 = List.hd cookies1 in 508 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 509 + "initial last access" (Ptime.of_float_s 3000.0) 510 + (Some (Cookie.last_access cookie1)); 511 + 512 + (* Advance time to 4000.0 *) 513 + Eio_mock.Clock.set_time clock 4000.0; 514 + 515 + (* Get cookies, which should update last access time to current clock time *) 516 + let _retrieved = 517 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 518 + ~is_secure:false 519 + in 520 + 521 + (* Verify last access time was updated to the new clock time *) 522 + let cookies2 = all_cookies jar in 523 + let cookie2 = List.hd cookies2 in 524 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 525 + "updated last access" (Ptime.of_float_s 4000.0) 526 + (Some (Cookie.last_access cookie2)) 527 + 528 + let set_cookie_header_expires () = 529 + Eio_mock.Backend.run @@ fun () -> 530 + let clock = Eio_mock.Clock.make () in 531 + 532 + (* Start at a known time *) 533 + Eio_mock.Clock.set_time clock 6000.0; 534 + 535 + (* Use RFC3339 format which is what Ptime.of_rfc3339 expects *) 536 + let header = 537 + "id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com" 538 + in 539 + let cookie_opt = 540 + of_set_cookie_header 541 + ~now:(fun () -> 542 + Ptime.of_float_s (Eio.Time.now clock) 543 + |> Option.value ~default:Ptime.epoch) 544 + ~domain:"example.com" ~path:"/" header 545 + in 546 + 547 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 548 + 549 + let cookie = Result.get_ok cookie_opt in 550 + Alcotest.(check string) "cookie name" "id" (Cookie.name cookie); 551 + Alcotest.(check string) "cookie value" "xyz789" (Cookie.value cookie); 552 + Alcotest.(check string) "cookie domain" "example.com" (Cookie.domain cookie); 553 + Alcotest.(check string) "cookie path" "/" (Cookie.path cookie); 554 + 555 + (* Verify expires is parsed correctly *) 556 + Alcotest.(check bool) 557 + "has expiry" true 558 + (Option.is_some (Cookie.expires cookie)); 559 + 560 + (* Verify the specific expiry time parsed from the RFC3339 date *) 561 + let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in 562 + match expected_expiry with 563 + | Ok (time, _, _) -> 564 + Alcotest.(check (option expiration_testable)) 565 + "expires matches parsed value" 566 + (Some (`DateTime time)) 567 + (Cookie.expires cookie) 568 + | Error _ -> Alcotest.fail "Failed to parse expected expiry time" 569 + 570 + let samesite_none_validation () = 571 + Eio_mock.Backend.run @@ fun () -> 572 + let clock = Eio_mock.Clock.make () in 573 + 574 + (* Start at a known time *) 575 + Eio_mock.Clock.set_time clock 7000.0; 576 + 577 + (* This should be rejected: SameSite=None without Secure *) 578 + let invalid_header = "token=abc; SameSite=None" in 579 + let cookie_opt = 580 + of_set_cookie_header 581 + ~now:(fun () -> 582 + Ptime.of_float_s (Eio.Time.now clock) 583 + |> Option.value ~default:Ptime.epoch) 584 + ~domain:"example.com" ~path:"/" invalid_header 585 + in 586 + 587 + Alcotest.(check bool) 588 + "invalid cookie rejected" true 589 + (Result.is_error cookie_opt); 590 + 591 + (* This should be accepted: SameSite=None with Secure *) 592 + let valid_header = "token=abc; SameSite=None; Secure" in 593 + let cookie_opt2 = 594 + of_set_cookie_header 595 + ~now:(fun () -> 596 + Ptime.of_float_s (Eio.Time.now clock) 597 + |> Option.value ~default:Ptime.epoch) 598 + ~domain:"example.com" ~path:"/" valid_header 599 + in 600 + 601 + Alcotest.(check bool) "valid cookie accepted" true (Result.is_ok cookie_opt2); 602 + 603 + let cookie = Result.get_ok cookie_opt2 in 604 + Alcotest.(check bool) "cookie is secure" true (Cookie.secure cookie); 605 + Alcotest.( 606 + check 607 + (option 608 + (Alcotest.testable 609 + (fun ppf -> function 610 + | `Strict -> Format.pp_print_string ppf "Strict" 611 + | `Lax -> Format.pp_print_string ppf "Lax" 612 + | `None -> Format.pp_print_string ppf "None") 613 + ( = )))) 614 + "samesite is None" (Some `None) (Cookie.same_site cookie) 615 + 616 + let domain_normalization () = 617 + Eio_mock.Backend.run @@ fun () -> 618 + let clock = Eio_mock.Clock.make () in 619 + Eio_mock.Clock.set_time clock 1000.0; 620 + 621 + (* Test parsing ".example.com" stores as "example.com" *) 622 + let header = "test=value; Domain=.example.com" in 623 + let cookie_opt = 624 + of_set_cookie_header 625 + ~now:(fun () -> 626 + Ptime.of_float_s (Eio.Time.now clock) 627 + |> Option.value ~default:Ptime.epoch) 628 + ~domain:"example.com" ~path:"/" header 629 + in 630 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 631 + let cookie = Result.get_ok cookie_opt in 632 + Alcotest.(check string) 633 + "domain normalized" "example.com" (Cookie.domain cookie); 634 + 635 + (* Test round-trip through Mozilla format normalizes domains *) 636 + let jar = v () in 637 + let cookie = 638 + Cookie.v ~domain:".example.com" ~path:"/" ~name:"test" ~value:"val" 639 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 640 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 641 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 642 + () 643 + in 644 + add_cookie jar cookie; 645 + 646 + let mozilla_format = to_mozilla_format jar in 647 + let jar2 = from_mozilla_format ~clock mozilla_format in 648 + let cookies2 = all_cookies jar2 in 649 + Alcotest.(check int) "one cookie" 1 (List.length cookies2); 650 + Alcotest.(check string) 651 + "domain normalized after round-trip" "example.com" 652 + (Cookie.domain (List.hd cookies2)) 653 + 654 + let max_age_stored_separately () = 655 + Eio_mock.Backend.run @@ fun () -> 656 + let clock = Eio_mock.Clock.make () in 657 + Eio_mock.Clock.set_time clock 5000.0; 658 + 659 + (* Parse a Set-Cookie header with Max-Age *) 660 + let header = "session=abc123; Max-Age=3600" in 661 + let cookie_opt = 662 + of_set_cookie_header 663 + ~now:(fun () -> 664 + Ptime.of_float_s (Eio.Time.now clock) 665 + |> Option.value ~default:Ptime.epoch) 666 + ~domain:"example.com" ~path:"/" header 667 + in 668 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 669 + 670 + let cookie = Result.get_ok cookie_opt in 671 + 672 + (* Verify max_age is stored as a Ptime.Span *) 673 + Alcotest.(check bool) 674 + "max_age is set" true 675 + (Option.is_some (Cookie.max_age cookie)); 676 + let max_age_span = Option.get (Cookie.max_age cookie) in 677 + Alcotest.(check (option int)) 678 + "max_age is 3600 seconds" (Some 3600) 679 + (Ptime.Span.to_int_s max_age_span); 680 + 681 + (* Verify expires is also computed correctly *) 682 + let expected_expiry = Ptime.of_float_s 8600.0 in 683 + begin match expected_expiry with 684 + | Some t -> 685 + Alcotest.(check (option expiration_testable)) 686 + "expires computed from max-age" 687 + (Some (`DateTime t)) 688 + (Cookie.expires cookie) 689 + | None -> Alcotest.fail "Expected expiry time" 690 + end 691 + 692 + let maxage_negative_zero () = 693 + Eio_mock.Backend.run @@ fun () -> 694 + let clock = Eio_mock.Clock.make () in 695 + Eio_mock.Clock.set_time clock 5000.0; 696 + 697 + (* Parse a Set-Cookie header with negative Max-Age *) 698 + let header = "session=abc123; Max-Age=-100" in 699 + let cookie_opt = 700 + of_set_cookie_header 701 + ~now:(fun () -> 702 + Ptime.of_float_s (Eio.Time.now clock) 703 + |> Option.value ~default:Ptime.epoch) 704 + ~domain:"example.com" ~path:"/" header 705 + in 706 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 707 + 708 + let cookie = Result.get_ok cookie_opt in 709 + 710 + (* Verify max_age is stored as 0 per RFC 6265 *) 711 + Alcotest.(check bool) 712 + "max_age is set" true 713 + (Option.is_some (Cookie.max_age cookie)); 714 + let max_age_span = Option.get (Cookie.max_age cookie) in 715 + Alcotest.(check (option int)) 716 + "negative max_age becomes 0" (Some 0) 717 + (Ptime.Span.to_int_s max_age_span); 718 + 719 + (* Verify expires is computed with 0 seconds *) 720 + let expected_expiry = Ptime.of_float_s 5000.0 in 721 + begin match expected_expiry with 722 + | Some t -> 723 + Alcotest.(check (option expiration_testable)) 724 + "expires computed with 0 seconds" 725 + (Some (`DateTime t)) 726 + (Cookie.expires cookie) 727 + | None -> Alcotest.fail "Expected expiry time" 728 + end 729 + 730 + let string_contains_substring s sub = 731 + try 732 + let len = String.length sub in 733 + let rec search i = 734 + if i + len > String.length s then false 735 + else if String.sub s i len = sub then true 736 + else search (i + 1) 737 + in 738 + search 0 739 + with Invalid_argument _ -> false 740 + 741 + let set_cookie_header_maxage () = 742 + Eio_mock.Backend.run @@ fun () -> 743 + let clock = Eio_mock.Clock.make () in 744 + Eio_mock.Clock.set_time clock 5000.0; 745 + 746 + (* Create a cookie with max_age *) 747 + let max_age_span = Ptime.Span.of_int_s 3600 in 748 + let expires_time = Ptime.of_float_s 8600.0 |> Option.get in 749 + let cookie = 750 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123" 751 + ~secure:true ~http_only:true 752 + ?expires:(Some (`DateTime expires_time)) 753 + ?max_age:(Some max_age_span) ?same_site:(Some `Strict) 754 + ~creation_time:(Ptime.of_float_s 5000.0 |> Option.get) 755 + ~last_access:(Ptime.of_float_s 5000.0 |> Option.get) 756 + () 757 + in 758 + 759 + let header = set_cookie_header cookie in 760 + 761 + (* Verify the header includes Max-Age *) 762 + Alcotest.(check bool) 763 + "header includes Max-Age" true 764 + (string_contains_substring header "Max-Age=3600"); 765 + 766 + (* Verify the header includes Expires *) 767 + Alcotest.(check bool) 768 + "header includes Expires" true 769 + (string_contains_substring header "Expires="); 770 + 771 + (* Verify the header includes other attributes *) 772 + Alcotest.(check bool) 773 + "header includes Secure" true 774 + (string_contains_substring header "Secure"); 775 + Alcotest.(check bool) 776 + "header includes HttpOnly" true 777 + (string_contains_substring header "HttpOnly"); 778 + Alcotest.(check bool) 779 + "header includes SameSite" true 780 + (string_contains_substring header "SameSite=Strict") 781 + 782 + let max_age_round_trip () = 783 + Eio_mock.Backend.run @@ fun () -> 784 + let clock = Eio_mock.Clock.make () in 785 + Eio_mock.Clock.set_time clock 5000.0; 786 + 787 + (* Parse a cookie with Max-Age *) 788 + let header = "session=xyz; Max-Age=7200; Secure; HttpOnly" in 789 + let cookie_opt = 790 + of_set_cookie_header 791 + ~now:(fun () -> 792 + Ptime.of_float_s (Eio.Time.now clock) 793 + |> Option.value ~default:Ptime.epoch) 794 + ~domain:"example.com" ~path:"/" header 795 + in 796 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 797 + let cookie = Result.get_ok cookie_opt in 798 + 799 + (* Generate Set-Cookie header from the cookie *) 800 + let set_cookie_header = set_cookie_header cookie in 801 + 802 + (* Parse it back *) 803 + Eio_mock.Clock.set_time clock 5000.0; 804 + (* Reset clock to same time *) 805 + let cookie2_opt = 806 + of_set_cookie_header 807 + ~now:(fun () -> 808 + Ptime.of_float_s (Eio.Time.now clock) 809 + |> Option.value ~default:Ptime.epoch) 810 + ~domain:"example.com" ~path:"/" set_cookie_header 811 + in 812 + Alcotest.(check bool) "cookie re-parsed" true (Result.is_ok cookie2_opt); 813 + let cookie2 = Result.get_ok cookie2_opt in 814 + 815 + (* Verify max_age is preserved *) 816 + Alcotest.(check (option int)) 817 + "max_age preserved" 818 + (Ptime.Span.to_int_s (Option.get (Cookie.max_age cookie))) 819 + (Ptime.Span.to_int_s (Option.get (Cookie.max_age cookie2))) 820 + 821 + let domain_matching () = 822 + Eio_mock.Backend.run @@ fun () -> 823 + let clock = Eio_mock.Clock.make () in 824 + Eio_mock.Clock.set_time clock 2000.0; 825 + 826 + let jar = v () in 827 + 828 + (* Create a cookie with domain "example.com" *) 829 + let cookie = 830 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 831 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 832 + ~creation_time:(Ptime.of_float_s 2000.0 |> Option.get) 833 + ~last_access:(Ptime.of_float_s 2000.0 |> Option.get) 834 + () 835 + in 836 + add_cookie jar cookie; 837 + 838 + (* Test "example.com" cookie matches "example.com" request *) 839 + let cookies1 = 840 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 841 + ~is_secure:false 842 + in 843 + Alcotest.(check int) "matches exact domain" 1 (List.length cookies1); 844 + 845 + (* Test "example.com" cookie matches "sub.example.com" request *) 846 + let cookies2 = 847 + Cookie_jar.cookies jar ~clock ~domain:"sub.example.com" ~path:"/" 848 + ~is_secure:false 849 + in 850 + Alcotest.(check int) "matches subdomain" 1 (List.length cookies2); 851 + 852 + (* Test "example.com" cookie matches "deep.sub.example.com" request *) 853 + let cookies3 = 854 + Cookie_jar.cookies jar ~clock ~domain:"deep.sub.example.com" ~path:"/" 855 + ~is_secure:false 856 + in 857 + Alcotest.(check int) "matches deep subdomain" 1 (List.length cookies3); 858 + 859 + (* Test "example.com" cookie doesn't match "notexample.com" *) 860 + let cookies4 = 861 + Cookie_jar.cookies jar ~clock ~domain:"notexample.com" ~path:"/" 862 + ~is_secure:false 863 + in 864 + Alcotest.(check int) "doesn't match different domain" 0 (List.length cookies4); 865 + 866 + (* Test "example.com" cookie doesn't match "fakeexample.com" *) 867 + let cookies5 = 868 + Cookie_jar.cookies jar ~clock ~domain:"fakeexample.com" ~path:"/" 869 + ~is_secure:false 870 + in 871 + Alcotest.(check int) "doesn't match prefix domain" 0 (List.length cookies5) 872 + 873 + (** {1 HTTP Date Parsing Tests} *) 874 + 875 + let http_date_fmt1 () = 876 + Eio_mock.Backend.run @@ fun () -> 877 + let clock = Eio_mock.Clock.make () in 878 + Eio_mock.Clock.set_time clock 1000.0; 879 + 880 + (* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *) 881 + let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" in 882 + let cookie_opt = 883 + of_set_cookie_header 884 + ~now:(fun () -> 885 + Ptime.of_float_s (Eio.Time.now clock) 886 + |> Option.value ~default:Ptime.epoch) 887 + ~domain:"example.com" ~path:"/" header 888 + in 889 + Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt); 890 + 891 + let cookie = Result.get_ok cookie_opt in 892 + Alcotest.(check bool) 893 + "FMT1 has expiry" true 894 + (Option.is_some (Cookie.expires cookie)); 895 + 896 + (* Verify the parsed time matches expected value *) 897 + let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in 898 + begin match expected with 899 + | Some t -> 900 + Alcotest.(check (option expiration_testable)) 901 + "FMT1 expiry correct" 902 + (Some (`DateTime t)) 903 + (Cookie.expires cookie) 904 + | None -> Alcotest.fail "Expected expiry time for FMT1" 905 + end 906 + 907 + let http_date_fmt2 () = 908 + Eio_mock.Backend.run @@ fun () -> 909 + let clock = Eio_mock.Clock.make () in 910 + Eio_mock.Clock.set_time clock 1000.0; 911 + 912 + (* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *) 913 + let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" in 914 + let cookie_opt = 915 + of_set_cookie_header 916 + ~now:(fun () -> 917 + Ptime.of_float_s (Eio.Time.now clock) 918 + |> Option.value ~default:Ptime.epoch) 919 + ~domain:"example.com" ~path:"/" header 920 + in 921 + Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt); 922 + 923 + let cookie = Result.get_ok cookie_opt in 924 + Alcotest.(check bool) 925 + "FMT2 has expiry" true 926 + (Option.is_some (Cookie.expires cookie)); 927 + 928 + (* Year 15 should be normalized to 2015 *) 929 + let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in 930 + begin match expected with 931 + | Some t -> 932 + Alcotest.(check (option expiration_testable)) 933 + "FMT2 expiry correct with year normalization" 934 + (Some (`DateTime t)) 935 + (Cookie.expires cookie) 936 + | None -> Alcotest.fail "Expected expiry time for FMT2" 937 + end 938 + 939 + let http_date_fmt3 () = 940 + Eio_mock.Backend.run @@ fun () -> 941 + let clock = Eio_mock.Clock.make () in 942 + Eio_mock.Clock.set_time clock 1000.0; 943 + 944 + (* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *) 945 + let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in 946 + let cookie_opt = 947 + of_set_cookie_header 948 + ~now:(fun () -> 949 + Ptime.of_float_s (Eio.Time.now clock) 950 + |> Option.value ~default:Ptime.epoch) 951 + ~domain:"example.com" ~path:"/" header 952 + in 953 + Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt); 954 + 955 + let cookie = Result.get_ok cookie_opt in 956 + Alcotest.(check bool) 957 + "FMT3 has expiry" true 958 + (Option.is_some (Cookie.expires cookie)); 959 + 960 + let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in 961 + begin match expected with 962 + | Some t -> 963 + Alcotest.(check (option expiration_testable)) 964 + "FMT3 expiry correct" 965 + (Some (`DateTime t)) 966 + (Cookie.expires cookie) 967 + | None -> Alcotest.fail "Expected expiry time for FMT3" 968 + end 969 + 970 + let http_date_fmt4 () = 971 + Eio_mock.Backend.run @@ fun () -> 972 + let clock = Eio_mock.Clock.make () in 973 + Eio_mock.Clock.set_time clock 1000.0; 974 + 975 + (* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *) 976 + let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" in 977 + let cookie_opt = 978 + of_set_cookie_header 979 + ~now:(fun () -> 980 + Ptime.of_float_s (Eio.Time.now clock) 981 + |> Option.value ~default:Ptime.epoch) 982 + ~domain:"example.com" ~path:"/" header 983 + in 984 + Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt); 985 + 986 + let cookie = Result.get_ok cookie_opt in 987 + Alcotest.(check bool) 988 + "FMT4 has expiry" true 989 + (Option.is_some (Cookie.expires cookie)); 990 + 991 + let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in 992 + begin match expected with 993 + | Some t -> 994 + Alcotest.(check (option expiration_testable)) 995 + "FMT4 expiry correct" 996 + (Some (`DateTime t)) 997 + (Cookie.expires cookie) 998 + | None -> Alcotest.fail "Expected expiry time for FMT4" 999 + end 1000 + 1001 + let abbrev_year_69to99 () = 1002 + Eio_mock.Backend.run @@ fun () -> 1003 + let clock = Eio_mock.Clock.make () in 1004 + Eio_mock.Clock.set_time clock 1000.0; 1005 + 1006 + (* Year 95 should become 1995 *) 1007 + let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in 1008 + let cookie_opt = 1009 + of_set_cookie_header 1010 + ~now:(fun () -> 1011 + Ptime.of_float_s (Eio.Time.now clock) 1012 + |> Option.value ~default:Ptime.epoch) 1013 + ~domain:"example.com" ~path:"/" header 1014 + in 1015 + let cookie = Result.get_ok cookie_opt in 1016 + let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in 1017 + begin match expected with 1018 + | Some t -> 1019 + Alcotest.(check (option expiration_testable)) 1020 + "year 95 becomes 1995" 1021 + (Some (`DateTime t)) 1022 + (Cookie.expires cookie) 1023 + | None -> Alcotest.fail "Expected expiry time for year 95" 1024 + end; 1025 + 1026 + (* Year 69 should become 1969 *) 1027 + let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in 1028 + let cookie_opt2 = 1029 + of_set_cookie_header 1030 + ~now:(fun () -> 1031 + Ptime.of_float_s (Eio.Time.now clock) 1032 + |> Option.value ~default:Ptime.epoch) 1033 + ~domain:"example.com" ~path:"/" header2 1034 + in 1035 + let cookie2 = Result.get_ok cookie_opt2 in 1036 + let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in 1037 + begin match expected2 with 1038 + | Some t -> 1039 + Alcotest.(check (option expiration_testable)) 1040 + "year 69 becomes 1969" 1041 + (Some (`DateTime t)) 1042 + (Cookie.expires cookie2) 1043 + | None -> Alcotest.fail "Expected expiry time for year 69" 1044 + end; 1045 + 1046 + (* Year 99 should become 1999 *) 1047 + let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in 1048 + let cookie_opt3 = 1049 + of_set_cookie_header 1050 + ~now:(fun () -> 1051 + Ptime.of_float_s (Eio.Time.now clock) 1052 + |> Option.value ~default:Ptime.epoch) 1053 + ~domain:"example.com" ~path:"/" header3 1054 + in 1055 + let cookie3 = Result.get_ok cookie_opt3 in 1056 + let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in 1057 + begin match expected3 with 1058 + | Some t -> 1059 + Alcotest.(check (option expiration_testable)) 1060 + "year 99 becomes 1999" 1061 + (Some (`DateTime t)) 1062 + (Cookie.expires cookie3) 1063 + | None -> Alcotest.fail "Expected expiry time for year 99" 1064 + end 1065 + 1066 + let abbrev_year_0to68 () = 1067 + Eio_mock.Backend.run @@ fun () -> 1068 + let clock = Eio_mock.Clock.make () in 1069 + Eio_mock.Clock.set_time clock 1000.0; 1070 + 1071 + (* Year 25 should become 2025 *) 1072 + let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in 1073 + let cookie_opt = 1074 + of_set_cookie_header 1075 + ~now:(fun () -> 1076 + Ptime.of_float_s (Eio.Time.now clock) 1077 + |> Option.value ~default:Ptime.epoch) 1078 + ~domain:"example.com" ~path:"/" header 1079 + in 1080 + let cookie = Result.get_ok cookie_opt in 1081 + let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in 1082 + begin match expected with 1083 + | Some t -> 1084 + Alcotest.(check (option expiration_testable)) 1085 + "year 25 becomes 2025" 1086 + (Some (`DateTime t)) 1087 + (Cookie.expires cookie) 1088 + | None -> Alcotest.fail "Expected expiry time for year 25" 1089 + end; 1090 + 1091 + (* Year 0 should become 2000 *) 1092 + let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in 1093 + let cookie_opt2 = 1094 + of_set_cookie_header 1095 + ~now:(fun () -> 1096 + Ptime.of_float_s (Eio.Time.now clock) 1097 + |> Option.value ~default:Ptime.epoch) 1098 + ~domain:"example.com" ~path:"/" header2 1099 + in 1100 + let cookie2 = Result.get_ok cookie_opt2 in 1101 + let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in 1102 + begin match expected2 with 1103 + | Some t -> 1104 + Alcotest.(check (option expiration_testable)) 1105 + "year 0 becomes 2000" 1106 + (Some (`DateTime t)) 1107 + (Cookie.expires cookie2) 1108 + | None -> Alcotest.fail "Expected expiry time for year 0" 1109 + end; 1110 + 1111 + (* Year 68 should become 2068 *) 1112 + let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in 1113 + let cookie_opt3 = 1114 + of_set_cookie_header 1115 + ~now:(fun () -> 1116 + Ptime.of_float_s (Eio.Time.now clock) 1117 + |> Option.value ~default:Ptime.epoch) 1118 + ~domain:"example.com" ~path:"/" header3 1119 + in 1120 + let cookie3 = Result.get_ok cookie_opt3 in 1121 + let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in 1122 + begin match expected3 with 1123 + | Some t -> 1124 + Alcotest.(check (option expiration_testable)) 1125 + "year 68 becomes 2068" 1126 + (Some (`DateTime t)) 1127 + (Cookie.expires cookie3) 1128 + | None -> Alcotest.fail "Expected expiry time for year 68" 1129 + end 1130 + 1131 + let rfc3339_still_works () = 1132 + Eio_mock.Backend.run @@ fun () -> 1133 + let clock = Eio_mock.Clock.make () in 1134 + Eio_mock.Clock.set_time clock 1000.0; 1135 + 1136 + (* Ensure RFC 3339 format still works for backward compatibility *) 1137 + let header = "session=abc; Expires=2025-10-21T07:28:00Z" in 1138 + let cookie_opt = 1139 + of_set_cookie_header 1140 + ~now:(fun () -> 1141 + Ptime.of_float_s (Eio.Time.now clock) 1142 + |> Option.value ~default:Ptime.epoch) 1143 + ~domain:"example.com" ~path:"/" header 1144 + in 1145 + Alcotest.(check bool) "RFC 3339 cookie parsed" true (Result.is_ok cookie_opt); 1146 + 1147 + let cookie = Result.get_ok cookie_opt in 1148 + Alcotest.(check bool) 1149 + "RFC 3339 has expiry" true 1150 + (Option.is_some (Cookie.expires cookie)); 1151 + 1152 + (* Verify the time was parsed correctly *) 1153 + let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in 1154 + match expected with 1155 + | Ok (time, _, _) -> 1156 + Alcotest.(check (option expiration_testable)) 1157 + "RFC 3339 expiry correct" 1158 + (Some (`DateTime time)) 1159 + (Cookie.expires cookie) 1160 + | Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time" 1161 + 1162 + let invalid_date_logs_warn () = 1163 + Eio_mock.Backend.run @@ fun () -> 1164 + let clock = Eio_mock.Clock.make () in 1165 + Eio_mock.Clock.set_time clock 1000.0; 1166 + 1167 + (* Invalid date format should log a warning but still parse the cookie *) 1168 + let header = "session=abc; Expires=InvalidDate" in 1169 + let cookie_opt = 1170 + of_set_cookie_header 1171 + ~now:(fun () -> 1172 + Ptime.of_float_s (Eio.Time.now clock) 1173 + |> Option.value ~default:Ptime.epoch) 1174 + ~domain:"example.com" ~path:"/" header 1175 + in 1176 + 1177 + (* Cookie should still be parsed, just without expires *) 1178 + Alcotest.(check bool) 1179 + "cookie parsed despite invalid date" true (Result.is_ok cookie_opt); 1180 + let cookie = Result.get_ok cookie_opt in 1181 + Alcotest.(check string) "cookie name correct" "session" (Cookie.name cookie); 1182 + Alcotest.(check string) "cookie value correct" "abc" (Cookie.value cookie); 1183 + (* expires should be None since date was invalid *) 1184 + Alcotest.(check (option expiration_testable)) 1185 + "expires is None for invalid date" None (Cookie.expires cookie) 1186 + 1187 + let case_insensitive_month_parsing () = 1188 + Eio_mock.Backend.run @@ fun () -> 1189 + let clock = Eio_mock.Clock.make () in 1190 + Eio_mock.Clock.set_time clock 1000.0; 1191 + 1192 + (* Test various case combinations for month names *) 1193 + let cases = 1194 + [ 1195 + ("session=abc; Expires=Wed, 21 oct 2015 07:28:00 GMT", "lowercase month"); 1196 + ("session=abc; Expires=Wed, 21 OCT 2015 07:28:00 GMT", "uppercase month"); 1197 + ("session=abc; Expires=Wed, 21 OcT 2015 07:28:00 GMT", "mixed case month"); 1198 + ("session=abc; Expires=Wed, 21 oCt 2015 07:28:00 GMT", "weird case month"); 1199 + ] 1200 + in 1201 + 1202 + List.iter 1203 + (fun (header, description) -> 1204 + let cookie_opt = 1205 + of_set_cookie_header 1206 + ~now:(fun () -> 1207 + Ptime.of_float_s (Eio.Time.now clock) 1208 + |> Option.value ~default:Ptime.epoch) 1209 + ~domain:"example.com" ~path:"/" header 1210 + in 1211 + Alcotest.(check bool) 1212 + (description ^ " parsed") true (Result.is_ok cookie_opt); 1213 + 1214 + let cookie = Result.get_ok cookie_opt in 1215 + Alcotest.(check bool) 1216 + (description ^ " has expiry") 1217 + true 1218 + (Option.is_some (Cookie.expires cookie)); 1219 + 1220 + (* Verify the date was parsed correctly regardless of case *) 1221 + let expires = Option.get (Cookie.expires cookie) in 1222 + match expires with 1223 + | `DateTime ptime -> 1224 + let year, month, _ = Ptime.to_date ptime in 1225 + Alcotest.(check int) (description ^ " year correct") 2015 year; 1226 + Alcotest.(check int) 1227 + (description ^ " month correct (October=10)") 1228 + 10 month 1229 + | `Session -> Alcotest.fail (description ^ " should not be session cookie")) 1230 + cases 1231 + 1232 + let case_insensitive_gmt_parsing () = 1233 + Eio_mock.Backend.run @@ fun () -> 1234 + let clock = Eio_mock.Clock.make () in 1235 + Eio_mock.Clock.set_time clock 1000.0; 1236 + 1237 + (* Test various case combinations for GMT timezone *) 1238 + let cases = 1239 + [ 1240 + ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "uppercase GMT"); 1241 + ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 gmt", "lowercase gmt"); 1242 + ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 Gmt", "mixed case Gmt"); 1243 + ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GmT", "weird case GmT"); 1244 + ] 1245 + in 1246 + 1247 + List.iter 1248 + (fun (header, description) -> 1249 + let cookie_opt = 1250 + of_set_cookie_header 1251 + ~now:(fun () -> 1252 + Ptime.of_float_s (Eio.Time.now clock) 1253 + |> Option.value ~default:Ptime.epoch) 1254 + ~domain:"example.com" ~path:"/" header 1255 + in 1256 + Alcotest.(check bool) 1257 + (description ^ " parsed") true (Result.is_ok cookie_opt); 1258 + 1259 + let cookie = Result.get_ok cookie_opt in 1260 + Alcotest.(check bool) 1261 + (description ^ " has expiry") 1262 + true 1263 + (Option.is_some (Cookie.expires cookie)); 1264 + 1265 + (* Verify the date was parsed correctly regardless of GMT case *) 1266 + let expires = Option.get (Cookie.expires cookie) in 1267 + match expires with 1268 + | `DateTime ptime -> 1269 + let year, month, day = Ptime.to_date ptime in 1270 + Alcotest.(check int) (description ^ " year correct") 2015 year; 1271 + Alcotest.(check int) 1272 + (description ^ " month correct (October=10)") 1273 + 10 month; 1274 + Alcotest.(check int) (description ^ " day correct") 21 day 1275 + | `Session -> Alcotest.fail (description ^ " should not be session cookie")) 1276 + cases 1277 + 1278 + (** {1 Delta Tracking Tests} *) 1279 + 1280 + let add_orig_not_delta () = 1281 + Eio_mock.Backend.run @@ fun () -> 1282 + let clock = Eio_mock.Clock.make () in 1283 + Eio_mock.Clock.set_time clock 1000.0; 1284 + 1285 + let jar = v () in 1286 + let cookie = 1287 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 1288 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1289 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1290 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1291 + () 1292 + in 1293 + add_original jar cookie; 1294 + 1295 + (* Delta should be empty *) 1296 + let delta = delta jar in 1297 + Alcotest.(check int) "delta is empty" 0 (List.length delta); 1298 + 1299 + (* But the cookie should be in the jar *) 1300 + Alcotest.(check int) "jar count is 1" 1 (count jar) 1301 + 1302 + let add_cookie_in_delta () = 1303 + Eio_mock.Backend.run @@ fun () -> 1304 + let clock = Eio_mock.Clock.make () in 1305 + Eio_mock.Clock.set_time clock 1000.0; 1306 + 1307 + let jar = v () in 1308 + let cookie = 1309 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 1310 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1311 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1312 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1313 + () 1314 + in 1315 + add_cookie jar cookie; 1316 + 1317 + (* Delta should contain the cookie *) 1318 + let delta = delta jar in 1319 + Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta); 1320 + let delta_cookie = List.hd delta in 1321 + Alcotest.(check string) "delta cookie name" "test" (Cookie.name delta_cookie); 1322 + Alcotest.(check string) 1323 + "delta cookie value" "value" 1324 + (Cookie.value delta_cookie) 1325 + 1326 + let remove_orig_removal () = 1327 + Eio_mock.Backend.run @@ fun () -> 1328 + let clock = Eio_mock.Clock.make () in 1329 + Eio_mock.Clock.set_time clock 1000.0; 1330 + 1331 + let jar = v () in 1332 + let cookie = 1333 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 1334 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1335 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1336 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1337 + () 1338 + in 1339 + add_original jar cookie; 1340 + 1341 + (* Remove the cookie *) 1342 + remove jar ~clock cookie; 1343 + 1344 + (* Delta should contain a removal cookie *) 1345 + let delta = delta jar in 1346 + Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta); 1347 + let removal_cookie = List.hd delta in 1348 + Alcotest.(check string) 1349 + "removal cookie name" "test" 1350 + (Cookie.name removal_cookie); 1351 + Alcotest.(check string) 1352 + "removal cookie has empty value" "" 1353 + (Cookie.value removal_cookie); 1354 + 1355 + (* Check Max-Age is 0 *) 1356 + match Cookie.max_age removal_cookie with 1357 + | Some span -> 1358 + Alcotest.(check (option int)) 1359 + "removal cookie Max-Age is 0" (Some 0) (Ptime.Span.to_int_s span) 1360 + | None -> Alcotest.fail "removal cookie should have Max-Age" 1361 + 1362 + let remove_delta_cookie () = 1363 + Eio_mock.Backend.run @@ fun () -> 1364 + let clock = Eio_mock.Clock.make () in 1365 + Eio_mock.Clock.set_time clock 1000.0; 1366 + 1367 + let jar = v () in 1368 + let cookie = 1369 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 1370 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1371 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1372 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1373 + () 1374 + in 1375 + add_cookie jar cookie; 1376 + 1377 + (* Remove the cookie *) 1378 + remove jar ~clock cookie; 1379 + 1380 + (* Delta should be empty *) 1381 + let delta = delta jar in 1382 + Alcotest.(check int) 1383 + "delta is empty after removing delta cookie" 0 (List.length delta) 1384 + 1385 + let cookies_orig_and_delta () = 1386 + Eio_mock.Backend.run @@ fun () -> 1387 + let clock = Eio_mock.Clock.make () in 1388 + Eio_mock.Clock.set_time clock 1000.0; 1389 + 1390 + let jar = v () in 1391 + 1392 + (* Add an original cookie *) 1393 + let original = 1394 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"original" ~value:"orig_val" 1395 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1396 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1397 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1398 + () 1399 + in 1400 + add_original jar original; 1401 + 1402 + (* Add a delta cookie *) 1403 + let delta_cookie = 1404 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"delta" ~value:"delta_val" 1405 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1406 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1407 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1408 + () 1409 + in 1410 + add_cookie jar delta_cookie; 1411 + 1412 + (* Get cookies should return both *) 1413 + let cookies = 1414 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 1415 + ~is_secure:false 1416 + in 1417 + Alcotest.(check int) "both cookies returned" 2 (List.length cookies); 1418 + 1419 + let names = List.map Cookie.name cookies |> List.sort String.compare in 1420 + Alcotest.(check (list string)) "cookie names" [ "delta"; "original" ] names 1421 + 1422 + let cookies_delta_takes_precedence () = 1423 + Eio_mock.Backend.run @@ fun () -> 1424 + let clock = Eio_mock.Clock.make () in 1425 + Eio_mock.Clock.set_time clock 1000.0; 1426 + 1427 + let jar = v () in 1428 + 1429 + (* Add an original cookie *) 1430 + let original = 1431 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"orig_val" 1432 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1433 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1434 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1435 + () 1436 + in 1437 + add_original jar original; 1438 + 1439 + (* Add a delta cookie with the same name/domain/path *) 1440 + let delta_cookie = 1441 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"delta_val" 1442 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1443 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1444 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1445 + () 1446 + in 1447 + add_cookie jar delta_cookie; 1448 + 1449 + (* Get cookies should return only the delta cookie *) 1450 + let cookies = 1451 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 1452 + ~is_secure:false 1453 + in 1454 + Alcotest.(check int) "only one cookie returned" 1 (List.length cookies); 1455 + let cookie = List.hd cookies in 1456 + Alcotest.(check string) "delta cookie value" "delta_val" (Cookie.value cookie) 1457 + 1458 + let cookies_excludes_removal_cookies () = 1459 + Eio_mock.Backend.run @@ fun () -> 1460 + let clock = Eio_mock.Clock.make () in 1461 + Eio_mock.Clock.set_time clock 1000.0; 1462 + 1463 + let jar = v () in 1464 + 1465 + (* Add an original cookie *) 1466 + let original = 1467 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 1468 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1469 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1470 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1471 + () 1472 + in 1473 + add_original jar original; 1474 + 1475 + (* Remove it *) 1476 + remove jar ~clock original; 1477 + 1478 + (* Get cookies should return nothing *) 1479 + let cookies = 1480 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 1481 + ~is_secure:false 1482 + in 1483 + Alcotest.(check int) "no cookies returned" 0 (List.length cookies); 1484 + 1485 + (* But delta should have the removal cookie *) 1486 + let delta = delta jar in 1487 + Alcotest.(check int) "delta has removal cookie" 1 (List.length delta) 1488 + 1489 + let delta_changed_cookies () = 1490 + Eio_mock.Backend.run @@ fun () -> 1491 + let clock = Eio_mock.Clock.make () in 1492 + Eio_mock.Clock.set_time clock 1000.0; 1493 + 1494 + let jar = v () in 1495 + 1496 + (* Add original cookies *) 1497 + let original1 = 1498 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"orig1" ~value:"val1" 1499 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1500 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1501 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1502 + () 1503 + in 1504 + add_original jar original1; 1505 + 1506 + let original2 = 1507 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"orig2" ~value:"val2" 1508 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1509 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1510 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1511 + () 1512 + in 1513 + add_original jar original2; 1514 + 1515 + (* Add a new delta cookie *) 1516 + let new_cookie = 1517 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"new" ~value:"new_val" 1518 + ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None 1519 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1520 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1521 + () 1522 + in 1523 + add_cookie jar new_cookie; 1524 + 1525 + (* Delta should only contain the new cookie *) 1526 + let delta = delta jar in 1527 + Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta); 1528 + let delta_cookie = List.hd delta in 1529 + Alcotest.(check string) "delta cookie name" "new" (Cookie.name delta_cookie) 1530 + 1531 + let removal_cookie_format () = 1532 + Eio_mock.Backend.run @@ fun () -> 1533 + let clock = Eio_mock.Clock.make () in 1534 + Eio_mock.Clock.set_time clock 1000.0; 1535 + 1536 + let jar = v () in 1537 + let cookie = 1538 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 1539 + ~secure:true ~http_only:true ?expires:None ~same_site:`Strict 1540 + ?max_age:None 1541 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1542 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 1543 + () 1544 + in 1545 + add_original jar cookie; 1546 + 1547 + (* Remove the cookie *) 1548 + remove jar ~clock cookie; 1549 + 1550 + (* Get the removal cookie *) 1551 + let delta = delta jar in 1552 + let removal = List.hd delta in 1553 + 1554 + (* Check all properties *) 1555 + Alcotest.(check string) 1556 + "removal cookie has empty value" "" (Cookie.value removal); 1557 + Alcotest.(check (option int)) 1558 + "removal cookie Max-Age is 0" (Some 0) 1559 + (Option.bind (Cookie.max_age removal) Ptime.Span.to_int_s); 1560 + 1561 + (* Check expires is in the past *) 1562 + let now = Ptime.of_float_s 1000.0 |> Option.get in 1563 + match Cookie.expires removal with 1564 + | Some (`DateTime exp) -> 1565 + Alcotest.(check bool) 1566 + "expires is in the past" true 1567 + (Ptime.compare exp now < 0) 1568 + | _ -> Alcotest.fail "removal cookie should have DateTime expires" 1569 + 1570 + (* ============================================================================ *) 1571 + (* Priority 2 Tests *) 1572 + (* ============================================================================ *) 1573 + 1574 + (* Priority 2.1: Partitioned Cookies *) 1575 + 1576 + let partitioned_parsing () = 1577 + Eio_main.run @@ fun env -> 1578 + let clock = Eio.Stdenv.clock env in 1579 + 1580 + match 1581 + of_set_cookie_header 1582 + ~now:(fun () -> 1583 + Ptime.of_float_s (Eio.Time.now clock) 1584 + |> Option.value ~default:Ptime.epoch) 1585 + ~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure" 1586 + with 1587 + | Ok c -> 1588 + Alcotest.(check bool) "partitioned flag" true (partitioned c); 1589 + Alcotest.(check bool) "secure flag" true (secure c) 1590 + | Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg) 1591 + 1592 + let partitioned_serialization () = 1593 + Eio_main.run @@ fun env -> 1594 + let clock = Eio.Stdenv.clock env in 1595 + let now = 1596 + Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 1597 + in 1598 + 1599 + let cookie = 1600 + Cookie.v ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123" ~secure:true 1601 + ~partitioned:true ~creation_time:now ~last_access:now () 1602 + in 1603 + 1604 + let header = set_cookie_header cookie in 1605 + let has_partitioned = contains_substring header "Partitioned" in 1606 + let has_secure = contains_substring header "Secure" in 1607 + Alcotest.(check bool) "contains Partitioned" true has_partitioned; 1608 + Alcotest.(check bool) "contains Secure" true has_secure 1609 + 1610 + let partitioned_requires_secure () = 1611 + Eio_main.run @@ fun env -> 1612 + let clock = Eio.Stdenv.clock env in 1613 + 1614 + (* Partitioned without Secure should be rejected *) 1615 + match 1616 + of_set_cookie_header 1617 + ~now:(fun () -> 1618 + Ptime.of_float_s (Eio.Time.now clock) 1619 + |> Option.value ~default:Ptime.epoch) 1620 + ~domain:"widget.com" ~path:"/" "id=123; Partitioned" 1621 + with 1622 + | Error _ -> () (* Expected *) 1623 + | Ok _ -> Alcotest.fail "Should reject Partitioned without Secure" 1624 + 1625 + (* Priority 2.2: Expiration Variants *) 1626 + 1627 + let expiration_variants () = 1628 + Eio_main.run @@ fun env -> 1629 + let clock = Eio.Stdenv.clock env in 1630 + let now = 1631 + Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 1632 + in 1633 + let make_base ~name ?expires () = 1634 + Cookie.v ~domain:"ex.com" ~path:"/" ~name ~value:"v" ?expires 1635 + ~creation_time:now ~last_access:now () 1636 + in 1637 + 1638 + (* No expiration *) 1639 + let c1 = make_base ~name:"no_expiry" () in 1640 + Alcotest.(check (option expiration_testable)) 1641 + "no expiration" None (expires c1); 1642 + 1643 + (* Session cookie *) 1644 + let c2 = make_base ~name:"session" ~expires:`Session () in 1645 + Alcotest.(check (option expiration_testable)) 1646 + "session cookie" (Some `Session) (expires c2); 1647 + 1648 + (* Explicit expiration *) 1649 + let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in 1650 + let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in 1651 + match expires c3 with 1652 + | Some (`DateTime t) when Ptime.equal t future -> () 1653 + | _ -> Alcotest.fail "Expected DateTime expiration" 1654 + 1655 + let parse_session_expiration () = 1656 + Eio_main.run @@ fun env -> 1657 + let clock = Eio.Stdenv.clock env in 1658 + 1659 + (* Expires=0 should parse as Session *) 1660 + match 1661 + of_set_cookie_header 1662 + ~now:(fun () -> 1663 + Ptime.of_float_s (Eio.Time.now clock) 1664 + |> Option.value ~default:Ptime.epoch) 1665 + ~domain:"ex.com" ~path:"/" "id=123; Expires=0" 1666 + with 1667 + | Ok c -> 1668 + Alcotest.(check (option expiration_testable)) 1669 + "expires=0 is session" (Some `Session) (expires c) 1670 + | Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg) 1671 + 1672 + let serialize_expiration_variants () = 1673 + Eio_main.run @@ fun env -> 1674 + let clock = Eio.Stdenv.clock env in 1675 + let now = 1676 + Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 1677 + in 1678 + 1679 + (* Session cookie serialization *) 1680 + let c1 = 1681 + Cookie.v ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v" ~expires:`Session 1682 + ~creation_time:now ~last_access:now () 1683 + in 1684 + let h1 = set_cookie_header c1 in 1685 + let has_expires = contains_substring h1 "Expires=" in 1686 + Alcotest.(check bool) "session has Expires" true has_expires; 1687 + 1688 + (* DateTime serialization *) 1689 + let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in 1690 + let c2 = 1691 + Cookie.v ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v" 1692 + ~expires:(`DateTime future) ~creation_time:now ~last_access:now () 1693 + in 1694 + let h2 = set_cookie_header c2 in 1695 + let has_expires2 = contains_substring h2 "Expires=" in 1696 + Alcotest.(check bool) "datetime has Expires" true has_expires2 1697 + 1698 + (* Priority 2.3: Value Trimming *) 1699 + 1700 + let quoted_cookie_values () = 1701 + Eio_main.run @@ fun env -> 1702 + let clock = Eio.Stdenv.clock env in 1703 + (* Test valid RFC 6265 cookie values: 1704 + cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) 1705 + Valid cases have either no quotes or properly paired DQUOTE wrapper *) 1706 + let valid_cases = 1707 + [ 1708 + ("name=value", "value", "value"); 1709 + (* No quotes *) 1710 + ("name=\"value\"", "\"value\"", "value"); 1711 + (* Properly quoted *) 1712 + ("name=\"\"", "\"\"", ""); 1713 + (* Empty quoted value *) 1714 + ] 1715 + in 1716 + 1717 + List.iter 1718 + (fun (input, expected_raw, expected_trimmed) -> 1719 + match 1720 + of_set_cookie_header 1721 + ~now:(fun () -> 1722 + Ptime.of_float_s (Eio.Time.now clock) 1723 + |> Option.value ~default:Ptime.epoch) 1724 + ~domain:"ex.com" ~path:"/" input 1725 + with 1726 + | Ok c -> 1727 + Alcotest.(check string) 1728 + (Fmt.str "raw value for %s" input) 1729 + expected_raw (value c); 1730 + Alcotest.(check string) 1731 + (Fmt.str "trimmed value for %s" input) 1732 + expected_trimmed (value_trimmed c) 1733 + | Error msg -> Alcotest.fail ("Parse failed: " ^ input ^ ": " ^ msg)) 1734 + valid_cases; 1735 + 1736 + (* Test invalid RFC 6265 cookie values are rejected *) 1737 + let invalid_cases = 1738 + [ 1739 + "name=\"partial"; 1740 + (* Opening quote without closing *) 1741 + "name=\"val\"\""; 1742 + (* Embedded quote *) 1743 + "name=val\""; 1744 + (* Trailing quote without opening *) 1745 + ] 1746 + in 1747 + 1748 + List.iter 1749 + (fun input -> 1750 + match 1751 + of_set_cookie_header 1752 + ~now:(fun () -> 1753 + Ptime.of_float_s (Eio.Time.now clock) 1754 + |> Option.value ~default:Ptime.epoch) 1755 + ~domain:"ex.com" ~path:"/" input 1756 + with 1757 + | Error _ -> () (* Expected - invalid values are rejected *) 1758 + | Ok _ -> Alcotest.failf "Should reject invalid value: %s" input) 1759 + invalid_cases 1760 + 1761 + let trimmed_val_not_equal () = 1762 + Eio_main.run @@ fun env -> 1763 + let clock = Eio.Stdenv.clock env in 1764 + 1765 + match 1766 + of_set_cookie_header 1767 + ~now:(fun () -> 1768 + Ptime.of_float_s (Eio.Time.now clock) 1769 + |> Option.value ~default:Ptime.epoch) 1770 + ~domain:"ex.com" ~path:"/" "name=\"value\"" 1771 + with 1772 + | Ok c1 -> 1773 + begin match 1774 + of_set_cookie_header 1775 + ~now:(fun () -> 1776 + Ptime.of_float_s (Eio.Time.now clock) 1777 + |> Option.value ~default:Ptime.epoch) 1778 + ~domain:"ex.com" ~path:"/" "name=value" 1779 + with 1780 + | Ok c2 -> 1781 + (* Different raw values *) 1782 + Alcotest.(check bool) 1783 + "different raw values" false 1784 + (value c1 = value c2); 1785 + (* Same trimmed values *) 1786 + Alcotest.(check string) 1787 + "same trimmed values" (value_trimmed c1) (value_trimmed c2) 1788 + | Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg) 1789 + end 1790 + | Error msg -> Alcotest.fail ("Parse failed for quoted: " ^ msg) 1791 + 1792 + (* Priority 2.4: Cookie Header Parsing *) 1793 + 1794 + let cookie_header_parsing_basic () = 1795 + Eio_main.run @@ fun env -> 1796 + let clock = Eio.Stdenv.clock env in 1797 + let result = 1798 + of_cookie_header 1799 + ~now:(fun () -> 1800 + Ptime.of_float_s (Eio.Time.now clock) 1801 + |> Option.value ~default:Ptime.epoch) 1802 + ~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en" 1803 + in 1804 + 1805 + match result with 1806 + | Error msg -> Alcotest.fail ("Parse failed: " ^ msg) 1807 + | Ok cookies -> 1808 + Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies); 1809 + 1810 + let find name_val = List.find (fun c -> name c = name_val) cookies in 1811 + Alcotest.(check string) "session value" "abc123" (value (find "session")); 1812 + Alcotest.(check string) "theme value" "dark" (value (find "theme")); 1813 + Alcotest.(check string) "lang value" "en" (value (find "lang")) 1814 + 1815 + let cookie_header_defaults () = 1816 + Eio_main.run @@ fun env -> 1817 + let clock = Eio.Stdenv.clock env in 1818 + 1819 + match 1820 + of_cookie_header 1821 + ~now:(fun () -> 1822 + Ptime.of_float_s (Eio.Time.now clock) 1823 + |> Option.value ~default:Ptime.epoch) 1824 + ~domain:"example.com" ~path:"/app" "session=xyz" 1825 + with 1826 + | Ok [ c ] -> 1827 + (* Domain and path from request context *) 1828 + Alcotest.(check string) "domain from context" "example.com" (domain c); 1829 + Alcotest.(check string) "path from context" "/app" (path c); 1830 + 1831 + (* Security flags default to false *) 1832 + Alcotest.(check bool) "secure default" false (secure c); 1833 + Alcotest.(check bool) "http_only default" false (http_only c); 1834 + Alcotest.(check bool) "partitioned default" false (partitioned c); 1835 + 1836 + (* Optional attributes default to None *) 1837 + Alcotest.(check (option expiration_testable)) 1838 + "no expiration" None (expires c); 1839 + Alcotest.(check (option span_testable)) "no max_age" None (max_age c); 1840 + Alcotest.(check (option same_site_testable)) 1841 + "no same_site" None (same_site c) 1842 + | Ok _ -> Alcotest.fail "Should parse single cookie" 1843 + | Error msg -> Alcotest.fail ("Parse failed: " ^ msg) 1844 + 1845 + let cookie_header_edge_cases () = 1846 + Eio_main.run @@ fun env -> 1847 + let clock = Eio.Stdenv.clock env in 1848 + 1849 + let test input expected_count description = 1850 + let result = 1851 + of_cookie_header 1852 + ~now:(fun () -> 1853 + Ptime.of_float_s (Eio.Time.now clock) 1854 + |> Option.value ~default:Ptime.epoch) 1855 + ~domain:"ex.com" ~path:"/" input 1856 + in 1857 + match result with 1858 + | Ok cookies -> 1859 + Alcotest.(check int) description expected_count (List.length cookies) 1860 + | Error msg -> Alcotest.fail (description ^ " failed: " ^ msg) 1861 + in 1862 + 1863 + test "" 0 "empty string"; 1864 + test ";;" 0 "only separators"; 1865 + test "a=1;;b=2" 2 "double separator"; 1866 + test " a=1 ; b=2 " 2 "excess whitespace"; 1867 + test " " 0 "only whitespace" 1868 + 1869 + let cookie_header_with_errors () = 1870 + Eio_main.run @@ fun env -> 1871 + let clock = Eio.Stdenv.clock env in 1872 + 1873 + (* Invalid cookie (empty name) should cause entire parse to fail *) 1874 + let result = 1875 + of_cookie_header 1876 + ~now:(fun () -> 1877 + Ptime.of_float_s (Eio.Time.now clock) 1878 + |> Option.value ~default:Ptime.epoch) 1879 + ~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2" 1880 + in 1881 + 1882 + (* Error should have descriptive message about the invalid cookie *) 1883 + match result with 1884 + | Error msg -> 1885 + let has_name = contains_substring msg "name" in 1886 + let has_empty = contains_substring msg "empty" in 1887 + Alcotest.(check bool) 1888 + "error mentions name or empty" true (has_name || has_empty) 1889 + | Ok _ -> Alcotest.fail "Expected error for empty cookie name" 1890 + 1891 + (* Max-Age and Expires Interaction *) 1892 + 1893 + let maxage_expires_present () = 1894 + Eio_main.run @@ fun env -> 1895 + let clock = Eio.Stdenv.clock env in 1896 + let now = 1897 + Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 1898 + in 1899 + let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in 1900 + 1901 + (* Create cookie with both *) 1902 + let cookie = 1903 + Cookie.v ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val" 1904 + ~max_age:(Ptime.Span.of_int_s 3600) ~expires:(`DateTime future) 1905 + ~creation_time:now ~last_access:now () 1906 + in 1907 + 1908 + (* Both should be present *) 1909 + begin match max_age cookie with 1910 + | Some span -> 1911 + begin match Ptime.Span.to_int_s span with 1912 + | Some s -> 1913 + Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s) 1914 + | None -> Alcotest.fail "max_age span could not be converted to int" 1915 + end 1916 + | None -> Alcotest.fail "max_age should be present" 1917 + end; 1918 + 1919 + begin match expires cookie with 1920 + | Some (`DateTime t) when Ptime.equal t future -> () 1921 + | _ -> Alcotest.fail "expires should be present" 1922 + end; 1923 + 1924 + (* Both should appear in serialization *) 1925 + let header = set_cookie_header cookie in 1926 + let has_max_age = contains_substring header "Max-Age=3600" in 1927 + let has_expires = contains_substring header "Expires=" in 1928 + Alcotest.(check bool) "contains Max-Age" true has_max_age; 1929 + Alcotest.(check bool) "contains Expires" true has_expires 1930 + 1931 + let parse_maxage_expires () = 1932 + Eio_main.run @@ fun env -> 1933 + let clock = Eio.Stdenv.clock env in 1934 + 1935 + (* Parse Set-Cookie with both attributes *) 1936 + match 1937 + of_set_cookie_header 1938 + ~now:(fun () -> 1939 + Ptime.of_float_s (Eio.Time.now clock) 1940 + |> Option.value ~default:Ptime.epoch) 1941 + ~domain:"ex.com" ~path:"/" 1942 + "id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT" 1943 + with 1944 + | Ok c -> 1945 + (* Both should be stored *) 1946 + begin match max_age c with 1947 + | Some span -> 1948 + begin match Ptime.Span.to_int_s span with 1949 + | Some s -> 1950 + Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s) 1951 + | None -> Alcotest.fail "max_age span could not be converted to int" 1952 + end 1953 + | None -> Alcotest.fail "max_age should be parsed" 1954 + end; 1955 + 1956 + begin match expires c with 1957 + | Some (`DateTime _) -> () 1958 + | _ -> Alcotest.fail "expires should be parsed" 1959 + end 1960 + | Error msg -> 1961 + Alcotest.fail ("Should parse cookie with both attributes: " ^ msg) 1962 + 1963 + (* ============================================================================ *) 1964 + (* Host-Only Flag Tests (RFC 6265 Section 5.3) *) 1965 + (* ============================================================================ *) 1966 + 1967 + let hostonly_no_domain () = 1968 + Eio_mock.Backend.run @@ fun () -> 1969 + let clock = Eio_mock.Clock.make () in 1970 + Eio_mock.Clock.set_time clock 1000.0; 1971 + 1972 + (* Cookie without Domain attribute should have host_only=true *) 1973 + let header = "session=abc123; Secure; HttpOnly" in 1974 + let cookie_opt = 1975 + of_set_cookie_header 1976 + ~now:(fun () -> 1977 + Ptime.of_float_s (Eio.Time.now clock) 1978 + |> Option.value ~default:Ptime.epoch) 1979 + ~domain:"example.com" ~path:"/" header 1980 + in 1981 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 1982 + let cookie = Result.get_ok cookie_opt in 1983 + Alcotest.(check bool) "host_only is true" true (Cookie.host_only cookie); 1984 + Alcotest.(check string) 1985 + "domain is request host" "example.com" (Cookie.domain cookie) 1986 + 1987 + let hostonly_with_domain () = 1988 + Eio_mock.Backend.run @@ fun () -> 1989 + let clock = Eio_mock.Clock.make () in 1990 + Eio_mock.Clock.set_time clock 1000.0; 1991 + 1992 + (* Cookie with Domain attribute should have host_only=false *) 1993 + let header = "session=abc123; Domain=example.com; Secure" in 1994 + let cookie_opt = 1995 + of_set_cookie_header 1996 + ~now:(fun () -> 1997 + Ptime.of_float_s (Eio.Time.now clock) 1998 + |> Option.value ~default:Ptime.epoch) 1999 + ~domain:"example.com" ~path:"/" header 2000 + in 2001 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 2002 + let cookie = Result.get_ok cookie_opt in 2003 + Alcotest.(check bool) "host_only is false" false (Cookie.host_only cookie); 2004 + Alcotest.(check string) 2005 + "domain is attribute value" "example.com" (Cookie.domain cookie) 2006 + 2007 + let hostonly_dotted_domain () = 2008 + Eio_mock.Backend.run @@ fun () -> 2009 + let clock = Eio_mock.Clock.make () in 2010 + Eio_mock.Clock.set_time clock 1000.0; 2011 + 2012 + (* Cookie with .domain should have host_only=false and normalized domain *) 2013 + let header = "session=abc123; Domain=.example.com" in 2014 + let cookie_opt = 2015 + of_set_cookie_header 2016 + ~now:(fun () -> 2017 + Ptime.of_float_s (Eio.Time.now clock) 2018 + |> Option.value ~default:Ptime.epoch) 2019 + ~domain:"example.com" ~path:"/" header 2020 + in 2021 + Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt); 2022 + let cookie = Result.get_ok cookie_opt in 2023 + Alcotest.(check bool) "host_only is false" false (Cookie.host_only cookie); 2024 + Alcotest.(check string) 2025 + "domain normalized" "example.com" (Cookie.domain cookie) 2026 + 2027 + let host_only_domain_matching () = 2028 + Eio_mock.Backend.run @@ fun () -> 2029 + let clock = Eio_mock.Clock.make () in 2030 + Eio_mock.Clock.set_time clock 1000.0; 2031 + 2032 + let jar = v () in 2033 + 2034 + (* Add a host-only cookie (no Domain attribute) *) 2035 + let host_only_cookie = 2036 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1" 2037 + ~secure:false ~http_only:false ~host_only:true 2038 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2039 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2040 + () 2041 + in 2042 + add_cookie jar host_only_cookie; 2043 + 2044 + (* Add a domain cookie (with Domain attribute) *) 2045 + let domain_cookie = 2046 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2" 2047 + ~secure:false ~http_only:false ~host_only:false 2048 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2049 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2050 + () 2051 + in 2052 + add_cookie jar domain_cookie; 2053 + 2054 + (* Both cookies should match exact domain *) 2055 + let cookies_exact = 2056 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2057 + ~is_secure:false 2058 + in 2059 + Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact); 2060 + 2061 + (* Only domain cookie should match subdomain *) 2062 + let cookies_sub = 2063 + Cookie_jar.cookies jar ~clock ~domain:"sub.example.com" ~path:"/" 2064 + ~is_secure:false 2065 + in 2066 + Alcotest.(check int) 2067 + "only domain cookie matches subdomain" 1 (List.length cookies_sub); 2068 + let sub_cookie = List.hd cookies_sub in 2069 + Alcotest.(check string) 2070 + "subdomain match is domain cookie" "domain" (Cookie.name sub_cookie) 2071 + 2072 + let hostonly_header_parsing () = 2073 + Eio_mock.Backend.run @@ fun () -> 2074 + let clock = Eio_mock.Clock.make () in 2075 + Eio_mock.Clock.set_time clock 1000.0; 2076 + 2077 + (* Cookies from Cookie header should have host_only=true *) 2078 + let result = 2079 + of_cookie_header 2080 + ~now:(fun () -> 2081 + Ptime.of_float_s (Eio.Time.now clock) 2082 + |> Option.value ~default:Ptime.epoch) 2083 + ~domain:"example.com" ~path:"/" "session=abc; theme=dark" 2084 + in 2085 + match result with 2086 + | Error msg -> Alcotest.fail ("Parse failed: " ^ msg) 2087 + | Ok cookies -> 2088 + Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies); 2089 + List.iter 2090 + (fun c -> 2091 + Alcotest.(check bool) 2092 + ("host_only is true for " ^ Cookie.name c) 2093 + true (Cookie.host_only c)) 2094 + cookies 2095 + 2096 + let hostonly_mozilla_roundtrip () = 2097 + Eio_mock.Backend.run @@ fun () -> 2098 + let clock = Eio_mock.Clock.make () in 2099 + Eio_mock.Clock.set_time clock 1000.0; 2100 + 2101 + let jar = v () in 2102 + 2103 + (* Add host-only cookie *) 2104 + let host_only = 2105 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1" 2106 + ~secure:false ~http_only:false ~host_only:true 2107 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2108 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2109 + () 2110 + in 2111 + add_cookie jar host_only; 2112 + 2113 + (* Add domain cookie *) 2114 + let domain_cookie = 2115 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2" 2116 + ~secure:false ~http_only:false ~host_only:false 2117 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2118 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2119 + () 2120 + in 2121 + add_cookie jar domain_cookie; 2122 + 2123 + (* Round trip through Mozilla format *) 2124 + let mozilla = to_mozilla_format jar in 2125 + let jar2 = from_mozilla_format ~clock mozilla in 2126 + let cookies = all_cookies jar2 in 2127 + 2128 + Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies); 2129 + 2130 + let find name_val = List.find (fun c -> Cookie.name c = name_val) cookies in 2131 + Alcotest.(check bool) 2132 + "hostonly preserved" true 2133 + (Cookie.host_only (find "hostonly")); 2134 + Alcotest.(check bool) 2135 + "domain preserved" false 2136 + (Cookie.host_only (find "domain")) 2137 + 2138 + (* ============================================================================ *) 2139 + (* Path Matching Tests (RFC 6265 Section 5.1.4) *) 2140 + (* ============================================================================ *) 2141 + 2142 + let path_matching_identical () = 2143 + Eio_mock.Backend.run @@ fun () -> 2144 + let clock = Eio_mock.Clock.make () in 2145 + Eio_mock.Clock.set_time clock 1000.0; 2146 + 2147 + let jar = v () in 2148 + let cookie = 2149 + Cookie.v ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val" 2150 + ~secure:false ~http_only:false 2151 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2152 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2153 + () 2154 + in 2155 + add_cookie jar cookie; 2156 + 2157 + (* Identical path should match *) 2158 + let cookies = 2159 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo" 2160 + ~is_secure:false 2161 + in 2162 + Alcotest.(check int) "identical path matches" 1 (List.length cookies) 2163 + 2164 + let path_match_trailing_slash () = 2165 + Eio_mock.Backend.run @@ fun () -> 2166 + let clock = Eio_mock.Clock.make () in 2167 + Eio_mock.Clock.set_time clock 1000.0; 2168 + 2169 + let jar = v () in 2170 + let cookie = 2171 + Cookie.v ~domain:"example.com" ~path:"/foo/" ~name:"test" ~value:"val" 2172 + ~secure:false ~http_only:false 2173 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2174 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2175 + () 2176 + in 2177 + add_cookie jar cookie; 2178 + 2179 + (* Cookie path /foo/ should match /foo/bar *) 2180 + let cookies = 2181 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" 2182 + ~is_secure:false 2183 + in 2184 + Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies); 2185 + 2186 + (* Cookie path /foo/ should match /foo/ *) 2187 + let cookies2 = 2188 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/" 2189 + ~is_secure:false 2190 + in 2191 + Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2) 2192 + 2193 + let path_match_prefix_slash () = 2194 + Eio_mock.Backend.run @@ fun () -> 2195 + let clock = Eio_mock.Clock.make () in 2196 + Eio_mock.Clock.set_time clock 1000.0; 2197 + 2198 + let jar = v () in 2199 + let cookie = 2200 + Cookie.v ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val" 2201 + ~secure:false ~http_only:false 2202 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2203 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2204 + () 2205 + in 2206 + add_cookie jar cookie; 2207 + 2208 + (* Cookie path /foo should match /foo/bar (next char is /) *) 2209 + let cookies = 2210 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" 2211 + ~is_secure:false 2212 + in 2213 + Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies); 2214 + 2215 + (* Cookie path /foo should match /foo/ *) 2216 + let cookies2 = 2217 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/" 2218 + ~is_secure:false 2219 + in 2220 + Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2) 2221 + 2222 + let path_match_no_false () = 2223 + Eio_mock.Backend.run @@ fun () -> 2224 + let clock = Eio_mock.Clock.make () in 2225 + Eio_mock.Clock.set_time clock 1000.0; 2226 + 2227 + let jar = v () in 2228 + let cookie = 2229 + Cookie.v ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val" 2230 + ~secure:false ~http_only:false 2231 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2232 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2233 + () 2234 + in 2235 + add_cookie jar cookie; 2236 + 2237 + (* Cookie path /foo should NOT match /foobar (no / separator) *) 2238 + let cookies = 2239 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foobar" 2240 + ~is_secure:false 2241 + in 2242 + Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies); 2243 + 2244 + (* Cookie path /foo should NOT match /foob *) 2245 + let cookies2 = 2246 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foob" 2247 + ~is_secure:false 2248 + in 2249 + Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2) 2250 + 2251 + let path_matching_root () = 2252 + Eio_mock.Backend.run @@ fun () -> 2253 + let clock = Eio_mock.Clock.make () in 2254 + Eio_mock.Clock.set_time clock 1000.0; 2255 + 2256 + let jar = v () in 2257 + let cookie = 2258 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"val" 2259 + ~secure:false ~http_only:false 2260 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2261 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2262 + () 2263 + in 2264 + add_cookie jar cookie; 2265 + 2266 + (* Root path should match everything *) 2267 + let cookies1 = 2268 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2269 + ~is_secure:false 2270 + in 2271 + Alcotest.(check int) "/ matches /" 1 (List.length cookies1); 2272 + 2273 + let cookies2 = 2274 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo" 2275 + ~is_secure:false 2276 + in 2277 + Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2); 2278 + 2279 + let cookies3 = 2280 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" 2281 + ~is_secure:false 2282 + in 2283 + Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3) 2284 + 2285 + let path_matching_no_match () = 2286 + Eio_mock.Backend.run @@ fun () -> 2287 + let clock = Eio_mock.Clock.make () in 2288 + Eio_mock.Clock.set_time clock 1000.0; 2289 + 2290 + let jar = v () in 2291 + let cookie = 2292 + Cookie.v ~domain:"example.com" ~path:"/foo/bar" ~name:"test" ~value:"val" 2293 + ~secure:false ~http_only:false 2294 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2295 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2296 + () 2297 + in 2298 + add_cookie jar cookie; 2299 + 2300 + (* Cookie path /foo/bar should NOT match /foo *) 2301 + let cookies = 2302 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo" 2303 + ~is_secure:false 2304 + in 2305 + Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies); 2306 + 2307 + (* Cookie path /foo/bar should NOT match / *) 2308 + let cookies2 = 2309 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2310 + ~is_secure:false 2311 + in 2312 + Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2); 2313 + 2314 + (* Cookie path /foo/bar should NOT match /baz *) 2315 + let cookies3 = 2316 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/baz" 2317 + ~is_secure:false 2318 + in 2319 + Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3) 2320 + 2321 + (* ============================================================================ *) 2322 + (* Cookie Ordering Tests (RFC 6265 Section 5.4, Step 2) *) 2323 + (* ============================================================================ *) 2324 + 2325 + let cookie_order_pathlength () = 2326 + Eio_mock.Backend.run @@ fun () -> 2327 + let clock = Eio_mock.Clock.make () in 2328 + Eio_mock.Clock.set_time clock 1000.0; 2329 + 2330 + let jar = v () in 2331 + 2332 + (* Add cookies with different path lengths, but same creation time *) 2333 + let cookie_short = 2334 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"short" ~value:"v1" 2335 + ~secure:false ~http_only:false 2336 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2337 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2338 + () 2339 + in 2340 + let cookie_medium = 2341 + Cookie.v ~domain:"example.com" ~path:"/foo" ~name:"medium" ~value:"v2" 2342 + ~secure:false ~http_only:false 2343 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2344 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2345 + () 2346 + in 2347 + let cookie_long = 2348 + Cookie.v ~domain:"example.com" ~path:"/foo/bar" ~name:"long" ~value:"v3" 2349 + ~secure:false ~http_only:false 2350 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2351 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2352 + () 2353 + in 2354 + 2355 + (* Add in random order *) 2356 + add_cookie jar cookie_short; 2357 + add_cookie jar cookie_long; 2358 + add_cookie jar cookie_medium; 2359 + 2360 + (* Get cookies for path /foo/bar/baz - all three should match *) 2361 + let cookies = 2362 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" 2363 + ~is_secure:false 2364 + in 2365 + 2366 + Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies); 2367 + 2368 + (* Verify order: longest path first *) 2369 + let names = List.map Cookie.name cookies in 2370 + Alcotest.(check (list string)) 2371 + "cookies ordered by path length (longest first)" 2372 + [ "long"; "medium"; "short" ] 2373 + names 2374 + 2375 + let cookie_order_ctime () = 2376 + Eio_mock.Backend.run @@ fun () -> 2377 + let clock = Eio_mock.Clock.make () in 2378 + Eio_mock.Clock.set_time clock 2000.0; 2379 + 2380 + let jar = v () in 2381 + 2382 + (* Add cookies with same path but different creation times *) 2383 + let cookie_new = 2384 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1" 2385 + ~secure:false ~http_only:false 2386 + ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get) 2387 + ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) 2388 + () 2389 + in 2390 + let cookie_old = 2391 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"old" ~value:"v2" 2392 + ~secure:false ~http_only:false 2393 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2394 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2395 + () 2396 + in 2397 + let cookie_middle = 2398 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"middle" ~value:"v3" 2399 + ~secure:false ~http_only:false 2400 + ~creation_time:(Ptime.of_float_s 1200.0 |> Option.get) 2401 + ~last_access:(Ptime.of_float_s 1200.0 |> Option.get) 2402 + () 2403 + in 2404 + 2405 + (* Add in random order *) 2406 + add_cookie jar cookie_new; 2407 + add_cookie jar cookie_old; 2408 + add_cookie jar cookie_middle; 2409 + 2410 + let cookies = 2411 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2412 + ~is_secure:false 2413 + in 2414 + 2415 + Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies); 2416 + 2417 + (* Verify order: earlier creation time first (for same path length) *) 2418 + let names = List.map Cookie.name cookies in 2419 + Alcotest.(check (list string)) 2420 + "cookies ordered by creation time (earliest first)" 2421 + [ "old"; "middle"; "new" ] names 2422 + 2423 + let cookie_ordering_combined () = 2424 + Eio_mock.Backend.run @@ fun () -> 2425 + let clock = Eio_mock.Clock.make () in 2426 + Eio_mock.Clock.set_time clock 2000.0; 2427 + 2428 + let jar = v () in 2429 + 2430 + (* Mix of different paths and creation times *) 2431 + let cookie_a = 2432 + Cookie.v ~domain:"example.com" ~path:"/foo" ~name:"a" ~value:"v1" 2433 + ~secure:false ~http_only:false 2434 + ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get) 2435 + ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) 2436 + () 2437 + in 2438 + let cookie_b = 2439 + Cookie.v ~domain:"example.com" ~path:"/foo" ~name:"b" ~value:"v2" 2440 + ~secure:false ~http_only:false 2441 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2442 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2443 + () 2444 + in 2445 + let cookie_c = 2446 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"c" ~value:"v3" ~secure:false 2447 + ~http_only:false 2448 + ~creation_time:(Ptime.of_float_s 500.0 |> Option.get) 2449 + ~last_access:(Ptime.of_float_s 500.0 |> Option.get) 2450 + () 2451 + in 2452 + 2453 + add_cookie jar cookie_a; 2454 + add_cookie jar cookie_c; 2455 + add_cookie jar cookie_b; 2456 + 2457 + let cookies = 2458 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" 2459 + ~is_secure:false 2460 + in 2461 + 2462 + Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies); 2463 + 2464 + (* /foo cookies (length 4) should come before / cookie (length 1) 2465 + Within /foo, earlier creation time (b=1000) should come before (a=1500) *) 2466 + let names = List.map Cookie.name cookies in 2467 + Alcotest.(check (list string)) 2468 + "cookies ordered by path length then creation time" [ "b"; "a"; "c" ] names 2469 + 2470 + (* ============================================================================ *) 2471 + (* Creation Time Preservation Tests (RFC 6265 Section 5.3, Step 11.3) *) 2472 + (* ============================================================================ *) 2473 + 2474 + let ctime_preserved_update () = 2475 + Eio_mock.Backend.run @@ fun () -> 2476 + let clock = Eio_mock.Clock.make () in 2477 + Eio_mock.Clock.set_time clock 1000.0; 2478 + 2479 + let jar = v () in 2480 + 2481 + (* Add initial cookie with creation_time=500 *) 2482 + let original_creation = Ptime.of_float_s 500.0 |> Option.get in 2483 + let cookie_v1 = 2484 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v1" 2485 + ~secure:false ~http_only:false ~creation_time:original_creation 2486 + ~last_access:(Ptime.of_float_s 500.0 |> Option.get) 2487 + () 2488 + in 2489 + add_cookie jar cookie_v1; 2490 + 2491 + (* Update the cookie with a new value (creation_time=1000) *) 2492 + Eio_mock.Clock.set_time clock 1500.0; 2493 + let cookie_v2 = 2494 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v2" 2495 + ~secure:false ~http_only:false 2496 + ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get) 2497 + ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) 2498 + () 2499 + in 2500 + add_cookie jar cookie_v2; 2501 + 2502 + (* Get the cookie and verify creation_time was preserved *) 2503 + let cookies = 2504 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2505 + ~is_secure:false 2506 + in 2507 + Alcotest.(check int) "still one cookie" 1 (List.length cookies); 2508 + 2509 + let cookie = List.hd cookies in 2510 + Alcotest.(check string) "value was updated" "v2" (Cookie.value cookie); 2511 + 2512 + (* Creation time should be preserved from original cookie *) 2513 + let creation_float = Ptime.to_float_s (Cookie.creation_time cookie) in 2514 + Alcotest.(check (float 0.001)) 2515 + "creation_time preserved from original" 500.0 creation_float 2516 + 2517 + let ctime_preserved_addorig () = 2518 + Eio_mock.Backend.run @@ fun () -> 2519 + let clock = Eio_mock.Clock.make () in 2520 + Eio_mock.Clock.set_time clock 1000.0; 2521 + 2522 + let jar = v () in 2523 + 2524 + (* Add initial original cookie *) 2525 + let original_creation = Ptime.of_float_s 100.0 |> Option.get in 2526 + let cookie_v1 = 2527 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v1" 2528 + ~secure:false ~http_only:false ~creation_time:original_creation 2529 + ~last_access:(Ptime.of_float_s 100.0 |> Option.get) 2530 + () 2531 + in 2532 + add_original jar cookie_v1; 2533 + 2534 + (* Replace with new original cookie *) 2535 + let cookie_v2 = 2536 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v2" 2537 + ~secure:false ~http_only:false 2538 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2539 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2540 + () 2541 + in 2542 + add_original jar cookie_v2; 2543 + 2544 + let cookies = all_cookies jar in 2545 + Alcotest.(check int) "still one cookie" 1 (List.length cookies); 2546 + 2547 + let cookie = List.hd cookies in 2548 + Alcotest.(check string) "value was updated" "v2" (Cookie.value cookie); 2549 + 2550 + (* Creation time should be preserved *) 2551 + let creation_float = Ptime.to_float_s (Cookie.creation_time cookie) in 2552 + Alcotest.(check (float 0.001)) 2553 + "creation_time preserved in add_original" 100.0 creation_float 2554 + 2555 + let creation_time_new_cookie () = 2556 + Eio_mock.Backend.run @@ fun () -> 2557 + let clock = Eio_mock.Clock.make () in 2558 + Eio_mock.Clock.set_time clock 1000.0; 2559 + 2560 + let jar = v () in 2561 + 2562 + (* Add a new cookie (no existing cookie to preserve from) *) 2563 + let cookie = 2564 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1" 2565 + ~secure:false ~http_only:false 2566 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2567 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2568 + () 2569 + in 2570 + add_cookie jar cookie; 2571 + 2572 + let cookies = 2573 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2574 + ~is_secure:false 2575 + in 2576 + let cookie = List.hd cookies in 2577 + 2578 + (* New cookie should keep its own creation time *) 2579 + let creation_float = Ptime.to_float_s (Cookie.creation_time cookie) in 2580 + Alcotest.(check (float 0.001)) 2581 + "new cookie keeps its creation_time" 1000.0 creation_float 2582 + 2583 + (* ============================================================================ *) 2584 + (* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *) 2585 + (* ============================================================================ *) 2586 + 2587 + let ipv4_exact_match () = 2588 + Eio_mock.Backend.run @@ fun () -> 2589 + let clock = Eio_mock.Clock.make () in 2590 + Eio_mock.Clock.set_time clock 1000.0; 2591 + 2592 + let jar = v () in 2593 + let cookie = 2594 + Cookie.v ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val" 2595 + ~secure:false ~http_only:false ~host_only:false 2596 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2597 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2598 + () 2599 + in 2600 + add_cookie jar cookie; 2601 + 2602 + (* IPv4 cookie should match exact IP *) 2603 + let cookies = 2604 + Cookie_jar.cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" 2605 + ~is_secure:false 2606 + in 2607 + Alcotest.(check int) "IPv4 exact match" 1 (List.length cookies) 2608 + 2609 + let ipv4_no_suffix_match () = 2610 + Eio_mock.Backend.run @@ fun () -> 2611 + let clock = Eio_mock.Clock.make () in 2612 + Eio_mock.Clock.set_time clock 1000.0; 2613 + 2614 + let jar = v () in 2615 + (* Cookie for 168.1.1 - this should NOT match requests to 192.168.1.1 2616 + even though "192.168.1.1" ends with ".168.1.1" *) 2617 + let cookie = 2618 + Cookie.v ~domain:"168.1.1" ~path:"/" ~name:"test" ~value:"val" ~secure:false 2619 + ~http_only:false ~host_only:false 2620 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2621 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2622 + () 2623 + in 2624 + add_cookie jar cookie; 2625 + 2626 + (* Should NOT match - IP addresses don't do suffix matching *) 2627 + let cookies = 2628 + Cookie_jar.cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" 2629 + ~is_secure:false 2630 + in 2631 + Alcotest.(check int) "IPv4 no suffix match" 0 (List.length cookies) 2632 + 2633 + let ipv4_different_ip () = 2634 + Eio_mock.Backend.run @@ fun () -> 2635 + let clock = Eio_mock.Clock.make () in 2636 + Eio_mock.Clock.set_time clock 1000.0; 2637 + 2638 + let jar = v () in 2639 + let cookie = 2640 + Cookie.v ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val" 2641 + ~secure:false ~http_only:false ~host_only:false 2642 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2643 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2644 + () 2645 + in 2646 + add_cookie jar cookie; 2647 + 2648 + (* Different IP should not match *) 2649 + let cookies = 2650 + Cookie_jar.cookies jar ~clock ~domain:"192.168.1.2" ~path:"/" 2651 + ~is_secure:false 2652 + in 2653 + Alcotest.(check int) "different IPv4 no match" 0 (List.length cookies) 2654 + 2655 + let ipv6_exact_match () = 2656 + Eio_mock.Backend.run @@ fun () -> 2657 + let clock = Eio_mock.Clock.make () in 2658 + Eio_mock.Clock.set_time clock 1000.0; 2659 + 2660 + let jar = v () in 2661 + let cookie = 2662 + Cookie.v ~domain:"::1" ~path:"/" ~name:"test" ~value:"val" ~secure:false 2663 + ~http_only:false ~host_only:false 2664 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2665 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2666 + () 2667 + in 2668 + add_cookie jar cookie; 2669 + 2670 + (* IPv6 loopback should match exactly *) 2671 + let cookies = 2672 + Cookie_jar.cookies jar ~clock ~domain:"::1" ~path:"/" ~is_secure:false 2673 + in 2674 + Alcotest.(check int) "IPv6 exact match" 1 (List.length cookies) 2675 + 2676 + let ipv6_full_format () = 2677 + Eio_mock.Backend.run @@ fun () -> 2678 + let clock = Eio_mock.Clock.make () in 2679 + Eio_mock.Clock.set_time clock 1000.0; 2680 + 2681 + let jar = v () in 2682 + let cookie = 2683 + Cookie.v ~domain:"2001:db8::1" ~path:"/" ~name:"test" ~value:"val" 2684 + ~secure:false ~http_only:false ~host_only:false 2685 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2686 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2687 + () 2688 + in 2689 + add_cookie jar cookie; 2690 + 2691 + (* IPv6 should match exactly *) 2692 + let cookies = 2693 + Cookie_jar.cookies jar ~clock ~domain:"2001:db8::1" ~path:"/" 2694 + ~is_secure:false 2695 + in 2696 + Alcotest.(check int) "IPv6 full format match" 1 (List.length cookies); 2697 + 2698 + (* Different IPv6 should not match *) 2699 + let cookies2 = 2700 + Cookie_jar.cookies jar ~clock ~domain:"2001:db8::2" ~path:"/" 2701 + ~is_secure:false 2702 + in 2703 + Alcotest.(check int) "different IPv6 no match" 0 (List.length cookies2) 2704 + 2705 + let ip_vs_hostname () = 2706 + Eio_mock.Backend.run @@ fun () -> 2707 + let clock = Eio_mock.Clock.make () in 2708 + Eio_mock.Clock.set_time clock 1000.0; 2709 + 2710 + let jar = v () in 2711 + 2712 + (* Add a hostname cookie with host_only=false (domain cookie) *) 2713 + let hostname_cookie = 2714 + Cookie.v ~domain:"example.com" ~path:"/" ~name:"hostname" ~value:"h1" 2715 + ~secure:false ~http_only:false ~host_only:false 2716 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2717 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2718 + () 2719 + in 2720 + add_cookie jar hostname_cookie; 2721 + 2722 + (* Add an IP cookie with host_only=false *) 2723 + let ip_cookie = 2724 + Cookie.v ~domain:"192.168.1.1" ~path:"/" ~name:"ip" ~value:"i1" 2725 + ~secure:false ~http_only:false ~host_only:false 2726 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2727 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 2728 + () 2729 + in 2730 + add_cookie jar ip_cookie; 2731 + 2732 + (* Hostname request should match hostname cookie and subdomains *) 2733 + let cookies1 = 2734 + Cookie_jar.cookies jar ~clock ~domain:"example.com" ~path:"/" 2735 + ~is_secure:false 2736 + in 2737 + Alcotest.(check int) 2738 + "hostname matches hostname cookie" 1 (List.length cookies1); 2739 + 2740 + let cookies2 = 2741 + Cookie_jar.cookies jar ~clock ~domain:"sub.example.com" ~path:"/" 2742 + ~is_secure:false 2743 + in 2744 + Alcotest.(check int) 2745 + "subdomain matches hostname cookie" 1 (List.length cookies2); 2746 + 2747 + (* IP request should only match IP cookie exactly *) 2748 + let cookies3 = 2749 + Cookie_jar.cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" 2750 + ~is_secure:false 2751 + in 2752 + Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3); 2753 + Alcotest.(check string) 2754 + "IP cookie is returned" "ip" 2755 + (Cookie.name (List.hd cookies3)) 2756 + 2757 + (* ============================================================================ *) 2758 + (* RFC 6265 Validation Tests *) 2759 + (* ============================================================================ *) 2760 + 2761 + let validate_cookie_name_valid () = 2762 + (* Valid token characters per RFC 2616 *) 2763 + let valid_names = 2764 + [ "session"; "SID"; "my-cookie"; "COOKIE_123"; "abc.def" ] 2765 + in 2766 + List.iter 2767 + (fun name -> 2768 + match Cookie.Validate.cookie_name name with 2769 + | Ok _ -> () 2770 + | Error msg -> Alcotest.failf "Name %S should be valid: %s" name msg) 2771 + valid_names 2772 + 2773 + let validate_cookie_name_invalid () = 2774 + (* Invalid: control chars, separators, spaces *) 2775 + let invalid_names = 2776 + [ 2777 + ("", "empty"); 2778 + ("my cookie", "space"); 2779 + ("cookie=value", "equals"); 2780 + ("my;cookie", "semicolon"); 2781 + ("name\t", "tab"); 2782 + ("(cookie)", "parens"); 2783 + ("name,val", "comma"); 2784 + ] 2785 + in 2786 + List.iter 2787 + (fun (name, reason) -> 2788 + match Cookie.Validate.cookie_name name with 2789 + | Error _ -> () (* Expected *) 2790 + | Ok _ -> Alcotest.failf "Name %S (%s) should be invalid" name reason) 2791 + invalid_names 2792 + 2793 + let validate_cookie_value_valid () = 2794 + (* Valid cookie-octets or quoted values *) 2795 + let valid_values = 2796 + [ 2797 + "abc123"; 2798 + "value!#$%&'()*+-./"; 2799 + "\"quoted\""; 2800 + ""; 2801 + "with space"; 2802 + "delete me"; 2803 + ] 2804 + in 2805 + List.iter 2806 + (fun value -> 2807 + match Cookie.Validate.cookie_value value with 2808 + | Ok _ -> () 2809 + | Error msg -> Alcotest.failf "Value %S should be valid: %s" value msg) 2810 + valid_values 2811 + 2812 + let validate_cookie_value_invalid () = 2813 + (* Invalid: comma, semicolon, backslash, unmatched quotes *) 2814 + let invalid_values = 2815 + [ 2816 + ("with,comma", "comma"); 2817 + ("with;semi", "semicolon"); 2818 + ("back\\slash", "backslash"); 2819 + ("\"unmatched", "unmatched opening quote"); 2820 + ("unmatched\"", "unmatched closing quote"); 2821 + ] 2822 + in 2823 + List.iter 2824 + (fun (value, reason) -> 2825 + match Cookie.Validate.cookie_value value with 2826 + | Error _ -> () (* Expected *) 2827 + | Ok _ -> Alcotest.failf "Value %S (%s) should be invalid" value reason) 2828 + invalid_values 2829 + 2830 + let validate_domain_valid () = 2831 + (* Valid domain names and IP addresses *) 2832 + let valid_domains = 2833 + [ "example.com"; "sub.example.com"; ".example.com"; "192.168.1.1"; "::1" ] 2834 + in 2835 + List.iter 2836 + (fun domain -> 2837 + match Cookie.Validate.domain_value domain with 2838 + | Ok _ -> () 2839 + | Error msg -> Alcotest.failf "Domain %S should be valid: %s" domain msg) 2840 + valid_domains 2841 + 2842 + let validate_domain_invalid () = 2843 + (* Invalid domain names - only test cases that domain-name library rejects. 2844 + Note: domain-name library has specific rules that may differ from what 2845 + we might expect from the RFC. *) 2846 + let invalid_domains = 2847 + [ 2848 + ("", "empty"); 2849 + (* Note: "-invalid.com" and "invalid-.com" are valid per domain-name library *) 2850 + ] 2851 + in 2852 + List.iter 2853 + (fun (domain, reason) -> 2854 + match Cookie.Validate.domain_value domain with 2855 + | Error _ -> () (* Expected *) 2856 + | Ok _ -> 2857 + Alcotest.fail 2858 + (Fmt.str "Domain %S (%s) should be invalid" domain reason)) 2859 + invalid_domains 2860 + 2861 + let validate_path_valid () = 2862 + let valid_paths = [ "/"; "/path"; "/path/to/resource"; "/path?query" ] in 2863 + List.iter 2864 + (fun path -> 2865 + match Cookie.Validate.path_value path with 2866 + | Ok _ -> () 2867 + | Error msg -> Alcotest.failf "Path %S should be valid: %s" path msg) 2868 + valid_paths 2869 + 2870 + let validate_path_invalid () = 2871 + let invalid_paths = 2872 + [ ("/path;bad", "semicolon"); ("/path\x00bad", "control char") ] 2873 + in 2874 + List.iter 2875 + (fun (path, reason) -> 2876 + match Cookie.Validate.path_value path with 2877 + | Error _ -> () (* Expected *) 2878 + | Ok _ -> Alcotest.failf "Path %S (%s) should be invalid" path reason) 2879 + invalid_paths 2880 + 2881 + let duplicate_cookie_detection () = 2882 + Eio_mock.Backend.run @@ fun () -> 2883 + let clock = Eio_mock.Clock.make () in 2884 + Eio_mock.Clock.set_time clock 1000.0; 2885 + 2886 + (* Duplicate cookie names should be rejected *) 2887 + let result = 2888 + of_cookie_header 2889 + ~now:(fun () -> 2890 + Ptime.of_float_s (Eio.Time.now clock) 2891 + |> Option.value ~default:Ptime.epoch) 2892 + ~domain:"example.com" ~path:"/" "session=abc; theme=dark; session=xyz" 2893 + in 2894 + match result with 2895 + | Error msg -> 2896 + (* Should mention duplicate *) 2897 + let contains_dup = 2898 + contains_substring (String.lowercase_ascii msg) "duplicate" 2899 + in 2900 + Alcotest.(check bool) "error mentions duplicate" true contains_dup 2901 + | Ok _ -> Alcotest.fail "Should reject duplicate cookie names" 2902 + 2903 + let validation_error_messages () = 2904 + Eio_mock.Backend.run @@ fun () -> 2905 + let clock = Eio_mock.Clock.make () in 2906 + Eio_mock.Clock.set_time clock 1000.0; 2907 + 2908 + (* Test that error messages are descriptive *) 2909 + let cases = 2910 + [ 2911 + ("=noname", "Cookie name is empty"); 2912 + ("bad cookie=value", "invalid characters"); 2913 + ] 2914 + in 2915 + List.iter 2916 + (fun (header, expected_substring) -> 2917 + match 2918 + of_set_cookie_header 2919 + ~now:(fun () -> 2920 + Ptime.of_float_s (Eio.Time.now clock) 2921 + |> Option.value ~default:Ptime.epoch) 2922 + ~domain:"example.com" ~path:"/" header 2923 + with 2924 + | Error msg -> 2925 + let has_substring = contains_substring msg expected_substring in 2926 + Alcotest.(check bool) 2927 + (Fmt.str "error for %S mentions %S" header expected_substring) 2928 + true has_substring 2929 + | Ok _ -> Alcotest.failf "Should reject %S" header) 2930 + cases 2931 + 2932 + (* ============================================================================ *) 2933 + (* Public Suffix Validation Tests (RFC 6265 Section 5.3, Step 5) *) 2934 + (* ============================================================================ *) 2935 + 2936 + let public_suffix_rejection () = 2937 + Eio_mock.Backend.run @@ fun () -> 2938 + let clock = Eio_mock.Clock.make () in 2939 + Eio_mock.Clock.set_time clock 1000.0; 2940 + 2941 + (* Setting a cookie for a public suffix (TLD) should be rejected *) 2942 + let cases = 2943 + [ 2944 + (* (request_domain, cookie_domain, description) *) 2945 + ("www.example.com", "com", "TLD .com"); 2946 + ("www.example.co.uk", "co.uk", "ccTLD .co.uk"); 2947 + ("foo.bar.github.io", "github.io", "private domain github.io"); 2948 + ] 2949 + in 2950 + 2951 + List.iter 2952 + (fun (request_domain, cookie_domain, description) -> 2953 + let header = Fmt.str "session=abc; Domain=.%s" cookie_domain in 2954 + let result = 2955 + of_set_cookie_header 2956 + ~now:(fun () -> 2957 + Ptime.of_float_s (Eio.Time.now clock) 2958 + |> Option.value ~default:Ptime.epoch) 2959 + ~domain:request_domain ~path:"/" header 2960 + in 2961 + match result with 2962 + | Error msg -> 2963 + (* Should mention public suffix *) 2964 + let has_psl = 2965 + contains_substring (String.lowercase_ascii msg) "public suffix" 2966 + in 2967 + Alcotest.(check bool) 2968 + (Fmt.str "%s: error mentions public suffix" description) 2969 + true has_psl 2970 + | Ok _ -> Alcotest.failf "Should reject cookie for %s" description) 2971 + cases 2972 + 2973 + let pubsuffix_exact_match () = 2974 + Eio_mock.Backend.run @@ fun () -> 2975 + let clock = Eio_mock.Clock.make () in 2976 + Eio_mock.Clock.set_time clock 1000.0; 2977 + 2978 + (* If request host exactly matches the public suffix domain, allow it. 2979 + This is rare but possible for private domains like blogspot.com *) 2980 + let header = "session=abc; Domain=.blogspot.com" in 2981 + let result = 2982 + of_set_cookie_header 2983 + ~now:(fun () -> 2984 + Ptime.of_float_s (Eio.Time.now clock) 2985 + |> Option.value ~default:Ptime.epoch) 2986 + ~domain:"blogspot.com" ~path:"/" header 2987 + in 2988 + Alcotest.(check bool) 2989 + "exact match allows public suffix" true (Result.is_ok result) 2990 + 2991 + let non_public_suffix_allowed () = 2992 + Eio_mock.Backend.run @@ fun () -> 2993 + let clock = Eio_mock.Clock.make () in 2994 + Eio_mock.Clock.set_time clock 1000.0; 2995 + 2996 + (* Normal domain (not a public suffix) should be allowed *) 2997 + let cases = 2998 + [ 2999 + ("www.example.com", "example.com", "registrable domain"); 3000 + ("sub.example.com", "example.com", "parent of subdomain"); 3001 + ("www.example.co.uk", "example.co.uk", "registrable domain under ccTLD"); 3002 + ] 3003 + in 3004 + 3005 + List.iter 3006 + (fun (request_domain, cookie_domain, description) -> 3007 + let header = Fmt.str "session=abc; Domain=.%s" cookie_domain in 3008 + let result = 3009 + of_set_cookie_header 3010 + ~now:(fun () -> 3011 + Ptime.of_float_s (Eio.Time.now clock) 3012 + |> Option.value ~default:Ptime.epoch) 3013 + ~domain:request_domain ~path:"/" header 3014 + in 3015 + match result with 3016 + | Ok cookie -> 3017 + Alcotest.(check string) 3018 + (Fmt.str "%s: domain correct" description) 3019 + cookie_domain (Cookie.domain cookie) 3020 + | Error msg -> Alcotest.failf "%s should be allowed: %s" description msg) 3021 + cases 3022 + 3023 + let pubsuffix_no_domain () = 3024 + Eio_mock.Backend.run @@ fun () -> 3025 + let clock = Eio_mock.Clock.make () in 3026 + Eio_mock.Clock.set_time clock 1000.0; 3027 + 3028 + (* Cookie without Domain attribute should always be allowed (host-only) *) 3029 + let header = "session=abc; Secure; HttpOnly" in 3030 + let result = 3031 + of_set_cookie_header 3032 + ~now:(fun () -> 3033 + Ptime.of_float_s (Eio.Time.now clock) 3034 + |> Option.value ~default:Ptime.epoch) 3035 + ~domain:"www.example.com" ~path:"/" header 3036 + in 3037 + match result with 3038 + | Ok cookie -> 3039 + Alcotest.(check bool) "host_only is true" true (Cookie.host_only cookie); 3040 + Alcotest.(check string) 3041 + "domain is request domain" "www.example.com" (Cookie.domain cookie) 3042 + | Error msg -> Alcotest.fail ("Should allow host-only cookie: " ^ msg) 3043 + 3044 + let pubsuffix_ip_bypass () = 3045 + Eio_mock.Backend.run @@ fun () -> 3046 + let clock = Eio_mock.Clock.make () in 3047 + Eio_mock.Clock.set_time clock 1000.0; 3048 + 3049 + (* IP addresses should bypass PSL check *) 3050 + let header = "session=abc; Domain=192.168.1.1" in 3051 + let result = 3052 + of_set_cookie_header 3053 + ~now:(fun () -> 3054 + Ptime.of_float_s (Eio.Time.now clock) 3055 + |> Option.value ~default:Ptime.epoch) 3056 + ~domain:"192.168.1.1" ~path:"/" header 3057 + in 3058 + Alcotest.(check bool) "IP address bypasses PSL" true (Result.is_ok result) 3059 + 3060 + let public_suffix_case_insensitive () = 3061 + Eio_mock.Backend.run @@ fun () -> 3062 + let clock = Eio_mock.Clock.make () in 3063 + Eio_mock.Clock.set_time clock 1000.0; 3064 + 3065 + (* Public suffix check should be case-insensitive *) 3066 + let header = "session=abc; Domain=.COM" in 3067 + let result = 3068 + of_set_cookie_header 3069 + ~now:(fun () -> 3070 + Ptime.of_float_s (Eio.Time.now clock) 3071 + |> Option.value ~default:Ptime.epoch) 3072 + ~domain:"www.example.COM" ~path:"/" header 3073 + in 3074 + Alcotest.(check bool) 3075 + "uppercase TLD still rejected" true (Result.is_error result) 3076 + 3077 + let suite = 3078 + let open Alcotest in 3079 + ( "cookie", 3080 + [ 3081 + test_case "Load Mozilla format from string" `Quick load_mozilla_cookies; 3082 + test_case "Load Mozilla format from file" `Quick load_from_file; 3083 + test_case "Round trip Mozilla format" `Quick round_trip_mozilla_format; 3084 + test_case "Domain and security matching" `Quick cookie_matching; 3085 + test_case "Empty jar operations" `Quick empty_jar; 3086 + test_case "Cookie expiry with mock clock" `Quick cookie_expiry_mock_clock; 3087 + test_case "cookies filters expired cookies" `Quick cookies_filters_expired; 3088 + test_case "Max-Age parsing with mock clock" `Quick 3089 + maxage_parsing_mockclock; 3090 + test_case "Last access time with mock clock" `Quick 3091 + last_access_time_mockclock; 3092 + test_case "Parse Set-Cookie with Expires" `Quick set_cookie_header_expires; 3093 + test_case "SameSite=None validation" `Quick samesite_none_validation; 3094 + test_case "Domain normalization" `Quick domain_normalization; 3095 + test_case "Domain matching with normalized domains" `Quick domain_matching; 3096 + test_case "Max-Age stored separately from Expires" `Quick 3097 + max_age_stored_separately; 3098 + test_case "Negative Max-Age becomes 0" `Quick maxage_negative_zero; 3099 + test_case "set_cookie_header includes Max-Age" `Quick 3100 + set_cookie_header_maxage; 3101 + test_case "Max-Age round-trip parsing" `Quick max_age_round_trip; 3102 + test_case "add_original doesn't affect delta" `Quick add_orig_not_delta; 3103 + test_case "add_cookie appears in delta" `Quick add_cookie_in_delta; 3104 + test_case "remove original creates removal cookie" `Quick 3105 + remove_orig_removal; 3106 + test_case "remove delta cookie just removes it" `Quick remove_delta_cookie; 3107 + test_case "cookies combines original and delta" `Quick 3108 + cookies_orig_and_delta; 3109 + test_case "cookies delta takes precedence" `Quick 3110 + cookies_delta_takes_precedence; 3111 + test_case "cookies excludes removal cookies" `Quick 3112 + cookies_excludes_removal_cookies; 3113 + test_case "delta returns only changed cookies" `Quick 3114 + delta_changed_cookies; 3115 + test_case "removal cookie format" `Quick removal_cookie_format; 3116 + test_case "HTTP date FMT1 (RFC 1123)" `Quick http_date_fmt1; 3117 + test_case "HTTP date FMT2 (RFC 850)" `Quick http_date_fmt2; 3118 + test_case "HTTP date FMT3 (asctime)" `Quick http_date_fmt3; 3119 + test_case "HTTP date FMT4 (variant)" `Quick http_date_fmt4; 3120 + test_case "Abbreviated year 69-99 becomes 1900+" `Quick abbrev_year_69to99; 3121 + test_case "Abbreviated year 0-68 becomes 2000+" `Quick abbrev_year_0to68; 3122 + test_case "RFC 3339 backward compatibility" `Quick rfc3339_still_works; 3123 + test_case "Invalid date format logs warning" `Quick invalid_date_logs_warn; 3124 + test_case "Case-insensitive month parsing" `Quick 3125 + case_insensitive_month_parsing; 3126 + test_case "Case-insensitive GMT parsing" `Quick 3127 + case_insensitive_gmt_parsing; 3128 + test_case "parse partitioned cookie" `Quick partitioned_parsing; 3129 + test_case "serialize partitioned cookie" `Quick partitioned_serialization; 3130 + test_case "partitioned requires secure" `Quick partitioned_requires_secure; 3131 + test_case "expiration variants" `Quick expiration_variants; 3132 + test_case "parse session expiration" `Quick parse_session_expiration; 3133 + test_case "serialize expiration variants" `Quick 3134 + serialize_expiration_variants; 3135 + test_case "quoted values" `Quick quoted_cookie_values; 3136 + test_case "trimmed not used for equality" `Quick trimmed_val_not_equal; 3137 + test_case "parse basic" `Quick cookie_header_parsing_basic; 3138 + test_case "default values" `Quick cookie_header_defaults; 3139 + test_case "edge cases" `Quick cookie_header_edge_cases; 3140 + test_case "multiple with errors" `Quick cookie_header_with_errors; 3141 + test_case "both present" `Quick maxage_expires_present; 3142 + test_case "parse both" `Quick parse_maxage_expires; 3143 + test_case "host_only without Domain attribute" `Quick hostonly_no_domain; 3144 + test_case "host_only with Domain attribute" `Quick hostonly_with_domain; 3145 + test_case "host_only with dotted Domain attribute" `Quick 3146 + hostonly_dotted_domain; 3147 + test_case "host_only domain matching" `Quick host_only_domain_matching; 3148 + test_case "host_only Cookie header parsing" `Quick hostonly_header_parsing; 3149 + test_case "host_only Mozilla format round trip" `Quick 3150 + hostonly_mozilla_roundtrip; 3151 + test_case "identical path" `Quick path_matching_identical; 3152 + test_case "path with trailing slash" `Quick path_match_trailing_slash; 3153 + test_case "prefix with slash separator" `Quick path_match_prefix_slash; 3154 + test_case "no false prefix match" `Quick path_match_no_false; 3155 + test_case "root path matches all" `Quick path_matching_root; 3156 + test_case "path no match" `Quick path_matching_no_match; 3157 + test_case "IPv4 exact match" `Quick ipv4_exact_match; 3158 + test_case "IPv4 no suffix match" `Quick ipv4_no_suffix_match; 3159 + test_case "IPv4 different IP no match" `Quick ipv4_different_ip; 3160 + test_case "IPv6 exact match" `Quick ipv6_exact_match; 3161 + test_case "IPv6 full format" `Quick ipv6_full_format; 3162 + test_case "IP vs hostname behavior" `Quick ip_vs_hostname; 3163 + test_case "valid cookie names" `Quick validate_cookie_name_valid; 3164 + test_case "invalid cookie names" `Quick validate_cookie_name_invalid; 3165 + test_case "valid cookie values" `Quick validate_cookie_value_valid; 3166 + test_case "invalid cookie values" `Quick validate_cookie_value_invalid; 3167 + test_case "valid domain values" `Quick validate_domain_valid; 3168 + test_case "invalid domain values" `Quick validate_domain_invalid; 3169 + test_case "valid path values" `Quick validate_path_valid; 3170 + test_case "invalid path values" `Quick validate_path_invalid; 3171 + test_case "duplicate cookie detection" `Quick duplicate_cookie_detection; 3172 + test_case "validation error messages" `Quick validation_error_messages; 3173 + test_case "ordering by path length" `Quick cookie_order_pathlength; 3174 + test_case "ordering by creation time" `Quick cookie_order_ctime; 3175 + test_case "ordering combined" `Quick cookie_ordering_combined; 3176 + test_case "preserved on update" `Quick ctime_preserved_update; 3177 + test_case "preserved in add_original" `Quick ctime_preserved_addorig; 3178 + test_case "new cookie keeps time" `Quick creation_time_new_cookie; 3179 + test_case "reject public suffix domains" `Quick public_suffix_rejection; 3180 + test_case "allow exact match on public suffix" `Quick 3181 + pubsuffix_exact_match; 3182 + test_case "allow non-public-suffix domains" `Quick 3183 + non_public_suffix_allowed; 3184 + test_case "no Domain attribute bypasses PSL" `Quick pubsuffix_no_domain; 3185 + test_case "IP address bypasses PSL" `Quick pubsuffix_ip_bypass; 3186 + test_case "case insensitive check" `Quick public_suffix_case_insensitive; 3187 + ] )
+3
test/test_cookie.mli
··· 1 + (** Cookie parsing and serialization tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list