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

Compare changes

Choose any two refs to compare.

Changed files
+194 -120
lib
+40 -54
lib/core/cookeio.ml
···
(* IP addresses bypass PSL check per RFC 6265 Section 5.1.3 *)
match Ipaddr.of_string cookie_domain with
| Ok _ -> Ok () (* IP addresses are not subject to PSL rules *)
-
| Error _ ->
+
| Error _ -> (
let psl = Lazy.force psl in
-
(match Publicsuffix.is_public_suffix psl cookie_domain with
-
| Error _ ->
-
(* If PSL lookup fails (e.g., invalid domain), allow the cookie.
-
Domain name validation is handled separately. *)
-
Ok ()
-
| Ok false ->
-
(* Not a public suffix, allow the cookie *)
+
match Publicsuffix.is_public_suffix psl cookie_domain with
+
| Error _ | Ok false ->
+
(* If PSL lookup fails (e.g., invalid domain) or not a public suffix,
+
allow the cookie. Domain name validation is handled separately. *)
Ok ()
| Ok true ->
(* It's a public suffix - only allow if request host matches exactly.
···
(** Parse HTTP date by trying all supported formats in sequence *)
let parse_http_date s =
-
match parse_fmt1 s with
-
| Some t -> Some t
-
| None -> (
-
match parse_fmt2 s with
-
| Some t -> Some t
-
| None -> (
-
match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
+
let ( <|> ) a b = match a with Some _ -> a | None -> b () in
+
parse_fmt1 s <|> fun () ->
+
parse_fmt2 s <|> fun () ->
+
parse_fmt3 s <|> fun () ->
+
parse_fmt4 s
end
(** {1 Cookie Parsing} *)
···
@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 =
-
match attrs.same_site with
-
| Some `None when not attrs.secure ->
-
Log.warn (fun m ->
-
m
-
"Cookie has SameSite=None but Secure flag is not set; this \
-
violates RFC requirements");
-
false
-
| _ -> true
-
in
-
(* Partitioned requires Secure flag *)
-
let partitioned_valid =
-
if attrs.partitioned && not attrs.secure then (
+
match (attrs.same_site, attrs.secure, attrs.partitioned) with
+
| Some `None, false, _ ->
+
Log.warn (fun m ->
+
m
+
"Cookie has SameSite=None but Secure flag is not set; this \
+
violates RFC requirements");
+
false
+
| _, false, true ->
Log.warn (fun m ->
m
"Cookie has Partitioned attribute but Secure flag is not set; this \
violates CHIPS requirements");
-
false)
-
else true
-
in
-
samesite_valid && partitioned_valid
+
false
+
| _ -> true
(** Build final cookie from name/value and accumulated attributes.
···
Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
(* Add Max-Age if present *)
-
(match max_age cookie with
-
| Some span -> (
-
match Ptime.Span.to_int_s span with
-
| Some seconds ->
-
Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
-
| None -> ())
-
| None -> ());
+
Option.iter
+
(fun span ->
+
Option.iter
+
(fun seconds ->
+
Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds))
+
(Ptime.Span.to_int_s span))
+
(max_age cookie);
(* Add Expires if present *)
-
(match expires cookie with
-
| Some `Session ->
-
(* Session cookies can be indicated with Expires=0 or a past date *)
-
Buffer.add_string buffer "; Expires=0"
-
| Some (`DateTime exp_time) ->
-
(* Format as HTTP date *)
-
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
-
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
-
| None -> ());
+
Option.iter
+
(function
+
| `Session -> Buffer.add_string buffer "; Expires=0"
+
| `DateTime exp_time ->
+
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
+
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str))
+
(expires cookie);
(* Add Domain *)
Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
···
if partitioned cookie then Buffer.add_string buffer "; Partitioned";
(* Add SameSite *)
-
(match same_site cookie with
-
| Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
-
| Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
-
| Some `None -> Buffer.add_string buffer "; SameSite=None"
-
| None -> ());
+
Option.iter
+
(function
+
| `Strict -> Buffer.add_string buffer "; SameSite=Strict"
+
| `Lax -> Buffer.add_string buffer "; SameSite=Lax"
+
| `None -> Buffer.add_string buffer "; SameSite=None")
+
(same_site cookie);
Buffer.contents buffer
+79 -2
lib/core/cookeio.mli
···
- 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 *)
+
@see <https://datatracker.ietf.org/doc/html/rfc6265> RFC 6265 - HTTP State Management Mechanism
+
+
{2 Standards and References}
+
+
This library implements and references the following IETF specifications:
+
+
{ul
+
{- {{:https://datatracker.ietf.org/doc/html/rfc6265}RFC 6265} -
+
HTTP State Management Mechanism (April 2011) - Primary specification}
+
{- {{:https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis}RFC 6265bis} -
+
Cookies: HTTP State Management Mechanism (Draft) - SameSite attribute and modern updates}
+
{- {{:https://datatracker.ietf.org/doc/html/rfc1034#section-3.5}RFC 1034 Section 3.5} -
+
Domain Names - Preferred Name Syntax for domain validation}
+
{- {{:https://datatracker.ietf.org/doc/html/rfc2616#section-2.2}RFC 2616 Section 2.2} -
+
HTTP/1.1 - Token syntax definition}
+
{- {{:https://datatracker.ietf.org/doc/html/rfc1123#section-5.2.14}RFC 1123 Section 5.2.14} -
+
Internet Host Requirements - Date format (rfc1123-date)}}
+
+
Additional standards:
+
{ul
+
{- {{:https://publicsuffix.org/}Mozilla Public Suffix List} - Registry
+
of public suffixes for cookie domain validation per RFC 6265 Section 5.3 Step 5}} *)
(** {1 Types} *)
···
Validation functions for cookie names, values, and attributes per
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 Section 4.1.1}.
+
+
These functions implement the syntactic requirements from RFC 6265 to ensure
+
cookies conform to the specification before being sent in HTTP headers.
+
All validation failures return detailed error messages citing the specific
+
RFC requirement that was violated.
+
+
{2 Validation Philosophy}
+
+
Per RFC 6265 Section 4, there is an important distinction between:
+
- {b Server requirements} (Section 4.1): Strict syntax for generating Set-Cookie headers
+
- {b User agent requirements} (Section 5): Lenient parsing for receiving Set-Cookie headers
+
+
These validation functions enforce the {b server requirements}, ensuring that
+
cookies generated by this library conform to RFC 6265 syntax. When parsing
+
cookies from HTTP headers, the library may be more lenient to maximize
+
interoperability with non-compliant servers.
+
+
{2 Character Set Requirements}
+
+
RFC 6265 restricts cookies to US-ASCII characters with specific exclusions:
+
- Cookie names: RFC 2616 tokens (no CTLs, no separators)
+
- Cookie values: cookie-octet characters (0x21, 0x23-0x2B, 0x2D-0x3A, 0x3C-0x5B, 0x5D-0x7E)
+
- Domain values: RFC 1034 domain name syntax or IP addresses
+
- Path values: Any character except CTLs and semicolon
+
These functions return [Ok value] on success or [Error msg] with a detailed
explanation of why validation failed.
···
Cookie names must be valid RFC 2616 tokens: one or more characters
excluding control characters and separators.
+
Per {{:https://datatracker.ietf.org/doc/html/rfc2616#section-2.2}RFC 2616 Section 2.2},
+
a token is defined as: one or more characters excluding control characters
+
and the following 19 separator characters: parentheses, angle brackets, at-sign,
+
comma, semicolon, colon, backslash, double-quote, forward slash, square brackets,
+
question mark, equals, curly braces, space, and horizontal tab.
+
+
This means tokens consist of visible ASCII characters (33-126) excluding
+
control characters (0-31, 127) and the separator characters listed above.
+
@param name The cookie name to validate
@return [Ok name] if valid, [Error message] with explanation if invalid
-
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1
+
@see <https://datatracker.ietf.org/doc/html/rfc2616#section-2.2> RFC 2616 Section 2.2 - Basic Rules *)
val cookie_value : string -> (string, string) result
(** Validate a cookie value per RFC 6265.
···
double quotes. Invalid characters include: control characters, space,
double quote (except as wrapper), comma, semicolon, and backslash.
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1}RFC 6265 Section 4.1.1},
+
cookie-value may be:
+
- Zero or more cookie-octet characters, or
+
- Double-quoted string containing cookie-octet characters
+
+
Where cookie-octet excludes: CTLs (0x00-0x1F, 0x7F), space (0x20),
+
double-quote (0x22), comma (0x2C), semicolon (0x3B), and backslash (0x5C).
+
+
Valid cookie-octet characters: 0x21, 0x23-0x2B, 0x2D-0x3A, 0x3C-0x5B, 0x5D-0x7E
+
@param value The cookie value to validate
@return [Ok value] if valid, [Error message] with explanation if invalid
···
- A valid domain name per RFC 1034 Section 3.5
- A valid IPv4 address
- A valid IPv6 address
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc1034#section-3.5}RFC 1034 Section 3.5},
+
preferred domain name syntax requires:
+
- Labels separated by dots
+
- Labels must start with a letter
+
- Labels must end with a letter or digit
+
- Labels may contain letters, digits, and hyphens
+
- Labels are case-insensitive
+
- Total length limited to 255 octets
+
+
Leading dots are stripped per RFC 6265 Section 5.2.3 before validation.
@param domain The domain value to validate (leading dot is stripped first)
@return [Ok domain] if valid, [Error message] with explanation if invalid
+28 -59
lib/jar/cookeio_jar.ml
···
String.sub domain 1 (String.length domain - 1)
| _ -> domain
+
(** Remove duplicate cookies, keeping the last occurrence.
+
+
Used to deduplicate combined cookie lists where delta cookies should
+
take precedence over original cookies. *)
+
let dedup_by_identity cookies =
+
let rec aux acc = function
+
| [] -> List.rev acc
+
| c :: rest ->
+
let has_duplicate =
+
List.exists (fun c2 -> cookie_identity_matches c c2) rest
+
in
+
if has_duplicate then aux acc rest else aux (c :: acc) rest
+
in
+
aux [] cookies
+
(** 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
+
let is_ip_address domain = Result.is_ok (Ipaddr.of_string domain)
(** Check if a cookie domain matches a request 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)
+
request_domain = cookie_domain
+
|| (not (is_ip_address request_domain || host_only)
+
&& String.ends_with ~suffix:("." ^ cookie_domain) request_domain)
(** Check if a cookie path matches a request path.
···
(* Combine original and delta cookies, with delta taking precedence *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
-
(* Filter out duplicates, keeping the last occurrence (from delta) *)
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
(* Keep this cookie only if no later cookie has the same identity *)
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique_cookies = dedup [] all_cookies in
+
let unique_cookies = dedup_by_identity all_cookies in
(* Filter for applicable cookies, excluding removal cookies and expired cookies *)
let applicable =
···
let count jar =
Eio.Mutex.lock jar.mutex;
-
(* Combine and deduplicate cookies for count *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
+
let unique = dedup_by_identity all_cookies in
let n = List.length unique in
Eio.Mutex.unlock jar.mutex;
n
let get_all_cookies jar =
Eio.Mutex.lock jar.mutex;
-
(* Combine and deduplicate, with delta taking precedence *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
+
let unique = dedup_by_identity all_cookies in
Eio.Mutex.unlock jar.mutex;
unique
···
(* Combine and deduplicate cookies *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
+
let unique = dedup_by_identity all_cookies in
List.iter
(fun cookie ->
···
|> Option.value ~default:Ptime.epoch
in
let expires =
-
let exp_int = try int_of_string expires with _ -> 0 in
-
if exp_int = 0 then None
-
else
-
match Ptime.of_float_s (float_of_int exp_int) with
-
| Some t -> Some (`DateTime t)
-
| None -> None
+
match int_of_string_opt expires with
+
| Some exp_int when exp_int <> 0 ->
+
Option.map (fun t -> `DateTime t)
+
(Ptime.of_float_s (float_of_int exp_int))
+
| _ -> None
in
(* Mozilla format: include_subdomains=TRUE means host_only=false *)
let host_only = include_subdomains <> "TRUE" in
+47 -5
lib/jar/cookeio_jar.mli
···
- 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 *)
+
@see <https://datatracker.ietf.org/doc/html/rfc6265> RFC 6265 - HTTP State Management Mechanism
+
+
{2 Standards and References}
+
+
This cookie jar implements the storage model from:
+
+
{ul
+
{- {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3}RFC 6265 Section 5.3} -
+
Storage Model - Cookie insertion, replacement, and expiration}
+
{- {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4}RFC 6265 Section 5.4} -
+
The Cookie Header - Cookie retrieval and ordering}}
+
+
Key RFC 6265 requirements implemented:
+
{ul
+
{- Domain matching per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3}Section 5.1.3}}
+
{- Path matching per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4}Section 5.1.4}}
+
{- Cookie ordering per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4}Section 5.4 Step 2}}
+
{- Creation time preservation per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3}Section 5.3 Step 11.3}}} *)
type t
(** Cookie jar for storing and managing cookies.
···
Cookeio.t list
(** Get cookies applicable for a URL.
-
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:
+
Implements the cookie retrieval algorithm from
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.4}RFC 6265 Section 5.4}
+
for generating the Cookie header.
+
+
{3 Algorithm}
+
+
Per RFC 6265 Section 5.4, the user agent should:
+
1. Filter cookies by domain matching (Section 5.1.3)
+
2. Filter cookies by path matching (Section 5.1.4)
+
3. Filter out cookies with Secure attribute when request is non-secure
+
4. Filter out expired cookies
+
5. Sort remaining cookies (longer paths first, then by creation time)
+
6. Update last-access-time for retrieved cookies
+
+
This function implements all these steps, combining original and delta cookies
+
with delta taking precedence. Excludes:
- Removal cookies (empty value)
- Expired cookies (expiry-time in the past per Section 5.3)
+
- Secure cookies when [is_secure = false]
+
+
{3 Cookie Ordering}
Cookies are sorted per Section 5.4, Step 2:
- Cookies with longer paths are listed before cookies with shorter paths
- Among cookies with equal-length paths, cookies with earlier creation-times
are listed first
-
Also updates the last access time of matching cookies using the provided clock.
+
This ordering ensures more specific cookies take precedence.
+
+
{3 Matching Rules}
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}.
+
+
@param t Cookie jar
+
@param clock Clock for updating last-access-time
+
@param domain Request domain
+
@param path Request path
+
@param is_secure Whether the request is over a secure channel (HTTPS)
+
@return List of matching cookies, sorted per RFC 6265
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (expiry)
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)