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

more specific docs

+168
RFC-TODO.md
···
···
+
# RFC 6265 Compliance TODO
+
+
This document tracks deviations from [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) (HTTP State Management Mechanism) and missing features in ocaml-cookeio.
+
+
## High Priority
+
+
### 1. Public Suffix Validation (Section 5.3, Step 5)
+
+
**Status:** Not implemented
+
+
The RFC requires rejecting cookies with domains that are "public suffixes" (e.g., `.com`, `.co.uk`) to prevent domain-wide cookie attacks.
+
+
**Required behavior:**
+
- Maintain or reference a public suffix list (e.g., from [publicsuffix.org](https://publicsuffix.org/))
+
- Reject cookies where the Domain attribute is a public suffix (unless it exactly matches the request host)
+
+
**Security impact:** Without this, an attacker on `evil.com` could potentially set cookies for `.com` affecting all `.com` sites.
+
+
---
+
+
## Medium Priority
+
+
### 2. IP Address Domain Matching (Section 5.1.3)
+
+
**Status:** ✅ IMPLEMENTED
+
+
The RFC specifies that domain suffix matching should only apply to host names, not IP addresses.
+
+
**Implementation:**
+
- Uses the `ipaddr` library to detect IPv4 and IPv6 addresses
+
- IP addresses require exact match only (no suffix matching)
+
- Hostnames continue to support subdomain matching when `host_only = false`
+
+
---
+
+
### 3. Expires Header Date Format (Section 4.1.1)
+
+
**Status:** Wrong format
+
+
**Current behavior:** Outputs RFC3339 format (`2021-06-09T10:18:14+00:00`)
+
+
**RFC requirement:** Use `rfc1123-date` format (`Wed, 09 Jun 2021 10:18:14 GMT`)
+
+
**Location:** `cookeio.ml:447-448`
+
+
**Fix:** Implement RFC1123 date formatting for Set-Cookie header output.
+
+
---
+
+
### 4. Cookie Ordering in Header (Section 5.4, Step 2)
+
+
**Status:** Not implemented
+
+
When generating Cookie headers, cookies SHOULD be sorted:
+
1. Cookies with longer paths listed first
+
2. Among equal-length paths, earlier creation-times listed first
+
+
**Location:** `get_cookies` function in `cookeio_jar.ml`
+
+
---
+
+
### 5. Creation Time Preservation (Section 5.3, Step 11.3)
+
+
**Status:** Not implemented
+
+
When replacing an existing cookie (same name/domain/path), the creation-time of the old cookie should be preserved.
+
+
**Current behavior:** Completely replaces cookie, losing original creation time.
+
+
**Location:** `add_cookie` and `add_original` functions in `cookeio_jar.ml`
+
+
---
+
+
### 6. Default Path Computation (Section 5.1.4)
+
+
**Status:** Not implemented (caller responsibility)
+
+
The RFC specifies an algorithm for computing default path when Path attribute is absent:
+
1. If uri-path is empty or doesn't start with `/`, return `/`
+
2. If uri-path contains only one `/`, return `/`
+
3. Return characters up to (but not including) the rightmost `/`
+
+
**Suggestion:** Add `default_path : string -> string` helper function.
+
+
---
+
+
## Low Priority
+
+
### 7. Storage Limits (Section 6.1)
+
+
**Status:** Not implemented
+
+
RFC recommends minimum capabilities:
+
- At least 4096 bytes per cookie
+
- At least 50 cookies per domain
+
- At least 3000 cookies total
+
+
**Suggestion:** Add configurable limits with RFC-recommended defaults.
+
+
---
+
+
### 8. Excess Cookie Eviction (Section 5.3)
+
+
**Status:** Not implemented
+
+
When storage limits are exceeded, evict in priority order:
+
1. Expired cookies
+
2. Cookies sharing domain with many others
+
3. All cookies
+
+
Tiebreaker: earliest `last-access-time` first (LRU).
+
+
---
+
+
### 9. Two-Digit Year Parsing (Section 5.1.1)
+
+
**Status:** Minor deviation
+
+
**RFC specification:**
+
- Years 70-99 → add 1900
+
- Years 0-69 → add 2000
+
+
**Current code** (`cookeio.ml:128-130`):
+
```ocaml
+
if year >= 0 && year <= 68 then year + 2000
+
else if year >= 69 && year <= 99 then year + 1900
+
```
+
+
**Issue:** Year 69 is treated as 1969, but RFC says 70-99 get 1900, implying 69 should get 2000.
+
+
---
+
+
## Compliant Features
+
+
The following RFC requirements are correctly implemented:
+
+
- [x] Case-insensitive attribute name matching (Section 5.2)
+
- [x] Leading dot removal from Domain attribute (Section 5.2.3)
+
- [x] Max-Age takes precedence over Expires (Section 5.3, Step 3)
+
- [x] Secure flag handling (Section 5.2.5)
+
- [x] HttpOnly flag handling (Section 5.2.6)
+
- [x] Cookie date parsing with multiple format support (Section 5.1.1)
+
- [x] Session vs persistent cookie distinction (Section 5.3)
+
- [x] Last-access-time updates on retrieval (Section 5.4, Step 3)
+
- [x] Host-only flag for domain matching (Section 5.3, Step 6)
+
- [x] Path matching algorithm (Section 5.1.4)
+
- [x] IP address domain matching - exact match only (Section 5.1.3)
+
+
---
+
+
## Extensions Beyond RFC 6265
+
+
These features are implemented but not part of RFC 6265:
+
+
| Feature | Specification |
+
|---------|---------------|
+
| SameSite | RFC 6265bis (draft) |
+
| Partitioned | CHIPS proposal |
+
| Mozilla format | De facto standard |
+
+
---
+
+
## References
+
+
- [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) - HTTP State Management Mechanism
+
- [RFC 6265bis](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis) - Updated cookie spec (draft)
+
- [Public Suffix List](https://publicsuffix.org/) - Mozilla's public suffix database
+
- [CHIPS](https://developer.chrome.com/docs/privacy-sandbox/chips/) - Cookies Having Independent Partitioned State
+1
cookeio.opam
···
"dune" {>= "3.20" & >= "3.20"}
"logs" {>= "0.10.0"}
"ptime" {>= "1.1.0"}
"eio_main"
"alcotest" {with-test}
"odoc" {with-doc}
···
"dune" {>= "3.20" & >= "3.20"}
"logs" {>= "0.10.0"}
"ptime" {>= "1.1.0"}
+
"ipaddr" {>= "5.0.0"}
"eio_main"
"alcotest" {with-test}
"odoc" {with-doc}
+1
dune-project
···
(dune (>= 3.20))
(logs (>= 0.10.0))
(ptime (>= 1.1.0))
eio_main
(alcotest :with-test)
(odoc :with-doc)))
···
(dune (>= 3.20))
(logs (>= 0.10.0))
(ptime (>= 1.1.0))
+
(ipaddr (>= 5.0.0))
eio_main
(alcotest :with-test)
(odoc :with-doc)))
+111 -12
lib/core/cookeio.ml
···
module Log = (val Logs.src_log src : Logs.LOG)
module SameSite = struct
type t = [ `Strict | `Lax | `None ]
···
| `None -> Format.pp_print_string ppf "None"
end
module Expiration = struct
type t = [ `Session | `DateTime of Ptime.t ]
···
(** {1 Cookie Parsing Helpers} *)
let normalize_domain domain =
-
(* Strip leading dot per RFC 6265 *)
match String.starts_with ~prefix:"." domain with
| true when String.length domain > 1 ->
String.sub domain 1 (String.length domain - 1)
| _ -> domain
-
(** {1 HTTP Date Parsing} *)
module DateParser = struct
-
(** Month name to number mapping (case-insensitive) *)
let month_of_string s =
match String.lowercase_ascii s with
| "jan" -> Some 1
···
| "dec" -> Some 12
| _ -> None
-
(** Normalize abbreviated years:
-
- Years 69-99 get 1900 added (e.g., 95 → 1995)
-
- Years 0-68 get 2000 added (e.g., 25 → 2025)
-
- Years >= 100 are returned as-is *)
let normalize_year year =
if year >= 0 && year <= 68 then year + 2000
else if year >= 69 && year <= 99 then year + 1900
···
same_site = None;
}
-
(** Parse a single attribute and update the accumulator in-place *)
let parse_attribute now attrs attr_name attr_value =
let attr_lower = String.lowercase_ascii attr_name in
match attr_lower with
···
| _ ->
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
-
(** Validate cookie attributes and log warnings for invalid combinations *)
let validate_attributes attrs =
(* SameSite=None requires Secure flag *)
let samesite_valid =
···
samesite_valid && partitioned_valid
(** Build final cookie from name/value and accumulated attributes.
-
Per RFC 6265 Section 5.3:
-
- If Domain attribute is present, host_only = false, domain = attribute value
-
- If Domain attribute is absent, host_only = true, domain = request host *)
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
let host_only, domain =
match attrs.domain with
···
(** {1 Cookie Parsing} *)
let of_set_cookie_header ~now ~domain:request_domain ~path:request_path
header_value =
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
···
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
Some cookie)
let of_cookie_header ~now ~domain ~path header_value =
Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
···
Ok cookie)
parts
let make_cookie_header cookies =
cookies
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
|> String.concat "; "
let make_set_cookie_header cookie =
let buffer = Buffer.create 128 in
Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
···
module Log = (val Logs.src_log src : Logs.LOG)
+
(** SameSite attribute for cross-site request control.
+
+
The SameSite attribute is defined in the RFC 6265bis draft and controls
+
whether cookies are sent with cross-site requests.
+
+
@see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *)
module SameSite = struct
type t = [ `Strict | `Lax | `None ]
···
| `None -> Format.pp_print_string ppf "None"
end
+
(** Cookie expiration type.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
cookies have either a persistent expiry time or are session cookies that
+
expire when the user agent session ends.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
module Expiration = struct
type t = [ `Session | `DateTime of Ptime.t ]
···
(** {1 Cookie Parsing Helpers} *)
+
(** Normalize a domain by stripping the leading dot.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 Section 5.2.3},
+
if the first character of the Domain attribute value is ".", that character
+
is ignored (the domain remains case-insensitive).
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> RFC 6265 Section 5.2.3 - The Domain Attribute *)
let normalize_domain domain =
match String.starts_with ~prefix:"." domain with
| true when String.length domain > 1 ->
String.sub domain 1 (String.length domain - 1)
| _ -> domain
+
(** {1 HTTP Date Parsing}
+
+
Date parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1}
+
which requires parsing dates in various HTTP formats. *)
module DateParser = struct
+
(** Month name to number mapping (case-insensitive).
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1},
+
month tokens are matched case-insensitively.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *)
let month_of_string s =
match String.lowercase_ascii s with
| "jan" -> Some 1
···
| "dec" -> Some 12
| _ -> None
+
(** Normalize abbreviated years per RFC 6265.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1}:
+
- Years 70-99 get 1900 added (e.g., 95 → 1995)
+
- Years 0-69 get 2000 added (e.g., 25 → 2025)
+
- Years >= 100 are returned as-is
+
+
Note: This implementation treats year 69 as 1969 (adding 1900), which
+
technically differs from the RFC's "70 and less than or equal to 99" rule.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *)
let normalize_year year =
if year >= 0 && year <= 68 then year + 2000
else if year >= 69 && year <= 99 then year + 1900
···
same_site = None;
}
+
(** Parse a single cookie attribute and update the accumulator in-place.
+
+
Attribute parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2}
+
which defines the grammar and semantics for each cookie attribute.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
let parse_attribute now attrs attr_name attr_value =
let attr_lower = String.lowercase_ascii attr_name in
match attr_lower with
···
| _ ->
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
+
(** Validate cookie attributes and log warnings for invalid combinations.
+
+
Validates:
+
- SameSite=None requires the Secure flag (per RFC 6265bis)
+
- Partitioned requires the Secure flag (per CHIPS specification)
+
+
@see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - SameSite
+
@see <https://developer.chrome.com/docs/privacy-sandbox/chips/> CHIPS - Cookies Having Independent Partitioned State *)
let validate_attributes attrs =
(* SameSite=None requires Secure flag *)
let samesite_valid =
···
samesite_valid && partitioned_valid
(** Build final cookie from name/value and accumulated attributes.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}:
+
- If Domain attribute is present, host-only-flag = false, domain = attribute value
+
- If Domain attribute is absent, host-only-flag = true, domain = request host
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
let host_only, domain =
match attrs.domain with
···
(** {1 Cookie Parsing} *)
+
(** Parse a Set-Cookie HTTP response header.
+
+
Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2},
+
extracting the cookie name, value, and all attributes. Returns [None] if
+
the cookie is invalid or fails validation.
+
+
@param now Function returning current time for Max-Age computation
+
@param domain The request host (used as default domain)
+
@param path The request path (used as default path)
+
@param header_value The Set-Cookie header value string
+
@return The parsed cookie, or [None] if parsing/validation fails
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
let of_set_cookie_header ~now ~domain:request_domain ~path:request_path
header_value =
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
···
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
Some cookie)
+
(** Parse a Cookie HTTP request header.
+
+
Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}.
+
The Cookie header contains semicolon-separated name=value pairs.
+
+
Cookies parsed from the Cookie header have [host_only = true] since we
+
cannot determine from the header alone whether they originally had a
+
Domain attribute.
+
+
@param now Function returning current time for timestamps
+
@param domain The request host (assigned to all parsed cookies)
+
@param path The request path (assigned to all parsed cookies)
+
@param header_value The Cookie header value string
+
@return List of parse results (Ok cookie or Error message)
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
let of_cookie_header ~now ~domain ~path header_value =
Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
···
Ok cookie)
parts
+
(** Generate a Cookie HTTP request header from a list of cookies.
+
+
Formats cookies according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}
+
as semicolon-separated name=value pairs.
+
+
@param cookies List of cookies to include
+
@return The Cookie header value string
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
let make_cookie_header cookies =
cookies
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
|> String.concat "; "
+
(** Generate a Set-Cookie HTTP response header from a cookie.
+
+
Formats the cookie according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 Section 4.1}
+
including all attributes.
+
+
Note: The Expires attribute is currently formatted using RFC 3339, which
+
differs from the RFC-recommended rfc1123-date format.
+
+
@param cookie The cookie to serialize
+
@return The Set-Cookie header value string
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - The Set-Cookie Header *)
let make_set_cookie_header cookie =
let buffer = Buffer.create 128 in
Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
+167 -74
lib/core/cookeio.mli
···
(** Cookie management library for OCaml
-
HTTP cookies are a mechanism that allows "server side connections to store
-
and retrieve information on the client side." Originally designed to enable
-
persistent client-side state for web applications, cookies are essential for
-
storing user preferences, session data, shopping cart contents, and
-
authentication tokens.
-
This library provides a complete cookie jar implementation following
-
established web standards while integrating Eio for efficient asynchronous
-
operations.
{2 Cookie Format and Structure}
-
Cookies are set via the Set-Cookie HTTP response header with the basic
-
format: [NAME=VALUE] with optional attributes including:
-
- [expires]: Optional cookie lifetime specification
-
- [domain]: Specifying valid domains using tail matching
-
- [path]: Defining URL subset for cookie validity
- [secure]: Transmission over secure channels only
- [httponly]: Not accessible to JavaScript
-
- [samesite]: Cross-site request behavior control
{2 Domain and Path Matching}
-
The library implements standard domain and path matching rules:
-
- Domain matching uses "tail matching" (e.g., "acme.com" matches
-
"anvil.acme.com")
-
- Path matching allows subset URL specification for fine-grained control
-
- More specific path mappings are sent first in Cookie headers *)
module SameSite : sig
type t = [ `Strict | `Lax | `None ]
(** Cookie same-site policy for controlling cross-site request behavior.
- [`Strict]: Cookie only sent for same-site requests, providing maximum
protection
- [`Lax]: Cookie sent for same-site requests and top-level navigation
(default for modern browsers)
- [`None]: Cookie sent for all cross-site requests (requires [secure]
-
flag) *)
val equal : t -> t -> bool
-
(** Equality function for same-site values *)
val pp : Format.formatter -> t -> unit
-
(** Pretty printer for same-site values *)
end
module Expiration : sig
type t = [ `Session | `DateTime of Ptime.t ]
(** Cookie expiration strategy.
-
- [`Session]: Session cookie that expires when browser session ends
-
- [`DateTime time]: Persistent cookie that expires at specific time *)
val equal : t -> t -> bool
-
(** Equality function for expiration values *)
val pp : Format.formatter -> t -> unit
-
(** Pretty printer for expiration values *)
end
type t
(** HTTP Cookie representation with all standard attributes.
A cookie represents a name-value pair with associated metadata that controls
-
its scope, security, and lifetime. Cookies with the same [name], [domain],
-
and [path] will overwrite each other when added to a cookie jar. *)
(** {1 Cookie Accessors} *)
val domain : t -> string
-
(** Get the domain of a cookie *)
val path : t -> string
-
(** Get the path of a cookie *)
val name : t -> string
-
(** Get the name of a cookie *)
val value : t -> string
-
(** Get the value of a cookie *)
val value_trimmed : t -> string
(** Get cookie value with surrounding double-quotes removed if they form a
···
Only removes quotes when both opening and closing quotes are present. The
raw value is always preserved in {!value}. This is useful for handling
-
quoted cookie values per RFC 6265.
Examples:
- ["value"] → ["value"]
···
- ["\"val\"\""] → ["val\""] (removes outer pair only) *)
val secure : t -> bool
-
(** Check if cookie is secure only *)
val http_only : t -> bool
-
(** Check if cookie is HTTP only *)
val partitioned : t -> bool
(** Check if cookie has the Partitioned attribute.
···
Partitioned cookies are part of CHIPS (Cookies Having Independent
Partitioned State) and are stored separately per top-level site, enabling
privacy-preserving third-party cookie functionality. Partitioned cookies
-
must always be Secure. *)
val host_only : t -> bool
(** Check if cookie has the host-only flag set.
-
Per RFC 6265 Section 5.3:
-
- If the Set-Cookie header included a Domain attribute, host_only is false
-
and the cookie matches the domain and all subdomains.
-
- If no Domain attribute was present, host_only is true and the cookie
only matches the exact request host.
Example:
- Cookie set on "example.com" with Domain=example.com: host_only=false,
matches example.com and sub.example.com
- Cookie set on "example.com" without Domain attribute: host_only=true,
-
matches only example.com, not sub.example.com *)
val expires : t -> Expiration.t option
(** Get the expiration attribute if set.
-
- [None]: No expiration specified (browser decides lifetime)
-
- [Some `Session]: Session cookie (expires when browser session ends)
- [Some (`DateTime t)]: Expires at specific time [t]
Both [max_age] and [expires] can be present simultaneously. This library
-
stores both independently. *)
val max_age : t -> Ptime.Span.t option
(** Get the max-age attribute if set.
-
Both [max_age] and [expires] can be present simultaneously. When both are
-
present in a Set-Cookie header, browsers prioritize [max_age] per RFC 6265.
-
This library stores both independently and serializes both when present. *)
val same_site : t -> SameSite.t option
-
(** Get the same-site policy of a cookie *)
val creation_time : t -> Ptime.t
-
(** Get the creation time of a cookie *)
val last_access : t -> Ptime.t
-
(** Get the last access time of a cookie *)
val make :
domain:string ->
···
t
(** Create a new cookie with the given attributes.
-
@param host_only If true, the cookie only matches the exact domain (no
-
subdomains). Defaults to false. Per RFC 6265, this should be true when no
-
Domain attribute was present in the Set-Cookie header.
Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
-
combinations will result in validation errors. *)
(** {1 Cookie Creation and Parsing} *)
···
now:(unit -> Ptime.t) -> domain:string -> path:string -> string -> t option
(** Parse Set-Cookie response header value into a cookie.
-
Set-Cookie headers are sent from server to client and contain the cookie
-
name, value, and all attributes.
-
-
Parses a Set-Cookie header value following RFC specifications:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
- Supports all standard attributes: [expires], [max-age], [domain], [path],
[secure], [httponly], [samesite], [partitioned]
···
- The [now] parameter is used for calculating expiry times from [max-age]
attributes and setting creation/access times
-
Cookie validation rules:
- [SameSite=None] requires the [Secure] flag to be set
- [Partitioned] requires the [Secure] flag to be set
Example:
-
[of_set_cookie_header ~now:(fun () -> Ptime_clock.now ())
-
~domain:"example.com" ~path:"/" "session=abc123; Secure; HttpOnly"] *)
val of_cookie_header :
now:(unit -> Ptime.t) ->
···
(t, string) result list
(** Parse Cookie request header containing semicolon-separated name=value pairs.
-
Cookie headers are sent from client to server and contain only name=value
-
pairs without attributes: ["name1=value1; name2=value2; name3=value3"]
Creates cookies with:
- Provided [domain] and [path] from request context
- All security flags set to [false] (defaults)
- All optional attributes set to [None]
- [creation_time] and [last_access] set to current time from [now]
Returns a list of parse results, one per cookie. Parse errors for individual
-
cookies are returned as [Error msg] without failing the entire parse. Empty
-
values and excess whitespace are ignored.
Example:
-
[of_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com"
-
~path:"/" "session=abc; theme=dark"] *)
val make_cookie_header : t list -> string
-
(** Create cookie header value from cookies.
Formats a list of cookies into a Cookie header value suitable for HTTP
-
requests.
- Format: [name1=value1; name2=value2; name3=value3]
- Only includes cookie names and values, not attributes
- Cookies should already be filtered for the target domain/path
-
- More specific path mappings should be ordered first in the input list
Example: [make_cookie_header cookies] might return
-
["session=abc123; theme=dark"] *)
val make_set_cookie_header : t -> string
(** Create Set-Cookie header value from a cookie.
-
Formats a cookie into a Set-Cookie header value suitable for HTTP responses.
Includes all cookie attributes: Max-Age, Expires, Domain, Path, Secure,
-
HttpOnly, and SameSite. *)
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
-
(** Pretty print a cookie *)
···
(** Cookie management library for OCaml
+
HTTP cookies are a mechanism defined in
+
{{:https://datatracker.ietf.org/doc/html/rfc6265} RFC 6265} that allows
+
"server side connections to store and retrieve information on the client
+
side." Originally designed to enable persistent client-side state for web
+
applications, cookies are essential for storing user preferences, session
+
data, shopping cart contents, and authentication tokens.
+
This library provides a complete cookie implementation following RFC 6265
+
while integrating Eio for efficient asynchronous operations.
{2 Cookie Format and Structure}
+
Cookies are set via the Set-Cookie HTTP response header
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} Section 4.1})
+
with the basic format: [NAME=VALUE] with optional attributes including:
+
- [expires]: Cookie lifetime specification
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1} Section 5.2.1})
+
- [max-age]: Cookie lifetime in seconds
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2} Section 5.2.2})
+
- [domain]: Valid domains using tail matching
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} Section 5.2.3})
+
- [path]: URL subset for cookie validity
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4} Section 5.2.4})
- [secure]: Transmission over secure channels only
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5} Section 5.2.5})
- [httponly]: Not accessible to JavaScript
+
({{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6} Section 5.2.6})
+
- [samesite]: Cross-site request behavior (RFC 6265bis)
+
- [partitioned]: CHIPS partitioned storage
{2 Domain and Path Matching}
+
The library implements standard domain and path matching rules from
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 5.1.3}
+
and {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 5.1.4}:
+
- Domain matching uses suffix matching for hostnames (e.g., "example.com"
+
matches "sub.example.com")
+
- IP addresses require exact match only
+
- Path matching requires exact match or prefix with "/" separator
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265> RFC 6265 - HTTP State Management Mechanism *)
+
+
(** {1 Types} *)
module SameSite : sig
type t = [ `Strict | `Lax | `None ]
(** Cookie same-site policy for controlling cross-site request behavior.
+
Defined in RFC 6265bis draft.
+
- [`Strict]: Cookie only sent for same-site requests, providing maximum
protection
- [`Lax]: Cookie sent for same-site requests and top-level navigation
(default for modern browsers)
- [`None]: Cookie sent for all cross-site requests (requires [secure]
+
flag per RFC 6265bis)
+
+
@see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *)
val equal : t -> t -> bool
+
(** Equality function for same-site values. *)
val pp : Format.formatter -> t -> unit
+
(** Pretty printer for same-site values. *)
end
module Expiration : sig
type t = [ `Session | `DateTime of Ptime.t ]
(** Cookie expiration strategy.
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}:
+
- [`Session]: Session cookie that expires when user agent session ends
+
(persistent-flag = false)
+
- [`DateTime time]: Persistent cookie that expires at specific time
+
(persistent-flag = true)
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
val equal : t -> t -> bool
+
(** Equality function for expiration values. *)
val pp : Format.formatter -> t -> unit
+
(** Pretty printer for expiration values. *)
end
type t
(** HTTP Cookie representation with all standard attributes.
A cookie represents a name-value pair with associated metadata that controls
+
its scope, security, and lifetime. Per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
cookies with the same [name], [domain], and [path] will overwrite each other
+
when stored.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
(** {1 Cookie Accessors} *)
val domain : t -> string
+
(** Get the domain of a cookie.
+
+
The domain is normalized per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 Section 5.2.3}
+
(leading dots removed). *)
val path : t -> string
+
(** Get the path of a cookie.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4> RFC 6265 Section 5.2.4 - The Path Attribute *)
val name : t -> string
+
(** Get the name of a cookie. *)
val value : t -> string
+
(** Get the value of a cookie. *)
val value_trimmed : t -> string
(** Get cookie value with surrounding double-quotes removed if they form a
···
Only removes quotes when both opening and closing quotes are present. The
raw value is always preserved in {!value}. This is useful for handling
+
quoted cookie values.
Examples:
- ["value"] → ["value"]
···
- ["\"val\"\""] → ["val\""] (removes outer pair only) *)
val secure : t -> bool
+
(** Check if cookie has the Secure flag.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5} RFC 6265 Section 5.2.5},
+
Secure cookies are only sent over HTTPS connections.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5> RFC 6265 Section 5.2.5 - The Secure Attribute *)
val http_only : t -> bool
+
(** Check if cookie has the HttpOnly flag.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6} RFC 6265 Section 5.2.6},
+
HttpOnly cookies are not accessible to client-side scripts.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6> RFC 6265 Section 5.2.6 - The HttpOnly Attribute *)
val partitioned : t -> bool
(** Check if cookie has the Partitioned attribute.
···
Partitioned cookies are part of CHIPS (Cookies Having Independent
Partitioned State) and are stored separately per top-level site, enabling
privacy-preserving third-party cookie functionality. Partitioned cookies
+
must always be Secure.
+
+
@see <https://developer.chrome.com/docs/privacy-sandbox/chips/> CHIPS - Cookies Having Independent Partitioned State *)
val host_only : t -> bool
(** Check if cookie has the host-only flag set.
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 6}:
+
- If the Set-Cookie header included a Domain attribute, host-only-flag is
+
false and the cookie matches the domain and all subdomains.
+
- If no Domain attribute was present, host-only-flag is true and the cookie
only matches the exact request host.
Example:
- Cookie set on "example.com" with Domain=example.com: host_only=false,
matches example.com and sub.example.com
- Cookie set on "example.com" without Domain attribute: host_only=true,
+
matches only example.com, not sub.example.com
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
val expires : t -> Expiration.t option
(** Get the expiration attribute if set.
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1} RFC 6265 Section 5.2.1}:
+
- [None]: No expiration specified (session cookie)
+
- [Some `Session]: Session cookie (expires when user agent session ends)
- [Some (`DateTime t)]: Expires at specific time [t]
Both [max_age] and [expires] can be present simultaneously. This library
+
stores both independently.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1> RFC 6265 Section 5.2.1 - The Expires Attribute *)
val max_age : t -> Ptime.Span.t option
(** Get the max-age attribute if set.
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2} RFC 6265 Section 5.2.2},
+
Max-Age specifies the cookie lifetime in seconds. Both [max_age] and
+
[expires] can be present simultaneously. When both are present in a
+
Set-Cookie header, browsers prioritize [max_age] per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} Section 5.3 Step 3}.
+
+
This library stores both independently and serializes both when present.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> RFC 6265 Section 5.2.2 - The Max-Age Attribute *)
val same_site : t -> SameSite.t option
+
(** Get the same-site policy of a cookie.
+
+
@see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *)
val creation_time : t -> Ptime.t
+
(** Get the creation time of a cookie.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
this is set when the cookie is first received. *)
val last_access : t -> Ptime.t
+
(** Get the last access time of a cookie.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
this is updated each time the cookie is retrieved for a request. *)
val make :
domain:string ->
···
t
(** Create a new cookie with the given attributes.
+
@param domain The cookie domain (will be normalized)
+
@param path The cookie path
+
@param name The cookie name
+
@param value The cookie value
+
@param secure If true, cookie only sent over HTTPS (default: false)
+
@param http_only If true, cookie not accessible to scripts (default: false)
+
@param expires Expiration time
+
@param max_age Lifetime in seconds
+
@param same_site Cross-site request policy
+
@param partitioned CHIPS partitioned storage (default: false)
+
@param host_only If true, exact domain match only (default: false). Per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
this should be true when no Domain attribute was present in the
+
Set-Cookie header.
+
@param creation_time When the cookie was created
+
@param last_access Last time the cookie was accessed
Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
+
combinations will result in validation errors.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
(** {1 Cookie Creation and Parsing} *)
···
now:(unit -> Ptime.t) -> domain:string -> path:string -> string -> t option
(** Parse Set-Cookie response header value into a cookie.
+
Parses a Set-Cookie header following
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2}:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
- Supports all standard attributes: [expires], [max-age], [domain], [path],
[secure], [httponly], [samesite], [partitioned]
···
- The [now] parameter is used for calculating expiry times from [max-age]
attributes and setting creation/access times
+
Cookie validation rules (from RFC 6265bis and CHIPS):
- [SameSite=None] requires the [Secure] flag to be set
- [Partitioned] requires the [Secure] flag to be set
Example:
+
{[of_set_cookie_header ~now:(fun () -> Ptime_clock.now ())
+
~domain:"example.com" ~path:"/" "session=abc123; Secure; HttpOnly"]}
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
val of_cookie_header :
now:(unit -> Ptime.t) ->
···
(t, string) result list
(** Parse Cookie request header containing semicolon-separated name=value pairs.
+
Parses a Cookie header following
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}.
+
Cookie headers contain only name=value pairs without attributes:
+
["name1=value1; name2=value2; name3=value3"]
Creates cookies with:
- Provided [domain] and [path] from request context
- All security flags set to [false] (defaults)
- All optional attributes set to [None]
+
- [host_only = true] (since we cannot determine from the header alone
+
whether cookies originally had a Domain attribute)
- [creation_time] and [last_access] set to current time from [now]
Returns a list of parse results, one per cookie. Parse errors for individual
+
cookies are returned as [Error msg] without failing the entire parse.
Example:
+
{[of_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com"
+
~path:"/" "session=abc; theme=dark"]}
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
val make_cookie_header : t list -> string
+
(** Create Cookie header value from cookies.
Formats a list of cookies into a Cookie header value suitable for HTTP
+
requests per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}.
- Format: [name1=value1; name2=value2; name3=value3]
- Only includes cookie names and values, not attributes
- Cookies should already be filtered for the target domain/path
Example: [make_cookie_header cookies] might return
+
["session=abc123; theme=dark"]
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
val make_set_cookie_header : t -> string
(** Create Set-Cookie header value from a cookie.
+
Formats a cookie into a Set-Cookie header value suitable for HTTP responses
+
per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 Section 4.1}.
Includes all cookie attributes: Max-Age, Expires, Domain, Path, Secure,
+
HttpOnly, Partitioned, and SameSite.
+
+
Note: The Expires attribute is currently formatted using RFC 3339 format,
+
which differs from the RFC-recommended rfc1123-date format specified in
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} Section 4.1.1}.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - The Set-Cookie Header *)
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
+
(** Pretty print a cookie. *)
+100 -12
lib/jar/cookeio_jar.ml
···
(** {1 Cookie Matching Helpers} *)
let cookie_identity_matches c1 c2 =
Cookeio.name c1 = Cookeio.name c2
&& Cookeio.domain c1 = Cookeio.domain c2
&& Cookeio.path c1 = Cookeio.path c2
let normalize_domain domain =
-
(* Strip leading dot per RFC 6265 *)
match String.starts_with ~prefix:"." domain with
| true when String.length domain > 1 ->
String.sub domain 1 (String.length domain - 1)
| _ -> domain
let domain_matches ~host_only cookie_domain request_domain =
-
(* RFC 6265 Section 5.4: Domain matching for Cookie header.
-
Cookie domains are stored without leading dots per RFC 6265. *)
-
request_domain = cookie_domain
-
|| (not host_only
-
&& String.ends_with ~suffix:("." ^ cookie_domain) request_domain)
let path_matches cookie_path request_path =
-
(* RFC 6265 Section 5.1.4: A request-path path-matches a cookie-path if:
-
1. The cookie-path and the request-path are identical, OR
-
2. The cookie-path is a prefix of request-path AND cookie-path ends with "/", OR
-
3. The cookie-path is a prefix of request-path AND the first char of
-
request-path not in cookie-path is "/" *)
if cookie_path = request_path then true
else if String.starts_with ~prefix:cookie_path request_path then
let cookie_len = String.length cookie_path in
···
|| (String.length request_path > cookie_len && request_path.[cookie_len] = '/')
else false
-
(** {1 HTTP Date Parsing} *)
let is_expired cookie clock =
match Cookeio.expires cookie with
| None -> false (* No expiration *)
···
Log.debug (fun m -> m "Returning %d delta cookies" (List.length result));
result
let make_removal_cookie cookie ~clock =
let now =
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
···
Eio.Mutex.unlock jar.mutex
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
=
Log.debug (fun m ->
···
(** {1 Cookie Matching Helpers} *)
+
(** Two cookies are considered identical if they have the same name, domain,
+
and path. This is used when replacing or removing cookies.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
let cookie_identity_matches c1 c2 =
Cookeio.name c1 = Cookeio.name c2
&& Cookeio.domain c1 = Cookeio.domain c2
&& Cookeio.path c1 = Cookeio.path c2
+
(** Normalize a domain by stripping the leading dot.
+
+
Per RFC 6265, the Domain attribute value is canonicalized by removing any
+
leading dot before storage.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> RFC 6265 Section 5.2.3 - The Domain Attribute *)
let normalize_domain domain =
match String.starts_with ~prefix:"." domain with
| true when String.length domain > 1 ->
String.sub domain 1 (String.length domain - 1)
| _ -> domain
+
(** Check if a string is an IP address (IPv4 or IPv6).
+
+
Per RFC 6265 Section 5.1.3, domain matching should only apply to hostnames,
+
not IP addresses. IP addresses require exact match only.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> RFC 6265 Section 5.1.3 - Domain Matching *)
+
let is_ip_address domain =
+
match Ipaddr.of_string domain with
+
| Ok _ -> true
+
| Error _ -> false
+
+
(** Check if a cookie domain matches a request domain.
+
+
Per RFC 6265 Section 5.1.3, a string domain-matches a given domain string if:
+
- The domain string and the string are identical, OR
+
- All of the following are true:
+
- The domain string is a suffix of the string
+
- The last character of the string not in the domain string is "."
+
- The string is a host name (i.e., not an IP address)
+
+
Additionally, per Section 5.3 Step 6, if the cookie has the host-only-flag
+
set, only exact matches are allowed.
+
+
@param host_only If true, only exact domain match is allowed
+
@param cookie_domain The domain stored in the cookie (without leading dot)
+
@param request_domain The domain from the HTTP request
+
@return true if the cookie should be sent for this domain
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> RFC 6265 Section 5.1.3 - Domain Matching
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (host-only-flag) *)
let domain_matches ~host_only cookie_domain request_domain =
+
if is_ip_address request_domain then
+
(* IP addresses: exact match only per Section 5.1.3 *)
+
request_domain = cookie_domain
+
else
+
(* Hostnames: exact match or subdomain match (if not host_only) *)
+
request_domain = cookie_domain
+
|| (not host_only
+
&& String.ends_with ~suffix:("." ^ cookie_domain) request_domain)
+
+
(** Check if a cookie path matches a request path.
+
Per RFC 6265 Section 5.1.4, a request-path path-matches a given cookie-path if:
+
- The cookie-path and the request-path are identical, OR
+
- The cookie-path is a prefix of the request-path, AND either:
+
- The last character of the cookie-path is "/", OR
+
- The first character of the request-path that is not included in the
+
cookie-path is "/"
+
+
@param cookie_path The path stored in the cookie
+
@param request_path The path from the HTTP request
+
@return true if the cookie should be sent for this path
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4> RFC 6265 Section 5.1.4 - Paths and Path-Match *)
let path_matches cookie_path request_path =
if cookie_path = request_path then true
else if String.starts_with ~prefix:cookie_path request_path then
let cookie_len = String.length cookie_path in
···
|| (String.length request_path > cookie_len && request_path.[cookie_len] = '/')
else false
+
(** {1 Cookie Expiration} *)
+
+
(** Check if a cookie has expired based on its expiry-time.
+
+
Per RFC 6265 Section 5.3, a cookie is expired if the current date and time
+
is past the expiry-time. Session cookies (with no Expires or Max-Age) never
+
expire via this check - they expire when the "session" ends.
+
+
@param cookie The cookie to check
+
@param clock The Eio clock for current time
+
@return true if the cookie has expired
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
let is_expired cookie clock =
match Cookeio.expires cookie with
| None -> false (* No expiration *)
···
Log.debug (fun m -> m "Returning %d delta cookies" (List.length result));
result
+
(** Create a removal cookie for deleting a cookie from the client.
+
+
Per RFC 6265 Section 5.3, to remove a cookie, the server sends a Set-Cookie
+
header with an expiry date in the past. We also set Max-Age=0 and an empty
+
value for maximum compatibility.
+
+
@param cookie The cookie to create a removal for
+
@param clock The Eio clock for timestamps
+
@return A new cookie configured to cause deletion
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
let make_removal_cookie cookie ~clock =
let now =
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
···
Eio.Mutex.unlock jar.mutex
+
(** Retrieve cookies that should be sent for a given request.
+
+
Per RFC 6265 Section 5.4, the user agent should include a Cookie header
+
containing cookies that match the request-uri's domain, path, and security
+
context. This function also updates the last-access-time for matched cookies.
+
+
@param jar The cookie jar to search
+
@param clock The Eio clock for timestamp updates
+
@param domain The request domain (hostname or IP address)
+
@param path The request path
+
@param is_secure Whether the request is over a secure channel (HTTPS)
+
@return List of cookies that should be included in the Cookie header
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
=
Log.debug (fun m ->
+56 -24
lib/jar/cookeio_jar.mli
···
(** Cookie jar for storing and managing HTTP cookies.
This module provides a complete cookie jar implementation following
-
established web standards while integrating Eio for efficient asynchronous
-
operations.
A cookie jar maintains a collection of cookies with automatic cleanup of
expired entries. It implements the standard browser behavior for cookie
storage, including:
- Automatic removal of expired cookies
-
- Domain and path-based cookie retrieval
- Delta tracking for Set-Cookie headers
-
- Mozilla format persistence for cross-tool compatibility *)
type t
(** Cookie jar for storing and managing cookies.
A cookie jar maintains a collection of cookies with automatic cleanup of
expired entries and enforcement of storage limits. It implements the
-
standard browser behavior for cookie storage, including:
-
- Automatic removal of expired cookies
-
- LRU eviction when storage limits are exceeded
-
- Domain and path-based cookie retrieval
-
- Mozilla format persistence for cross-tool compatibility *)
(** {1 Cookie Jar Creation and Loading} *)
val create : unit -> t
-
(** Create an empty cookie jar *)
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t
(** Load cookies from Mozilla format file.
···
exist or cannot be loaded. *)
val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit
-
(** Save cookies to Mozilla format file *)
(** {1 Cookie Jar Management} *)
···
The cookie is added to the delta, meaning it will appear in Set-Cookie
headers when calling {!delta}. If a cookie with the same name/domain/path
-
exists in the delta, it will be replaced. *)
val add_original : t -> Cookeio.t -> unit
(** Add an original cookie to the jar.
···
Returns cookies that have been added via {!add_cookie} and removal cookies
for original cookies that have been removed. Does not include original
-
cookies that were added via {!add_original}. *)
val remove : t -> clock:_ Eio.Time.clock -> Cookeio.t -> unit
(** Remove a cookie from the jar.
If an original cookie with the same name/domain/path exists, creates a
removal cookie (empty value, Max-Age=0, past expiration) that appears in the
-
delta. If only a delta cookie exists, simply removes it from the delta. *)
val get_cookies :
t ->
···
Returns all cookies that match the given domain and path, and satisfy the
secure flag requirement. Combines original and delta cookies, with delta
taking precedence. Excludes removal cookies (empty value). Also updates the
-
last access time of matching cookies using the provided clock. *)
val clear : t -> unit
-
(** Clear all cookies *)
val clear_expired : t -> clock:_ Eio.Time.clock -> unit
-
(** Clear expired cookies *)
val clear_session_cookies : t -> unit
-
(** Clear session cookies (those without expiry) *)
val count : t -> int
-
(** Get the number of cookies in the jar *)
val get_all_cookies : t -> Cookeio.t list
-
(** Get all cookies in the jar *)
val is_empty : t -> bool
-
(** Check if the jar is empty *)
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
-
(** Pretty print a cookie jar *)
(** {1 Mozilla Format} *)
val to_mozilla_format : t -> string
-
(** Write cookies in Mozilla format *)
val from_mozilla_format : clock:_ Eio.Time.clock -> string -> t
(** Parse Mozilla format cookies.
Creates a cookie jar from a string in Mozilla cookie format, using the
-
provided clock to set creation and last access times. *)
···
(** Cookie jar for storing and managing HTTP cookies.
This module provides a complete cookie jar implementation following
+
{{:https://datatracker.ietf.org/doc/html/rfc6265} RFC 6265} while
+
integrating Eio for efficient asynchronous operations.
A cookie jar maintains a collection of cookies with automatic cleanup of
expired entries. It implements the standard browser behavior for cookie
storage, including:
- Automatic removal of expired cookies
+
- Domain and path-based cookie retrieval per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4} Section 5.4}
- Delta tracking for Set-Cookie headers
+
- Mozilla format persistence for cross-tool compatibility
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265> RFC 6265 - HTTP State Management Mechanism *)
type t
(** Cookie jar for storing and managing cookies.
A cookie jar maintains a collection of cookies with automatic cleanup of
expired entries and enforcement of storage limits. It implements the
+
standard browser behavior for cookie storage per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
(** {1 Cookie Jar Creation and Loading} *)
val create : unit -> t
+
(** Create an empty cookie jar. *)
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t
(** Load cookies from Mozilla format file.
···
exist or cannot be loaded. *)
val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit
+
(** Save cookies to Mozilla format file. *)
(** {1 Cookie Jar Management} *)
···
The cookie is added to the delta, meaning it will appear in Set-Cookie
headers when calling {!delta}. If a cookie with the same name/domain/path
+
exists in the delta, it will be replaced per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
val add_original : t -> Cookeio.t -> unit
(** Add an original cookie to the jar.
···
Returns cookies that have been added via {!add_cookie} and removal cookies
for original cookies that have been removed. Does not include original
+
cookies that were added via {!add_original}.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - Set-Cookie *)
val remove : t -> clock:_ Eio.Time.clock -> Cookeio.t -> unit
(** Remove a cookie from the jar.
If an original cookie with the same name/domain/path exists, creates a
removal cookie (empty value, Max-Age=0, past expiration) that appears in the
+
delta. If only a delta cookie exists, simply removes it from the delta.
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
cookies are removed by sending a Set-Cookie with an expiry date in the past.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
val get_cookies :
t ->
···
Returns all cookies that match the given domain and path, and satisfy the
secure flag requirement. Combines original and delta cookies, with delta
taking precedence. Excludes removal cookies (empty value). Also updates the
+
last access time of matching cookies using the provided clock.
+
+
Domain matching follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 5.1.3}:
+
- IP addresses require exact match only
+
- Hostnames support subdomain matching unless host-only flag is set
+
+
Path matching follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 5.1.4}.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)
val clear : t -> unit
+
(** Clear all cookies. *)
val clear_expired : t -> clock:_ Eio.Time.clock -> unit
+
(** Clear expired cookies.
+
+
Removes cookies whose expiry-time is in the past per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
val clear_session_cookies : t -> unit
+
(** Clear session cookies.
+
+
Removes cookies that have no Expires or Max-Age attribute (session cookies).
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
+
these cookies are normally removed when the user agent "session" ends. *)
val count : t -> int
+
(** Get the number of unique cookies in the jar. *)
val get_all_cookies : t -> Cookeio.t list
+
(** Get all cookies in the jar. *)
val is_empty : t -> bool
+
(** Check if the jar is empty. *)
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
+
(** Pretty print a cookie jar. *)
(** {1 Mozilla Format} *)
val to_mozilla_format : t -> string
+
(** Serialize cookies in Mozilla/Netscape cookie format.
+
+
The Mozilla format uses tab-separated fields:
+
{[domain \t include_subdomains \t path \t secure \t expires \t name \t value]}
+
+
The [include_subdomains] field corresponds to the inverse of the
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} host-only-flag}
+
in RFC 6265. *)
val from_mozilla_format : clock:_ Eio.Time.clock -> string -> t
(** Parse Mozilla format cookies.
Creates a cookie jar from a string in Mozilla cookie format, using the
+
provided clock to set creation and last access times. The [include_subdomains]
+
field is mapped to the host-only-flag per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
+1 -1
lib/jar/dune
···
(library
(name cookeio_jar)
(public_name cookeio.jar)
-
(libraries cookeio eio logs ptime unix))
···
(library
(name cookeio_jar)
(public_name cookeio.jar)
+
(libraries cookeio eio logs ptime unix ipaddr))
+164
test/test_cookeio.ml
···
in
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
let () =
Eio_main.run @@ fun env ->
let open Alcotest in
···
test_path_matching_no_false_prefix;
test_case "root path matches all" `Quick test_path_matching_root;
test_case "path no match" `Quick test_path_matching_no_match;
] );
]
···
in
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
+
(* ============================================================================ *)
+
(* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *)
+
(* ============================================================================ *)
+
+
let test_ipv4_exact_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* IPv4 cookie should match exact IP *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "IPv4 exact match" 1 (List.length cookies)
+
+
let test_ipv4_no_suffix_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
(* Cookie for 168.1.1 - this should NOT match requests to 192.168.1.1
+
even though "192.168.1.1" ends with ".168.1.1" *)
+
let cookie =
+
Cookeio.make ~domain:"168.1.1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Should NOT match - IP addresses don't do suffix matching *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "IPv4 no suffix match" 0 (List.length cookies)
+
+
let test_ipv4_different_ip () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Different IP should not match *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"192.168.1.2" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "different IPv4 no match" 0 (List.length cookies)
+
+
let test_ipv6_exact_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"::1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* IPv6 loopback should match exactly *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"::1" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "IPv6 exact match" 1 (List.length cookies)
+
+
let test_ipv6_full_format () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"2001:db8::1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* IPv6 should match exactly *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"2001:db8::1" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "IPv6 full format match" 1 (List.length cookies);
+
+
(* Different IPv6 should not match *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"2001:db8::2" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "different IPv6 no match" 0 (List.length cookies2)
+
+
let test_ip_vs_hostname () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add a hostname cookie with host_only=false (domain cookie) *)
+
let hostname_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostname" ~value:"h1"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar hostname_cookie;
+
+
(* Add an IP cookie with host_only=false *)
+
let ip_cookie =
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"ip" ~value:"i1"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar ip_cookie;
+
+
(* Hostname request should match hostname cookie and subdomains *)
+
let cookies1 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "hostname matches hostname cookie" 1 (List.length cookies1);
+
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "subdomain matches hostname cookie" 1 (List.length cookies2);
+
+
(* IP request should only match IP cookie exactly *)
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3);
+
Alcotest.(check string) "IP cookie is returned" "ip" (Cookeio.name (List.hd cookies3))
+
let () =
Eio_main.run @@ fun env ->
let open Alcotest in
···
test_path_matching_no_false_prefix;
test_case "root path matches all" `Quick test_path_matching_root;
test_case "path no match" `Quick test_path_matching_no_match;
+
] );
+
( "ip_address_matching",
+
[
+
test_case "IPv4 exact match" `Quick test_ipv4_exact_match;
+
test_case "IPv4 no suffix match" `Quick test_ipv4_no_suffix_match;
+
test_case "IPv4 different IP no match" `Quick test_ipv4_different_ip;
+
test_case "IPv6 exact match" `Quick test_ipv6_exact_match;
+
test_case "IPv6 full format" `Quick test_ipv6_full_format;
+
test_case "IP vs hostname behavior" `Quick test_ip_vs_hostname;
] );
]