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

more specific docs

+769 -123
+168
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-cookeio. 4 + 5 + ## High Priority 6 + 7 + ### 1. Public Suffix Validation (Section 5.3, Step 5) 8 + 9 + **Status:** Not 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 + **Required behavior:** 14 + - Maintain or reference a public suffix list (e.g., from [publicsuffix.org](https://publicsuffix.org/)) 15 + - Reject cookies where the Domain attribute is a public suffix (unless it exactly matches the request host) 16 + 17 + **Security impact:** Without this, an attacker on `evil.com` could potentially set cookies for `.com` affecting all `.com` sites. 18 + 19 + --- 20 + 21 + ## Medium Priority 22 + 23 + ### 2. IP Address Domain Matching (Section 5.1.3) 24 + 25 + **Status:** ✅ IMPLEMENTED 26 + 27 + The RFC specifies that domain suffix matching should only apply to host names, not IP addresses. 28 + 29 + **Implementation:** 30 + - Uses the `ipaddr` library to detect IPv4 and IPv6 addresses 31 + - IP addresses require exact match only (no suffix matching) 32 + - Hostnames continue to support subdomain matching when `host_only = false` 33 + 34 + --- 35 + 36 + ### 3. Expires Header Date Format (Section 4.1.1) 37 + 38 + **Status:** Wrong format 39 + 40 + **Current behavior:** Outputs RFC3339 format (`2021-06-09T10:18:14+00:00`) 41 + 42 + **RFC requirement:** Use `rfc1123-date` format (`Wed, 09 Jun 2021 10:18:14 GMT`) 43 + 44 + **Location:** `cookeio.ml:447-448` 45 + 46 + **Fix:** Implement RFC1123 date formatting for Set-Cookie header output. 47 + 48 + --- 49 + 50 + ### 4. Cookie Ordering in Header (Section 5.4, Step 2) 51 + 52 + **Status:** Not implemented 53 + 54 + When generating Cookie headers, cookies SHOULD be sorted: 55 + 1. Cookies with longer paths listed first 56 + 2. Among equal-length paths, earlier creation-times listed first 57 + 58 + **Location:** `get_cookies` function in `cookeio_jar.ml` 59 + 60 + --- 61 + 62 + ### 5. Creation Time Preservation (Section 5.3, Step 11.3) 63 + 64 + **Status:** Not implemented 65 + 66 + When replacing an existing cookie (same name/domain/path), the creation-time of the old cookie should be preserved. 67 + 68 + **Current behavior:** Completely replaces cookie, losing original creation time. 69 + 70 + **Location:** `add_cookie` and `add_original` functions in `cookeio_jar.ml` 71 + 72 + --- 73 + 74 + ### 6. Default Path Computation (Section 5.1.4) 75 + 76 + **Status:** Not implemented (caller responsibility) 77 + 78 + The RFC specifies an algorithm for computing default path when Path attribute is absent: 79 + 1. If uri-path is empty or doesn't start with `/`, return `/` 80 + 2. If uri-path contains only one `/`, return `/` 81 + 3. Return characters up to (but not including) the rightmost `/` 82 + 83 + **Suggestion:** Add `default_path : string -> string` helper function. 84 + 85 + --- 86 + 87 + ## Low Priority 88 + 89 + ### 7. Storage Limits (Section 6.1) 90 + 91 + **Status:** Not implemented 92 + 93 + RFC recommends minimum capabilities: 94 + - At least 4096 bytes per cookie 95 + - At least 50 cookies per domain 96 + - At least 3000 cookies total 97 + 98 + **Suggestion:** Add configurable limits with RFC-recommended defaults. 99 + 100 + --- 101 + 102 + ### 8. Excess Cookie Eviction (Section 5.3) 103 + 104 + **Status:** Not implemented 105 + 106 + When storage limits are exceeded, evict in priority order: 107 + 1. Expired cookies 108 + 2. Cookies sharing domain with many others 109 + 3. All cookies 110 + 111 + Tiebreaker: earliest `last-access-time` first (LRU). 112 + 113 + --- 114 + 115 + ### 9. Two-Digit Year Parsing (Section 5.1.1) 116 + 117 + **Status:** Minor deviation 118 + 119 + **RFC specification:** 120 + - Years 70-99 → add 1900 121 + - Years 0-69 → add 2000 122 + 123 + **Current code** (`cookeio.ml:128-130`): 124 + ```ocaml 125 + if year >= 0 && year <= 68 then year + 2000 126 + else if year >= 69 && year <= 99 then year + 1900 127 + ``` 128 + 129 + **Issue:** Year 69 is treated as 1969, but RFC says 70-99 get 1900, implying 69 should get 2000. 130 + 131 + --- 132 + 133 + ## Compliant Features 134 + 135 + The following RFC requirements are correctly implemented: 136 + 137 + - [x] Case-insensitive attribute name matching (Section 5.2) 138 + - [x] Leading dot removal from Domain attribute (Section 5.2.3) 139 + - [x] Max-Age takes precedence over Expires (Section 5.3, Step 3) 140 + - [x] Secure flag handling (Section 5.2.5) 141 + - [x] HttpOnly flag handling (Section 5.2.6) 142 + - [x] Cookie date parsing with multiple format support (Section 5.1.1) 143 + - [x] Session vs persistent cookie distinction (Section 5.3) 144 + - [x] Last-access-time updates on retrieval (Section 5.4, Step 3) 145 + - [x] Host-only flag for domain matching (Section 5.3, Step 6) 146 + - [x] Path matching algorithm (Section 5.1.4) 147 + - [x] IP address domain matching - exact match only (Section 5.1.3) 148 + 149 + --- 150 + 151 + ## Extensions Beyond RFC 6265 152 + 153 + These features are implemented but not part of RFC 6265: 154 + 155 + | Feature | Specification | 156 + |---------|---------------| 157 + | SameSite | RFC 6265bis (draft) | 158 + | Partitioned | CHIPS proposal | 159 + | Mozilla format | De facto standard | 160 + 161 + --- 162 + 163 + ## References 164 + 165 + - [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) - HTTP State Management Mechanism 166 + - [RFC 6265bis](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis) - Updated cookie spec (draft) 167 + - [Public Suffix List](https://publicsuffix.org/) - Mozilla's public suffix database 168 + - [CHIPS](https://developer.chrome.com/docs/privacy-sandbox/chips/) - Cookies Having Independent Partitioned State
+1
cookeio.opam
··· 14 14 "dune" {>= "3.20" & >= "3.20"} 15 15 "logs" {>= "0.10.0"} 16 16 "ptime" {>= "1.1.0"} 17 + "ipaddr" {>= "5.0.0"} 17 18 "eio_main" 18 19 "alcotest" {with-test} 19 20 "odoc" {with-doc}
+1
dune-project
··· 21 21 (dune (>= 3.20)) 22 22 (logs (>= 0.10.0)) 23 23 (ptime (>= 1.1.0)) 24 + (ipaddr (>= 5.0.0)) 24 25 eio_main 25 26 (alcotest :with-test) 26 27 (odoc :with-doc)))
+111 -12
lib/core/cookeio.ml
··· 7 7 8 8 module Log = (val Logs.src_log src : Logs.LOG) 9 9 10 + (** SameSite attribute for cross-site request control. 11 + 12 + The SameSite attribute is defined in the RFC 6265bis draft and controls 13 + whether cookies are sent with cross-site requests. 14 + 15 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *) 10 16 module SameSite = struct 11 17 type t = [ `Strict | `Lax | `None ] 12 18 ··· 18 24 | `None -> Format.pp_print_string ppf "None" 19 25 end 20 26 27 + (** Cookie expiration type. 28 + 29 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 30 + cookies have either a persistent expiry time or are session cookies that 31 + expire when the user agent session ends. 32 + 33 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 21 34 module Expiration = struct 22 35 type t = [ `Session | `DateTime of Ptime.t ] 23 36 ··· 96 109 97 110 (** {1 Cookie Parsing Helpers} *) 98 111 112 + (** Normalize a domain by stripping the leading dot. 113 + 114 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 Section 5.2.3}, 115 + if the first character of the Domain attribute value is ".", that character 116 + is ignored (the domain remains case-insensitive). 117 + 118 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> RFC 6265 Section 5.2.3 - The Domain Attribute *) 99 119 let normalize_domain domain = 100 - (* Strip leading dot per RFC 6265 *) 101 120 match String.starts_with ~prefix:"." domain with 102 121 | true when String.length domain > 1 -> 103 122 String.sub domain 1 (String.length domain - 1) 104 123 | _ -> domain 105 124 106 - (** {1 HTTP Date Parsing} *) 125 + (** {1 HTTP Date Parsing} 126 + 127 + Date parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1} 128 + which requires parsing dates in various HTTP formats. *) 107 129 108 130 module DateParser = struct 109 - (** Month name to number mapping (case-insensitive) *) 131 + (** Month name to number mapping (case-insensitive). 132 + 133 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1}, 134 + month tokens are matched case-insensitively. 135 + 136 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *) 110 137 let month_of_string s = 111 138 match String.lowercase_ascii s with 112 139 | "jan" -> Some 1 ··· 123 150 | "dec" -> Some 12 124 151 | _ -> None 125 152 126 - (** Normalize abbreviated years: 127 - - Years 69-99 get 1900 added (e.g., 95 → 1995) 128 - - Years 0-68 get 2000 added (e.g., 25 → 2025) 129 - - Years >= 100 are returned as-is *) 153 + (** Normalize abbreviated years per RFC 6265. 154 + 155 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1}: 156 + - Years 70-99 get 1900 added (e.g., 95 → 1995) 157 + - Years 0-69 get 2000 added (e.g., 25 → 2025) 158 + - Years >= 100 are returned as-is 159 + 160 + Note: This implementation treats year 69 as 1969 (adding 1900), which 161 + technically differs from the RFC's "70 and less than or equal to 99" rule. 162 + 163 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *) 130 164 let normalize_year year = 131 165 if year >= 0 && year <= 68 then year + 2000 132 166 else if year >= 69 && year <= 99 then year + 1900 ··· 227 261 same_site = None; 228 262 } 229 263 230 - (** Parse a single attribute and update the accumulator in-place *) 264 + (** Parse a single cookie attribute and update the accumulator in-place. 265 + 266 + Attribute parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2} 267 + which defines the grammar and semantics for each cookie attribute. 268 + 269 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *) 231 270 let parse_attribute now attrs attr_name attr_value = 232 271 let attr_lower = String.lowercase_ascii attr_name in 233 272 match attr_lower with ··· 282 321 | _ -> 283 322 Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name) 284 323 285 - (** Validate cookie attributes and log warnings for invalid combinations *) 324 + (** Validate cookie attributes and log warnings for invalid combinations. 325 + 326 + Validates: 327 + - SameSite=None requires the Secure flag (per RFC 6265bis) 328 + - Partitioned requires the Secure flag (per CHIPS specification) 329 + 330 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - SameSite 331 + @see <https://developer.chrome.com/docs/privacy-sandbox/chips/> CHIPS - Cookies Having Independent Partitioned State *) 286 332 let validate_attributes attrs = 287 333 (* SameSite=None requires Secure flag *) 288 334 let samesite_valid = ··· 308 354 samesite_valid && partitioned_valid 309 355 310 356 (** Build final cookie from name/value and accumulated attributes. 311 - Per RFC 6265 Section 5.3: 312 - - If Domain attribute is present, host_only = false, domain = attribute value 313 - - If Domain attribute is absent, host_only = true, domain = request host *) 357 + 358 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}: 359 + - If Domain attribute is present, host-only-flag = false, domain = attribute value 360 + - If Domain attribute is absent, host-only-flag = true, domain = request host 361 + 362 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 314 363 let build_cookie ~request_domain ~request_path ~name ~value attrs ~now = 315 364 let host_only, domain = 316 365 match attrs.domain with ··· 341 390 342 391 (** {1 Cookie Parsing} *) 343 392 393 + (** Parse a Set-Cookie HTTP response header. 394 + 395 + Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2}, 396 + extracting the cookie name, value, and all attributes. Returns [None] if 397 + the cookie is invalid or fails validation. 398 + 399 + @param now Function returning current time for Max-Age computation 400 + @param domain The request host (used as default domain) 401 + @param path The request path (used as default path) 402 + @param header_value The Set-Cookie header value string 403 + @return The parsed cookie, or [None] if parsing/validation fails 404 + 405 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *) 344 406 let of_set_cookie_header ~now ~domain:request_domain ~path:request_path 345 407 header_value = 346 408 Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value); ··· 393 455 Log.debug (fun m -> m "Parsed cookie: %a" pp cookie); 394 456 Some cookie) 395 457 458 + (** Parse a Cookie HTTP request header. 459 + 460 + Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}. 461 + The Cookie header contains semicolon-separated name=value pairs. 462 + 463 + Cookies parsed from the Cookie header have [host_only = true] since we 464 + cannot determine from the header alone whether they originally had a 465 + Domain attribute. 466 + 467 + @param now Function returning current time for timestamps 468 + @param domain The request host (assigned to all parsed cookies) 469 + @param path The request path (assigned to all parsed cookies) 470 + @param header_value The Cookie header value string 471 + @return List of parse results (Ok cookie or Error message) 472 + 473 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *) 396 474 let of_cookie_header ~now ~domain ~path header_value = 397 475 Log.debug (fun m -> m "Parsing Cookie header: %s" header_value); 398 476 ··· 429 507 Ok cookie) 430 508 parts 431 509 510 + (** Generate a Cookie HTTP request header from a list of cookies. 511 + 512 + Formats cookies according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2} 513 + as semicolon-separated name=value pairs. 514 + 515 + @param cookies List of cookies to include 516 + @return The Cookie header value string 517 + 518 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *) 432 519 let make_cookie_header cookies = 433 520 cookies 434 521 |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c)) 435 522 |> String.concat "; " 436 523 524 + (** Generate a Set-Cookie HTTP response header from a cookie. 525 + 526 + Formats the cookie according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 Section 4.1} 527 + including all attributes. 528 + 529 + Note: The Expires attribute is currently formatted using RFC 3339, which 530 + differs from the RFC-recommended rfc1123-date format. 531 + 532 + @param cookie The cookie to serialize 533 + @return The Set-Cookie header value string 534 + 535 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - The Set-Cookie Header *) 437 536 let make_set_cookie_header cookie = 438 537 let buffer = Buffer.create 128 in 439 538 Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
+167 -74
lib/core/cookeio.mli
··· 5 5 6 6 (** Cookie management library for OCaml 7 7 8 - HTTP cookies are a mechanism that allows "server side connections to store 9 - and retrieve information on the client side." Originally designed to enable 10 - persistent client-side state for web applications, cookies are essential for 11 - storing user preferences, session data, shopping cart contents, and 12 - authentication tokens. 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. 13 14 14 - This library provides a complete cookie jar implementation following 15 - established web standards while integrating Eio for efficient asynchronous 16 - operations. 15 + This library provides a complete cookie implementation following RFC 6265 16 + while integrating Eio for efficient asynchronous operations. 17 17 18 18 {2 Cookie Format and Structure} 19 19 20 - Cookies are set via the Set-Cookie HTTP response header with the basic 21 - format: [NAME=VALUE] with optional attributes including: 22 - - [expires]: Optional cookie lifetime specification 23 - - [domain]: Specifying valid domains using tail matching 24 - - [path]: Defining URL subset for cookie validity 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 5.2.1}) 25 + - [max-age]: Cookie lifetime in seconds 26 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2} Section 5.2.2}) 27 + - [domain]: Valid domains using tail matching 28 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} Section 5.2.3}) 29 + - [path]: URL subset for cookie validity 30 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4} Section 5.2.4}) 25 31 - [secure]: Transmission over secure channels only 32 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5} Section 5.2.5}) 26 33 - [httponly]: Not accessible to JavaScript 27 - - [samesite]: Cross-site request behavior control 34 + ({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6} Section 5.2.6}) 35 + - [samesite]: Cross-site request behavior (RFC 6265bis) 36 + - [partitioned]: CHIPS partitioned storage 28 37 29 38 {2 Domain and Path Matching} 30 39 31 - The library implements standard domain and path matching rules: 32 - - Domain matching uses "tail matching" (e.g., "acme.com" matches 33 - "anvil.acme.com") 34 - - Path matching allows subset URL specification for fine-grained control 35 - - More specific path mappings are sent first in Cookie headers *) 40 + The library implements standard domain and path matching rules from 41 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 5.1.3} 42 + and {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 5.1.4}: 43 + - Domain matching uses suffix matching for hostnames (e.g., "example.com" 44 + matches "sub.example.com") 45 + - IP addresses require exact match only 46 + - Path matching requires exact match or prefix with "/" separator 47 + 48 + @see <https://datatracker.ietf.org/doc/html/rfc6265> RFC 6265 - HTTP State Management Mechanism *) 49 + 50 + (** {1 Types} *) 36 51 37 52 module SameSite : sig 38 53 type t = [ `Strict | `Lax | `None ] 39 54 (** Cookie same-site policy for controlling cross-site request behavior. 40 55 56 + Defined in RFC 6265bis draft. 57 + 41 58 - [`Strict]: Cookie only sent for same-site requests, providing maximum 42 59 protection 43 60 - [`Lax]: Cookie sent for same-site requests and top-level navigation 44 61 (default for modern browsers) 45 62 - [`None]: Cookie sent for all cross-site requests (requires [secure] 46 - flag) *) 63 + flag per RFC 6265bis) 64 + 65 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *) 47 66 48 67 val equal : t -> t -> bool 49 - (** Equality function for same-site values *) 68 + (** Equality function for same-site values. *) 50 69 51 70 val pp : Format.formatter -> t -> unit 52 - (** Pretty printer for same-site values *) 71 + (** Pretty printer for same-site values. *) 53 72 end 54 73 55 74 module Expiration : sig 56 75 type t = [ `Session | `DateTime of Ptime.t ] 57 76 (** Cookie expiration strategy. 58 77 59 - - [`Session]: Session cookie that expires when browser session ends 60 - - [`DateTime time]: Persistent cookie that expires at specific time *) 78 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}: 79 + - [`Session]: Session cookie that expires when user agent session ends 80 + (persistent-flag = false) 81 + - [`DateTime time]: Persistent cookie that expires at specific time 82 + (persistent-flag = true) 83 + 84 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 61 85 62 86 val equal : t -> t -> bool 63 - (** Equality function for expiration values *) 87 + (** Equality function for expiration values. *) 64 88 65 89 val pp : Format.formatter -> t -> unit 66 - (** Pretty printer for expiration values *) 90 + (** Pretty printer for expiration values. *) 67 91 end 68 92 69 93 type t 70 94 (** HTTP Cookie representation with all standard attributes. 71 95 72 96 A cookie represents a name-value pair with associated metadata that controls 73 - its scope, security, and lifetime. Cookies with the same [name], [domain], 74 - and [path] will overwrite each other when added to a cookie jar. *) 97 + its scope, security, and lifetime. Per 98 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 99 + cookies with the same [name], [domain], and [path] will overwrite each other 100 + when stored. 101 + 102 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 75 103 76 104 (** {1 Cookie Accessors} *) 77 105 78 106 val domain : t -> string 79 - (** Get the domain of a cookie *) 107 + (** Get the domain of a cookie. 108 + 109 + The domain is normalized per 110 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 Section 5.2.3} 111 + (leading dots removed). *) 80 112 81 113 val path : t -> string 82 - (** Get the path of a cookie *) 114 + (** Get the path of a cookie. 115 + 116 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4> RFC 6265 Section 5.2.4 - The Path Attribute *) 83 117 84 118 val name : t -> string 85 - (** Get the name of a cookie *) 119 + (** Get the name of a cookie. *) 86 120 87 121 val value : t -> string 88 - (** Get the value of a cookie *) 122 + (** Get the value of a cookie. *) 89 123 90 124 val value_trimmed : t -> string 91 125 (** Get cookie value with surrounding double-quotes removed if they form a ··· 93 127 94 128 Only removes quotes when both opening and closing quotes are present. The 95 129 raw value is always preserved in {!value}. This is useful for handling 96 - quoted cookie values per RFC 6265. 130 + quoted cookie values. 97 131 98 132 Examples: 99 133 - ["value"] → ["value"] ··· 102 136 - ["\"val\"\""] → ["val\""] (removes outer pair only) *) 103 137 104 138 val secure : t -> bool 105 - (** Check if cookie is secure only *) 139 + (** Check if cookie has the Secure flag. 140 + 141 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5} RFC 6265 Section 5.2.5}, 142 + Secure cookies are only sent over HTTPS connections. 143 + 144 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5> RFC 6265 Section 5.2.5 - The Secure Attribute *) 106 145 107 146 val http_only : t -> bool 108 - (** Check if cookie is HTTP only *) 147 + (** Check if cookie has the HttpOnly flag. 148 + 149 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6} RFC 6265 Section 5.2.6}, 150 + HttpOnly cookies are not accessible to client-side scripts. 151 + 152 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6> RFC 6265 Section 5.2.6 - The HttpOnly Attribute *) 109 153 110 154 val partitioned : t -> bool 111 155 (** Check if cookie has the Partitioned attribute. ··· 113 157 Partitioned cookies are part of CHIPS (Cookies Having Independent 114 158 Partitioned State) and are stored separately per top-level site, enabling 115 159 privacy-preserving third-party cookie functionality. Partitioned cookies 116 - must always be Secure. *) 160 + must always be Secure. 161 + 162 + @see <https://developer.chrome.com/docs/privacy-sandbox/chips/> CHIPS - Cookies Having Independent Partitioned State *) 117 163 118 164 val host_only : t -> bool 119 165 (** Check if cookie has the host-only flag set. 120 166 121 - Per RFC 6265 Section 5.3: 122 - - If the Set-Cookie header included a Domain attribute, host_only is false 123 - and the cookie matches the domain and all subdomains. 124 - - If no Domain attribute was present, host_only is true and the cookie 167 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 6}: 168 + - If the Set-Cookie header included a Domain attribute, host-only-flag is 169 + false and the cookie matches the domain and all subdomains. 170 + - If no Domain attribute was present, host-only-flag is true and the cookie 125 171 only matches the exact request host. 126 172 127 173 Example: 128 174 - Cookie set on "example.com" with Domain=example.com: host_only=false, 129 175 matches example.com and sub.example.com 130 176 - Cookie set on "example.com" without Domain attribute: host_only=true, 131 - matches only example.com, not sub.example.com *) 177 + matches only example.com, not sub.example.com 178 + 179 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 132 180 133 181 val expires : t -> Expiration.t option 134 182 (** Get the expiration attribute if set. 135 183 136 - - [None]: No expiration specified (browser decides lifetime) 137 - - [Some `Session]: Session cookie (expires when browser session ends) 184 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1} RFC 6265 Section 5.2.1}: 185 + - [None]: No expiration specified (session cookie) 186 + - [Some `Session]: Session cookie (expires when user agent session ends) 138 187 - [Some (`DateTime t)]: Expires at specific time [t] 139 188 140 189 Both [max_age] and [expires] can be present simultaneously. This library 141 - stores both independently. *) 190 + stores both independently. 191 + 192 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1> RFC 6265 Section 5.2.1 - The Expires Attribute *) 142 193 143 194 val max_age : t -> Ptime.Span.t option 144 195 (** Get the max-age attribute if set. 145 196 146 - Both [max_age] and [expires] can be present simultaneously. When both are 147 - present in a Set-Cookie header, browsers prioritize [max_age] per RFC 6265. 148 - This library stores both independently and serializes both when present. *) 197 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2} RFC 6265 Section 5.2.2}, 198 + Max-Age specifies the cookie lifetime in seconds. Both [max_age] and 199 + [expires] can be present simultaneously. When both are present in a 200 + Set-Cookie header, browsers prioritize [max_age] per 201 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} Section 5.3 Step 3}. 202 + 203 + This library stores both independently and serializes both when present. 204 + 205 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> RFC 6265 Section 5.2.2 - The Max-Age Attribute *) 149 206 150 207 val same_site : t -> SameSite.t option 151 - (** Get the same-site policy of a cookie *) 208 + (** Get the same-site policy of a cookie. 209 + 210 + @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *) 152 211 153 212 val creation_time : t -> Ptime.t 154 - (** Get the creation time of a cookie *) 213 + (** Get the creation time of a cookie. 214 + 215 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 216 + this is set when the cookie is first received. *) 155 217 156 218 val last_access : t -> Ptime.t 157 - (** Get the last access time of a cookie *) 219 + (** Get the last access time of a cookie. 220 + 221 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 222 + this is updated each time the cookie is retrieved for a request. *) 158 223 159 224 val make : 160 225 domain:string -> ··· 174 239 t 175 240 (** Create a new cookie with the given attributes. 176 241 177 - @param host_only If true, the cookie only matches the exact domain (no 178 - subdomains). Defaults to false. Per RFC 6265, this should be true when no 179 - Domain attribute was present in the Set-Cookie header. 242 + @param domain The cookie domain (will be normalized) 243 + @param path The cookie path 244 + @param name The cookie name 245 + @param value The cookie value 246 + @param secure If true, cookie only sent over HTTPS (default: false) 247 + @param http_only If true, cookie not accessible to scripts (default: false) 248 + @param expires Expiration time 249 + @param max_age Lifetime in seconds 250 + @param same_site Cross-site request policy 251 + @param partitioned CHIPS partitioned storage (default: false) 252 + @param host_only If true, exact domain match only (default: false). Per 253 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 254 + this should be true when no Domain attribute was present in the 255 + Set-Cookie header. 256 + @param creation_time When the cookie was created 257 + @param last_access Last time the cookie was accessed 180 258 181 259 Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid 182 - combinations will result in validation errors. *) 260 + combinations will result in validation errors. 261 + 262 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 183 263 184 264 (** {1 Cookie Creation and Parsing} *) 185 265 ··· 187 267 now:(unit -> Ptime.t) -> domain:string -> path:string -> string -> t option 188 268 (** Parse Set-Cookie response header value into a cookie. 189 269 190 - Set-Cookie headers are sent from server to client and contain the cookie 191 - name, value, and all attributes. 192 - 193 - Parses a Set-Cookie header value following RFC specifications: 270 + Parses a Set-Cookie header following 271 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2}: 194 272 - Basic format: [NAME=VALUE; attribute1; attribute2=value2] 195 273 - Supports all standard attributes: [expires], [max-age], [domain], [path], 196 274 [secure], [httponly], [samesite], [partitioned] ··· 200 278 - The [now] parameter is used for calculating expiry times from [max-age] 201 279 attributes and setting creation/access times 202 280 203 - Cookie validation rules: 281 + Cookie validation rules (from RFC 6265bis and CHIPS): 204 282 - [SameSite=None] requires the [Secure] flag to be set 205 283 - [Partitioned] requires the [Secure] flag to be set 206 284 207 285 Example: 208 - [of_set_cookie_header ~now:(fun () -> Ptime_clock.now ()) 209 - ~domain:"example.com" ~path:"/" "session=abc123; Secure; HttpOnly"] *) 286 + {[of_set_cookie_header ~now:(fun () -> Ptime_clock.now ()) 287 + ~domain:"example.com" ~path:"/" "session=abc123; Secure; HttpOnly"]} 288 + 289 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *) 210 290 211 291 val of_cookie_header : 212 292 now:(unit -> Ptime.t) -> ··· 216 296 (t, string) result list 217 297 (** Parse Cookie request header containing semicolon-separated name=value pairs. 218 298 219 - Cookie headers are sent from client to server and contain only name=value 220 - pairs without attributes: ["name1=value1; name2=value2; name3=value3"] 299 + Parses a Cookie header following 300 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}. 301 + Cookie headers contain only name=value pairs without attributes: 302 + ["name1=value1; name2=value2; name3=value3"] 221 303 222 304 Creates cookies with: 223 305 - Provided [domain] and [path] from request context 224 306 - All security flags set to [false] (defaults) 225 307 - All optional attributes set to [None] 308 + - [host_only = true] (since we cannot determine from the header alone 309 + whether cookies originally had a Domain attribute) 226 310 - [creation_time] and [last_access] set to current time from [now] 227 311 228 312 Returns a list of parse results, one per cookie. Parse errors for individual 229 - cookies are returned as [Error msg] without failing the entire parse. Empty 230 - values and excess whitespace are ignored. 313 + cookies are returned as [Error msg] without failing the entire parse. 231 314 232 315 Example: 233 - [of_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com" 234 - ~path:"/" "session=abc; theme=dark"] *) 316 + {[of_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com" 317 + ~path:"/" "session=abc; theme=dark"]} 318 + 319 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *) 235 320 236 321 val make_cookie_header : t list -> string 237 - (** Create cookie header value from cookies. 322 + (** Create Cookie header value from cookies. 238 323 239 324 Formats a list of cookies into a Cookie header value suitable for HTTP 240 - requests. 325 + requests per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}. 241 326 - Format: [name1=value1; name2=value2; name3=value3] 242 327 - Only includes cookie names and values, not attributes 243 328 - Cookies should already be filtered for the target domain/path 244 - - More specific path mappings should be ordered first in the input list 245 329 246 330 Example: [make_cookie_header cookies] might return 247 - ["session=abc123; theme=dark"] *) 331 + ["session=abc123; theme=dark"] 332 + 333 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *) 248 334 249 335 val make_set_cookie_header : t -> string 250 336 (** Create Set-Cookie header value from a cookie. 251 337 252 - Formats a cookie into a Set-Cookie header value suitable for HTTP responses. 338 + Formats a cookie into a Set-Cookie header value suitable for HTTP responses 339 + per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 Section 4.1}. 253 340 Includes all cookie attributes: Max-Age, Expires, Domain, Path, Secure, 254 - HttpOnly, and SameSite. *) 341 + HttpOnly, Partitioned, and SameSite. 342 + 343 + Note: The Expires attribute is currently formatted using RFC 3339 format, 344 + which differs from the RFC-recommended rfc1123-date format specified in 345 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} Section 4.1.1}. 346 + 347 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - The Set-Cookie Header *) 255 348 256 349 (** {1 Pretty Printing} *) 257 350 258 351 val pp : Format.formatter -> t -> unit 259 - (** Pretty print a cookie *) 352 + (** Pretty print a cookie. *)
+100 -12
lib/jar/cookeio_jar.ml
··· 22 22 23 23 (** {1 Cookie Matching Helpers} *) 24 24 25 + (** Two cookies are considered identical if they have the same name, domain, 26 + and path. This is used when replacing or removing cookies. 27 + 28 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 25 29 let cookie_identity_matches c1 c2 = 26 30 Cookeio.name c1 = Cookeio.name c2 27 31 && Cookeio.domain c1 = Cookeio.domain c2 28 32 && Cookeio.path c1 = Cookeio.path c2 29 33 34 + (** Normalize a domain by stripping the leading dot. 35 + 36 + Per RFC 6265, the Domain attribute value is canonicalized by removing any 37 + leading dot before storage. 38 + 39 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> RFC 6265 Section 5.2.3 - The Domain Attribute *) 30 40 let normalize_domain domain = 31 - (* Strip leading dot per RFC 6265 *) 32 41 match String.starts_with ~prefix:"." domain with 33 42 | true when String.length domain > 1 -> 34 43 String.sub domain 1 (String.length domain - 1) 35 44 | _ -> domain 36 45 46 + (** Check if a string is an IP address (IPv4 or IPv6). 47 + 48 + Per RFC 6265 Section 5.1.3, domain matching should only apply to hostnames, 49 + not IP addresses. IP addresses require exact match only. 50 + 51 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> RFC 6265 Section 5.1.3 - Domain Matching *) 52 + let is_ip_address domain = 53 + match Ipaddr.of_string domain with 54 + | Ok _ -> true 55 + | Error _ -> false 56 + 57 + (** Check if a cookie domain matches a request domain. 58 + 59 + Per RFC 6265 Section 5.1.3, a string domain-matches a given domain string if: 60 + - The domain string and the string are identical, OR 61 + - All of the following are true: 62 + - The domain string is a suffix of the string 63 + - The last character of the string not in the domain string is "." 64 + - The string is a host name (i.e., not an IP address) 65 + 66 + Additionally, per Section 5.3 Step 6, if the cookie has the host-only-flag 67 + set, only exact matches are allowed. 68 + 69 + @param host_only If true, only exact domain match is allowed 70 + @param cookie_domain The domain stored in the cookie (without leading dot) 71 + @param request_domain The domain from the HTTP request 72 + @return true if the cookie should be sent for this domain 73 + 74 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> RFC 6265 Section 5.1.3 - Domain Matching 75 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (host-only-flag) *) 37 76 let domain_matches ~host_only cookie_domain request_domain = 38 - (* RFC 6265 Section 5.4: Domain matching for Cookie header. 39 - Cookie domains are stored without leading dots per RFC 6265. *) 40 - request_domain = cookie_domain 41 - || (not host_only 42 - && String.ends_with ~suffix:("." ^ cookie_domain) request_domain) 77 + if is_ip_address request_domain then 78 + (* IP addresses: exact match only per Section 5.1.3 *) 79 + request_domain = cookie_domain 80 + else 81 + (* Hostnames: exact match or subdomain match (if not host_only) *) 82 + request_domain = cookie_domain 83 + || (not host_only 84 + && String.ends_with ~suffix:("." ^ cookie_domain) request_domain) 85 + 86 + (** Check if a cookie path matches a request path. 43 87 88 + Per RFC 6265 Section 5.1.4, a request-path path-matches a given cookie-path if: 89 + - The cookie-path and the request-path are identical, OR 90 + - The cookie-path is a prefix of the request-path, AND either: 91 + - The last character of the cookie-path is "/", OR 92 + - The first character of the request-path that is not included in the 93 + cookie-path is "/" 94 + 95 + @param cookie_path The path stored in the cookie 96 + @param request_path The path from the HTTP request 97 + @return true if the cookie should be sent for this path 98 + 99 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4> RFC 6265 Section 5.1.4 - Paths and Path-Match *) 44 100 let path_matches cookie_path request_path = 45 - (* RFC 6265 Section 5.1.4: A request-path path-matches a cookie-path if: 46 - 1. The cookie-path and the request-path are identical, OR 47 - 2. The cookie-path is a prefix of request-path AND cookie-path ends with "/", OR 48 - 3. The cookie-path is a prefix of request-path AND the first char of 49 - request-path not in cookie-path is "/" *) 50 101 if cookie_path = request_path then true 51 102 else if String.starts_with ~prefix:cookie_path request_path then 52 103 let cookie_len = String.length cookie_path in ··· 54 105 || (String.length request_path > cookie_len && request_path.[cookie_len] = '/') 55 106 else false 56 107 57 - (** {1 HTTP Date Parsing} *) 108 + (** {1 Cookie Expiration} *) 109 + 110 + (** Check if a cookie has expired based on its expiry-time. 111 + 112 + Per RFC 6265 Section 5.3, a cookie is expired if the current date and time 113 + is past the expiry-time. Session cookies (with no Expires or Max-Age) never 114 + expire via this check - they expire when the "session" ends. 115 + 116 + @param cookie The cookie to check 117 + @param clock The Eio clock for current time 118 + @return true if the cookie has expired 119 + 120 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 58 121 let is_expired cookie clock = 59 122 match Cookeio.expires cookie with 60 123 | None -> false (* No expiration *) ··· 118 181 Log.debug (fun m -> m "Returning %d delta cookies" (List.length result)); 119 182 result 120 183 184 + (** Create a removal cookie for deleting a cookie from the client. 185 + 186 + Per RFC 6265 Section 5.3, to remove a cookie, the server sends a Set-Cookie 187 + header with an expiry date in the past. We also set Max-Age=0 and an empty 188 + value for maximum compatibility. 189 + 190 + @param cookie The cookie to create a removal for 191 + @param clock The Eio clock for timestamps 192 + @return A new cookie configured to cause deletion 193 + 194 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 121 195 let make_removal_cookie cookie ~clock = 122 196 let now = 123 197 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch ··· 165 239 166 240 Eio.Mutex.unlock jar.mutex 167 241 242 + (** Retrieve cookies that should be sent for a given request. 243 + 244 + Per RFC 6265 Section 5.4, the user agent should include a Cookie header 245 + containing cookies that match the request-uri's domain, path, and security 246 + context. This function also updates the last-access-time for matched cookies. 247 + 248 + @param jar The cookie jar to search 249 + @param clock The Eio clock for timestamp updates 250 + @param domain The request domain (hostname or IP address) 251 + @param path The request path 252 + @param is_secure Whether the request is over a secure channel (HTTPS) 253 + @return List of cookies that should be included in the Cookie header 254 + 255 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *) 168 256 let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure 169 257 = 170 258 Log.debug (fun m ->
+56 -24
lib/jar/cookeio_jar.mli
··· 6 6 (** Cookie jar for storing and managing HTTP cookies. 7 7 8 8 This module provides a complete cookie jar implementation following 9 - established web standards while integrating Eio for efficient asynchronous 10 - operations. 9 + {{:https://datatracker.ietf.org/doc/html/rfc6265} RFC 6265} while 10 + integrating Eio for efficient asynchronous operations. 11 11 12 12 A cookie jar maintains a collection of cookies with automatic cleanup of 13 13 expired entries. It implements the standard browser behavior for cookie 14 14 storage, including: 15 15 - Automatic removal of expired cookies 16 - - Domain and path-based cookie retrieval 16 + - Domain and path-based cookie retrieval per 17 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4} Section 5.4} 17 18 - Delta tracking for Set-Cookie headers 18 - - Mozilla format persistence for cross-tool compatibility *) 19 + - Mozilla format persistence for cross-tool compatibility 20 + 21 + @see <https://datatracker.ietf.org/doc/html/rfc6265> RFC 6265 - HTTP State Management Mechanism *) 19 22 20 23 type t 21 24 (** Cookie jar for storing and managing cookies. 22 25 23 26 A cookie jar maintains a collection of cookies with automatic cleanup of 24 27 expired entries and enforcement of storage limits. It implements the 25 - standard browser behavior for cookie storage, including: 26 - - Automatic removal of expired cookies 27 - - LRU eviction when storage limits are exceeded 28 - - Domain and path-based cookie retrieval 29 - - Mozilla format persistence for cross-tool compatibility *) 28 + standard browser behavior for cookie storage per 29 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *) 30 30 31 31 (** {1 Cookie Jar Creation and Loading} *) 32 32 33 33 val create : unit -> t 34 - (** Create an empty cookie jar *) 34 + (** Create an empty cookie jar. *) 35 35 36 36 val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t 37 37 (** Load cookies from Mozilla format file. ··· 41 41 exist or cannot be loaded. *) 42 42 43 43 val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit 44 - (** Save cookies to Mozilla format file *) 44 + (** Save cookies to Mozilla format file. *) 45 45 46 46 (** {1 Cookie Jar Management} *) 47 47 ··· 50 50 51 51 The cookie is added to the delta, meaning it will appear in Set-Cookie 52 52 headers when calling {!delta}. If a cookie with the same name/domain/path 53 - exists in the delta, it will be replaced. *) 53 + exists in the delta, it will be replaced per 54 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *) 54 55 55 56 val add_original : t -> Cookeio.t -> unit 56 57 (** Add an original cookie to the jar. ··· 64 65 65 66 Returns cookies that have been added via {!add_cookie} and removal cookies 66 67 for original cookies that have been removed. Does not include original 67 - cookies that were added via {!add_original}. *) 68 + cookies that were added via {!add_original}. 69 + 70 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - Set-Cookie *) 68 71 69 72 val remove : t -> clock:_ Eio.Time.clock -> Cookeio.t -> unit 70 73 (** Remove a cookie from the jar. 71 74 72 75 If an original cookie with the same name/domain/path exists, creates a 73 76 removal cookie (empty value, Max-Age=0, past expiration) that appears in the 74 - delta. If only a delta cookie exists, simply removes it from the delta. *) 77 + delta. If only a delta cookie exists, simply removes it from the delta. 78 + 79 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 80 + cookies are removed by sending a Set-Cookie with an expiry date in the past. 81 + 82 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 75 83 76 84 val get_cookies : 77 85 t -> ··· 85 93 Returns all cookies that match the given domain and path, and satisfy the 86 94 secure flag requirement. Combines original and delta cookies, with delta 87 95 taking precedence. Excludes removal cookies (empty value). Also updates the 88 - last access time of matching cookies using the provided clock. *) 96 + last access time of matching cookies using the provided clock. 97 + 98 + Domain matching follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 5.1.3}: 99 + - IP addresses require exact match only 100 + - Hostnames support subdomain matching unless host-only flag is set 101 + 102 + Path matching follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 5.1.4}. 103 + 104 + @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *) 89 105 90 106 val clear : t -> unit 91 - (** Clear all cookies *) 107 + (** Clear all cookies. *) 92 108 93 109 val clear_expired : t -> clock:_ Eio.Time.clock -> unit 94 - (** Clear expired cookies *) 110 + (** Clear expired cookies. 111 + 112 + Removes cookies whose expiry-time is in the past per 113 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *) 95 114 96 115 val clear_session_cookies : t -> unit 97 - (** Clear session cookies (those without expiry) *) 116 + (** Clear session cookies. 117 + 118 + Removes cookies that have no Expires or Max-Age attribute (session cookies). 119 + Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}, 120 + these cookies are normally removed when the user agent "session" ends. *) 98 121 99 122 val count : t -> int 100 - (** Get the number of cookies in the jar *) 123 + (** Get the number of unique cookies in the jar. *) 101 124 102 125 val get_all_cookies : t -> Cookeio.t list 103 - (** Get all cookies in the jar *) 126 + (** Get all cookies in the jar. *) 104 127 105 128 val is_empty : t -> bool 106 - (** Check if the jar is empty *) 129 + (** Check if the jar is empty. *) 107 130 108 131 (** {1 Pretty Printing} *) 109 132 110 133 val pp : Format.formatter -> t -> unit 111 - (** Pretty print a cookie jar *) 134 + (** Pretty print a cookie jar. *) 112 135 113 136 (** {1 Mozilla Format} *) 114 137 115 138 val to_mozilla_format : t -> string 116 - (** Write cookies in Mozilla format *) 139 + (** Serialize cookies in Mozilla/Netscape cookie format. 140 + 141 + The Mozilla format uses tab-separated fields: 142 + {[domain \t include_subdomains \t path \t secure \t expires \t name \t value]} 143 + 144 + The [include_subdomains] field corresponds to the inverse of the 145 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} host-only-flag} 146 + in RFC 6265. *) 117 147 118 148 val from_mozilla_format : clock:_ Eio.Time.clock -> string -> t 119 149 (** Parse Mozilla format cookies. 120 150 121 151 Creates a cookie jar from a string in Mozilla cookie format, using the 122 - provided clock to set creation and last access times. *) 152 + provided clock to set creation and last access times. The [include_subdomains] 153 + field is mapped to the host-only-flag per 154 + {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
+1 -1
lib/jar/dune
··· 1 1 (library 2 2 (name cookeio_jar) 3 3 (public_name cookeio.jar) 4 - (libraries cookeio eio logs ptime unix)) 4 + (libraries cookeio eio logs ptime unix ipaddr))
+164
test/test_cookeio.ml
··· 2206 2206 in 2207 2207 Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3) 2208 2208 2209 + (* ============================================================================ *) 2210 + (* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *) 2211 + (* ============================================================================ *) 2212 + 2213 + let test_ipv4_exact_match () = 2214 + Eio_mock.Backend.run @@ fun () -> 2215 + let clock = Eio_mock.Clock.make () in 2216 + Eio_mock.Clock.set_time clock 1000.0; 2217 + 2218 + let jar = create () in 2219 + let cookie = 2220 + Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val" 2221 + ~secure:false ~http_only:false ~host_only:false 2222 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2223 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2224 + in 2225 + add_cookie jar cookie; 2226 + 2227 + (* IPv4 cookie should match exact IP *) 2228 + let cookies = 2229 + get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false 2230 + in 2231 + Alcotest.(check int) "IPv4 exact match" 1 (List.length cookies) 2232 + 2233 + let test_ipv4_no_suffix_match () = 2234 + Eio_mock.Backend.run @@ fun () -> 2235 + let clock = Eio_mock.Clock.make () in 2236 + Eio_mock.Clock.set_time clock 1000.0; 2237 + 2238 + let jar = create () in 2239 + (* Cookie for 168.1.1 - this should NOT match requests to 192.168.1.1 2240 + even though "192.168.1.1" ends with ".168.1.1" *) 2241 + let cookie = 2242 + Cookeio.make ~domain:"168.1.1" ~path:"/" ~name:"test" ~value:"val" 2243 + ~secure:false ~http_only:false ~host_only:false 2244 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2245 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2246 + in 2247 + add_cookie jar cookie; 2248 + 2249 + (* Should NOT match - IP addresses don't do suffix matching *) 2250 + let cookies = 2251 + get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false 2252 + in 2253 + Alcotest.(check int) "IPv4 no suffix match" 0 (List.length cookies) 2254 + 2255 + let test_ipv4_different_ip () = 2256 + Eio_mock.Backend.run @@ fun () -> 2257 + let clock = Eio_mock.Clock.make () in 2258 + Eio_mock.Clock.set_time clock 1000.0; 2259 + 2260 + let jar = create () in 2261 + let cookie = 2262 + Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val" 2263 + ~secure:false ~http_only:false ~host_only:false 2264 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2265 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2266 + in 2267 + add_cookie jar cookie; 2268 + 2269 + (* Different IP should not match *) 2270 + let cookies = 2271 + get_cookies jar ~clock ~domain:"192.168.1.2" ~path:"/" ~is_secure:false 2272 + in 2273 + Alcotest.(check int) "different IPv4 no match" 0 (List.length cookies) 2274 + 2275 + let test_ipv6_exact_match () = 2276 + Eio_mock.Backend.run @@ fun () -> 2277 + let clock = Eio_mock.Clock.make () in 2278 + Eio_mock.Clock.set_time clock 1000.0; 2279 + 2280 + let jar = create () in 2281 + let cookie = 2282 + Cookeio.make ~domain:"::1" ~path:"/" ~name:"test" ~value:"val" 2283 + ~secure:false ~http_only:false ~host_only:false 2284 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2285 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2286 + in 2287 + add_cookie jar cookie; 2288 + 2289 + (* IPv6 loopback should match exactly *) 2290 + let cookies = 2291 + get_cookies jar ~clock ~domain:"::1" ~path:"/" ~is_secure:false 2292 + in 2293 + Alcotest.(check int) "IPv6 exact match" 1 (List.length cookies) 2294 + 2295 + let test_ipv6_full_format () = 2296 + Eio_mock.Backend.run @@ fun () -> 2297 + let clock = Eio_mock.Clock.make () in 2298 + Eio_mock.Clock.set_time clock 1000.0; 2299 + 2300 + let jar = create () in 2301 + let cookie = 2302 + Cookeio.make ~domain:"2001:db8::1" ~path:"/" ~name:"test" ~value:"val" 2303 + ~secure:false ~http_only:false ~host_only:false 2304 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2305 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2306 + in 2307 + add_cookie jar cookie; 2308 + 2309 + (* IPv6 should match exactly *) 2310 + let cookies = 2311 + get_cookies jar ~clock ~domain:"2001:db8::1" ~path:"/" ~is_secure:false 2312 + in 2313 + Alcotest.(check int) "IPv6 full format match" 1 (List.length cookies); 2314 + 2315 + (* Different IPv6 should not match *) 2316 + let cookies2 = 2317 + get_cookies jar ~clock ~domain:"2001:db8::2" ~path:"/" ~is_secure:false 2318 + in 2319 + Alcotest.(check int) "different IPv6 no match" 0 (List.length cookies2) 2320 + 2321 + let test_ip_vs_hostname () = 2322 + Eio_mock.Backend.run @@ fun () -> 2323 + let clock = Eio_mock.Clock.make () in 2324 + Eio_mock.Clock.set_time clock 1000.0; 2325 + 2326 + let jar = create () in 2327 + 2328 + (* Add a hostname cookie with host_only=false (domain cookie) *) 2329 + let hostname_cookie = 2330 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostname" ~value:"h1" 2331 + ~secure:false ~http_only:false ~host_only:false 2332 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2333 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2334 + in 2335 + add_cookie jar hostname_cookie; 2336 + 2337 + (* Add an IP cookie with host_only=false *) 2338 + let ip_cookie = 2339 + Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"ip" ~value:"i1" 2340 + ~secure:false ~http_only:false ~host_only:false 2341 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2342 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2343 + in 2344 + add_cookie jar ip_cookie; 2345 + 2346 + (* Hostname request should match hostname cookie and subdomains *) 2347 + let cookies1 = 2348 + get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false 2349 + in 2350 + Alcotest.(check int) "hostname matches hostname cookie" 1 (List.length cookies1); 2351 + 2352 + let cookies2 = 2353 + get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false 2354 + in 2355 + Alcotest.(check int) "subdomain matches hostname cookie" 1 (List.length cookies2); 2356 + 2357 + (* IP request should only match IP cookie exactly *) 2358 + let cookies3 = 2359 + get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false 2360 + in 2361 + Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3); 2362 + Alcotest.(check string) "IP cookie is returned" "ip" (Cookeio.name (List.hd cookies3)) 2363 + 2209 2364 let () = 2210 2365 Eio_main.run @@ fun env -> 2211 2366 let open Alcotest in ··· 2366 2521 test_path_matching_no_false_prefix; 2367 2522 test_case "root path matches all" `Quick test_path_matching_root; 2368 2523 test_case "path no match" `Quick test_path_matching_no_match; 2524 + ] ); 2525 + ( "ip_address_matching", 2526 + [ 2527 + test_case "IPv4 exact match" `Quick test_ipv4_exact_match; 2528 + test_case "IPv4 no suffix match" `Quick test_ipv4_no_suffix_match; 2529 + test_case "IPv4 different IP no match" `Quick test_ipv4_different_ip; 2530 + test_case "IPv6 exact match" `Quick test_ipv6_exact_match; 2531 + test_case "IPv6 full format" `Quick test_ipv6_full_format; 2532 + test_case "IP vs hostname behavior" `Quick test_ip_vs_hostname; 2369 2533 ] ); 2370 2534 ]