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

Compare changes

Choose any two refs to compare.

+17 -2
.gitignore
···
-
_build
-
.*.swp
+
# OCaml build artifacts
+
_build/
+
*.install
+
*.merlin
+
+
# Third-party sources (fetch locally with opam source)
+
third_party/
+
+
# Editor and OS files
+
.DS_Store
+
*.swp
+
*~
+
.vscode/
+
.idea/
+
+
# Opam local switch
+
_opam/
+1 -1
.ocamlformat
···
-
version=0.27.0
+
version=0.28.1
+53
.tangled/workflows/build.yml
···
+
when:
+
- event: ["push", "pull_request"]
+
branch: ["main"]
+
+
engine: nixery
+
+
dependencies:
+
nixpkgs:
+
- shell
+
- stdenv
+
- findutils
+
- binutils
+
- libunwind
+
- ncurses
+
- opam
+
- git
+
- gawk
+
- gnupatch
+
- gnum4
+
- gnumake
+
- gnutar
+
- gnused
+
- gnugrep
+
- diffutils
+
- gzip
+
- bzip2
+
- gcc
+
- ocaml
+
- pkg-config
+
+
steps:
+
- name: opam
+
command: |
+
opam init --disable-sandboxing -a -y
+
- name: repo
+
command: |
+
opam repo add aoah https://tangled.org/anil.recoil.org/aoah-opam-repo.git
+
- name: switch
+
command: |
+
opam install . --confirm-level=unsafe-yes --deps-only
+
- name: build
+
command: |
+
opam exec -- dune build -p cookeio
+
- name: switch-test
+
command: |
+
opam install . --confirm-level=unsafe-yes --deps-only --with-test
+
- name: test
+
command: |
+
opam exec -- dune runtest --verbose
+
- name: doc
+
command: |
+
opam install -y odoc
+
opam exec -- dune build @doc
+16 -18
LICENSE.md
···
-
(*
-
* ISC License
-
*
-
* Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
-
*
-
* Permission to use, copy, modify, and distribute this software for any
-
* purpose with or without fee is hereby granted, provided that the above
-
* copyright notice and this permission notice appear in all copies.
-
*
-
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-
*
-
*)
+
+
ISC License
+
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
+
+
Permission to use, copy, modify, and distribute this software for any
+
purpose with or without fee is hereby granted, provided that the above
+
copyright notice and this permission notice appear in all copies.
+
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+173
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:** โœ… IMPLEMENTED
+
+
The RFC requires rejecting cookies with domains that are "public suffixes" (e.g., `.com`, `.co.uk`) to prevent domain-wide cookie attacks.
+
+
**Implementation:**
+
- Uses the `publicsuffix` library which embeds the Mozilla Public Suffix List at build time
+
- Validates Domain attribute in `of_set_cookie_header` before creating the cookie
+
- Rejects cookies where Domain is a public suffix (e.g., `.com`, `.co.uk`, `.github.io`)
+
- Allows cookies where the request host exactly matches the public suffix domain
+
- IP addresses bypass PSL validation (per RFC 6265 Section 5.1.3)
+
- Cookies without Domain attribute (host-only) are always allowed
+
+
**Security impact:** Prevents attackers from setting domain-wide cookies that would affect all sites under a TLD.
+
+
---
+
+
## 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:** โœ… IMPLEMENTED
+
+
When generating Cookie headers, cookies are sorted:
+
1. Cookies with longer paths listed first
+
2. Among equal-length paths, earlier creation-times listed first
+
+
**Implementation:** `get_cookies` function in `cookeio_jar.ml` uses `compare_cookie_order` to sort cookies before returning them.
+
+
---
+
+
### 5. Creation Time Preservation (Section 5.3, Step 11.3)
+
+
**Status:** โœ… IMPLEMENTED
+
+
When replacing an existing cookie (same name/domain/path), the creation-time of the old cookie is preserved.
+
+
**Implementation:** `add_cookie` and `add_original` functions in `cookeio_jar.ml` use `preserve_creation_time` to retain the original creation time when updating an existing cookie.
+
+
---
+
+
### 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)
+
- [x] Cookie ordering in headers - longer paths first, then by creation time (Section 5.4, Step 2)
+
- [x] Creation time preservation when replacing cookies (Section 5.3, Step 11.3)
+
- [x] Public suffix validation - rejects cookies for TLDs like .com (Section 5.3, Step 5)
+
+
---
+
+
## 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
+18
bin/cookeiocat.ml
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
let () =
+
Eio_main.run @@ fun env ->
+
let args = Sys.argv in
+
if Array.length args < 2 then (
+
Printf.eprintf "Usage: %s <cookies.txt>\n" args.(0);
+
exit 1
+
);
+
let file_path = args.(1) in
+
let fs = Eio.Stdenv.fs env in
+
let clock = Eio.Stdenv.clock env in
+
let path = Eio.Path.(fs / file_path) in
+
let jar = Cookeio_jar.load ~clock path in
+
Format.printf "%a@." Cookeio_jar.pp jar
+4
bin/dune
···
+
(executable
+
(name cookeiocat)
+
(public_name cookeiocat)
+
(libraries cookeio cookeio.jar eio_main ptime))
+11 -8
cookeio.opam
···
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
-
synopsis: "Cookie parsing and management library using Eio"
+
synopsis: "Cookie parsing and management library"
description:
-
"Cookeio provides cookie management functionality for OCaml applications, including parsing Set-Cookie headers, managing cookie jars, and supporting the Mozilla cookies.txt format for persistence."
-
maintainer: ["Anil Madhavapeddy"]
+
"Cookeio provides cookie parsing and serialization for OCaml applications. It handles parsing Set-Cookie and Cookie headers with full support for all cookie attributes."
+
maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
authors: ["Anil Madhavapeddy"]
license: "ISC"
-
homepage: "https://github.com/avsm/cookeio"
-
bug-reports: "https://github.com/avsm/cookeio/issues"
+
homepage: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio"
+
doc: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio"
+
bug-reports: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio/issues"
depends: [
+
"dune" {>= "3.18"}
"ocaml" {>= "5.2.0"}
-
"dune" {>= "3.19"}
-
"eio" {>= "1.0"}
"logs" {>= "0.9.0"}
"ptime" {>= "1.1.0"}
+
"ipaddr" {>= "5.6.0"}
+
"domain-name" {>= "0.4.0"}
+
"publicsuffix"
+
"eio_main"
"alcotest" {with-test}
"odoc" {with-doc}
]
···
"@doc" {with-doc}
]
]
-
dev-repo: "git+https://github.com/avsm/cookeio.git"
x-maintenance-intent: ["(latest)"]
+15 -10
dune-project
···
-
(lang dune 3.19)
+
(lang dune 3.18)
(name cookeio)
(generate_opam_files true)
-
(source (github avsm/cookeio))
-
+
(license ISC)
(authors "Anil Madhavapeddy")
-
(maintainers "Anil Madhavapeddy")
-
(license ISC)
+
(homepage "https://tangled.sh/@anil.recoil.org/ocaml-cookeio")
+
(maintainers "Anil Madhavapeddy <anil@recoil.org>")
+
(bug_reports "https://tangled.sh/@anil.recoil.org/ocaml-cookeio/issues")
+
(documentation "https://tangled.sh/@anil.recoil.org/ocaml-cookeio")
+
(maintenance_intent "(latest)")
(package
(name cookeio)
-
(synopsis "Cookie parsing and management library using Eio")
-
(description "Cookeio provides cookie management functionality for OCaml applications, including parsing Set-Cookie headers, managing cookie jars, and supporting the Mozilla cookies.txt format for persistence.")
+
(synopsis "Cookie parsing and management library")
+
(description "Cookeio provides cookie parsing and serialization for OCaml applications. It handles parsing Set-Cookie and Cookie headers with full support for all cookie attributes.")
(depends
(ocaml (>= 5.2.0))
-
dune
-
(eio (>= 1.0))
(logs (>= 0.9.0))
(ptime (>= 1.1.0))
-
(alcotest :with-test)))
+
(ipaddr (>= 5.6.0))
+
(domain-name (>= 0.4.0))
+
publicsuffix
+
eio_main
+
(alcotest :with-test)
+
(odoc :with-doc)))
-424
lib/cookeio.ml
···
-
let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
-
-
module Log = (val Logs.src_log src : Logs.LOG)
-
-
type same_site = [ `Strict | `Lax | `None ]
-
(** Cookie same-site policy *)
-
-
type t = {
-
domain : string;
-
path : string;
-
name : string;
-
value : string;
-
secure : bool;
-
http_only : bool;
-
expires : Ptime.t option;
-
same_site : same_site option;
-
creation_time : Ptime.t;
-
last_access : Ptime.t;
-
}
-
(** HTTP Cookie *)
-
-
type jar = { mutable cookies : t list; mutex : Eio.Mutex.t }
-
(** Cookie jar for storing and managing cookies *)
-
-
(** {1 Cookie Accessors} *)
-
-
let domain cookie = cookie.domain
-
let path cookie = cookie.path
-
let name cookie = cookie.name
-
let value cookie = cookie.value
-
let secure cookie = cookie.secure
-
let http_only cookie = cookie.http_only
-
let expires cookie = cookie.expires
-
let same_site cookie = cookie.same_site
-
let creation_time cookie = cookie.creation_time
-
let last_access cookie = cookie.last_access
-
-
let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
-
?expires ?same_site ~creation_time ~last_access () =
-
{ domain; path; name; value; secure; http_only; expires; same_site; creation_time; last_access }
-
-
(** {1 Cookie Jar Creation} *)
-
-
let create () =
-
Log.debug (fun m -> m "Creating new empty cookie jar");
-
{ cookies = []; mutex = Eio.Mutex.create () }
-
-
(** {1 Cookie Matching Helpers} *)
-
-
let domain_matches cookie_domain request_domain =
-
(* Cookie domain .example.com matches example.com and sub.example.com *)
-
if String.starts_with ~prefix:"." cookie_domain then
-
let domain_suffix = String.sub cookie_domain 1 (String.length cookie_domain - 1) in
-
request_domain = domain_suffix
-
|| String.ends_with ~suffix:("." ^ domain_suffix) request_domain
-
else cookie_domain = request_domain
-
-
let path_matches cookie_path request_path =
-
(* Cookie path /foo matches /foo, /foo/, /foo/bar *)
-
String.starts_with ~prefix:cookie_path request_path
-
-
let is_expired cookie clock =
-
match cookie.expires with
-
| None -> false (* Session cookie *)
-
| Some exp_time ->
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> Option.value ~default:Ptime.epoch
-
in
-
Ptime.compare now exp_time > 0
-
-
(** {1 Cookie Parsing} *)
-
-
(** Accumulated attributes from parsing Set-Cookie header *)
-
type cookie_attributes = {
-
mutable domain : string option;
-
mutable path : string option;
-
mutable secure : bool;
-
mutable http_only : bool;
-
mutable expires : Ptime.t option;
-
mutable same_site : same_site option;
-
}
-
-
(** Create empty attribute accumulator *)
-
let empty_attributes () =
-
{
-
domain = None;
-
path = None;
-
secure = false;
-
http_only = false;
-
expires = None;
-
same_site = None;
-
}
-
-
(** Parse a single attribute and update the accumulator in-place *)
-
let parse_attribute clock attrs attr_name attr_value =
-
let attr_lower = String.lowercase_ascii attr_name in
-
match attr_lower with
-
| "domain" -> attrs.domain <- Some attr_value
-
| "path" -> attrs.path <- Some attr_value
-
| "expires" -> (
-
match Ptime.of_rfc3339 attr_value with
-
| Ok (time, _, _) -> attrs.expires <- Some time
-
| Error (`RFC3339 (_, err)) ->
-
Log.warn (fun m ->
-
m "Failed to parse expires attribute '%s': %a" attr_value
-
Ptime.pp_rfc3339_error err))
-
| "max-age" -> (
-
match int_of_string_opt attr_value with
-
| Some seconds ->
-
let now = Eio.Time.now clock in
-
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
-
attrs.expires <- expires
-
| None ->
-
Log.warn (fun m -> m "Failed to parse max-age attribute '%s'" attr_value))
-
| "secure" -> attrs.secure <- true
-
| "httponly" -> attrs.http_only <- true
-
| "samesite" -> (
-
match String.lowercase_ascii attr_value with
-
| "strict" -> attrs.same_site <- Some `Strict
-
| "lax" -> attrs.same_site <- Some `Lax
-
| "none" -> attrs.same_site <- Some `None
-
| _ ->
-
Log.warn (fun m -> m "Invalid samesite value '%s', ignoring" attr_value))
-
| _ ->
-
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 *)
-
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
-
-
(** Build final cookie from name/value and accumulated attributes *)
-
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
-
let domain = Option.value attrs.domain ~default:request_domain in
-
let path = Option.value attrs.path ~default:request_path in
-
make ~domain ~path ~name ~value ~secure:attrs.secure ~http_only:attrs.http_only
-
?expires:attrs.expires ?same_site:attrs.same_site ~creation_time:now
-
~last_access:now ()
-
-
let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
-
header_value =
-
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
-
-
(* Split into attributes *)
-
let parts = String.split_on_char ';' header_value |> List.map String.trim in
-
-
match parts with
-
| [] -> None
-
| name_value :: attrs -> (
-
(* Parse name=value *)
-
match String.index_opt name_value '=' with
-
| None -> None
-
| Some eq_pos ->
-
let name = String.sub name_value 0 eq_pos |> String.trim in
-
let cookie_value =
-
String.sub name_value (eq_pos + 1)
-
(String.length name_value - eq_pos - 1)
-
|> String.trim
-
in
-
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> Option.value ~default:Ptime.epoch
-
in
-
-
(* Parse all attributes into mutable accumulator *)
-
let accumulated_attrs = empty_attributes () in
-
List.iter
-
(fun attr ->
-
match String.index_opt attr '=' with
-
| None ->
-
(* Attribute without value (e.g., Secure, HttpOnly) *)
-
parse_attribute clock accumulated_attrs attr ""
-
| Some eq ->
-
let attr_name = String.sub attr 0 eq |> String.trim in
-
let attr_value =
-
String.sub attr (eq + 1) (String.length attr - eq - 1)
-
|> String.trim
-
in
-
parse_attribute clock accumulated_attrs attr_name attr_value)
-
attrs;
-
-
(* Validate attributes *)
-
if not (validate_attributes accumulated_attrs) then (
-
Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
-
None)
-
else
-
let cookie =
-
build_cookie ~request_domain ~request_path ~name ~value:cookie_value
-
accumulated_attrs ~now
-
in
-
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
-
Some cookie)
-
-
and make_cookie_header cookies =
-
cookies
-
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
-
|> String.concat "; "
-
-
(** {1 Pretty Printing} *)
-
-
and pp_same_site ppf = function
-
| `Strict -> Format.pp_print_string ppf "Strict"
-
| `Lax -> Format.pp_print_string ppf "Lax"
-
| `None -> Format.pp_print_string ppf "None"
-
-
and pp ppf cookie =
-
Format.fprintf ppf
-
"@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
-
http_only=%b;@ expires=%a;@ same_site=%a }@]"
-
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
-
(http_only cookie)
-
(Format.pp_print_option Ptime.pp)
-
(expires cookie)
-
(Format.pp_print_option pp_same_site)
-
(same_site cookie)
-
-
let pp_jar ppf jar =
-
Eio.Mutex.lock jar.mutex;
-
let cookies = jar.cookies in
-
Eio.Mutex.unlock jar.mutex;
-
-
Format.fprintf ppf "@[<v>CookieJar with %d cookies:@," (List.length cookies);
-
List.iter (fun cookie -> Format.fprintf ppf " %a@," pp cookie) cookies;
-
Format.fprintf ppf "@]"
-
-
(** {1 Cookie Management} *)
-
-
let add_cookie jar cookie =
-
Log.debug (fun m ->
-
m "Adding cookie: %s=%s for domain %s" (name cookie) (value cookie)
-
(domain cookie));
-
-
Eio.Mutex.lock jar.mutex;
-
(* Remove existing cookie with same name, domain, and path *)
-
jar.cookies <-
-
List.filter
-
(fun c ->
-
not
-
(name c = name cookie && domain c = domain cookie
-
&& path c = path cookie))
-
jar.cookies;
-
jar.cookies <- cookie :: jar.cookies;
-
Eio.Mutex.unlock jar.mutex
-
-
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure =
-
Log.debug (fun m ->
-
m "Getting cookies for domain=%s path=%s secure=%b" request_domain
-
request_path is_secure);
-
-
Eio.Mutex.lock jar.mutex;
-
let applicable =
-
List.filter
-
(fun cookie ->
-
domain_matches (domain cookie) request_domain
-
&& path_matches (path cookie) request_path
-
&& ((not (secure cookie)) || is_secure))
-
jar.cookies
-
in
-
-
(* Update last access time *)
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
-
in
-
let updated =
-
List.map
-
(fun c ->
-
if List.memq c applicable then
-
make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c)
-
~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c)
-
?same_site:(same_site c) ~creation_time:(creation_time c)
-
~last_access:now ()
-
else c)
-
jar.cookies
-
in
-
jar.cookies <- updated;
-
Eio.Mutex.unlock jar.mutex;
-
-
Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
-
applicable
-
-
let clear jar =
-
Log.info (fun m -> m "Clearing all cookies");
-
Eio.Mutex.lock jar.mutex;
-
jar.cookies <- [];
-
Eio.Mutex.unlock jar.mutex
-
-
let clear_expired jar ~clock =
-
Eio.Mutex.lock jar.mutex;
-
let before_count = List.length jar.cookies in
-
jar.cookies <- List.filter (fun c -> not (is_expired c clock)) jar.cookies;
-
let removed = before_count - List.length jar.cookies in
-
Eio.Mutex.unlock jar.mutex;
-
Log.info (fun m -> m "Cleared %d expired cookies" removed)
-
-
let clear_session_cookies jar =
-
Eio.Mutex.lock jar.mutex;
-
let before_count = List.length jar.cookies in
-
jar.cookies <- List.filter (fun c -> expires c <> None) jar.cookies;
-
let removed = before_count - List.length jar.cookies in
-
Eio.Mutex.unlock jar.mutex;
-
Log.info (fun m -> m "Cleared %d session cookies" removed)
-
-
let count jar =
-
Eio.Mutex.lock jar.mutex;
-
let n = List.length jar.cookies in
-
Eio.Mutex.unlock jar.mutex;
-
n
-
-
let get_all_cookies jar =
-
Eio.Mutex.lock jar.mutex;
-
let cookies = jar.cookies in
-
Eio.Mutex.unlock jar.mutex;
-
cookies
-
-
let is_empty jar =
-
Eio.Mutex.lock jar.mutex;
-
let empty = jar.cookies = [] in
-
Eio.Mutex.unlock jar.mutex;
-
empty
-
-
(** {1 Mozilla Format} *)
-
-
let to_mozilla_format_internal jar =
-
let buffer = Buffer.create 1024 in
-
Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
-
Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
-
-
List.iter
-
(fun cookie ->
-
let include_subdomains =
-
if String.starts_with ~prefix:"." (domain cookie) then "TRUE" else "FALSE"
-
in
-
let secure_flag = if secure cookie then "TRUE" else "FALSE" in
-
let expires_str =
-
match expires cookie with
-
| None -> "0" (* Session cookie *)
-
| Some t ->
-
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
-
epoch
-
in
-
-
Buffer.add_string buffer
-
(Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (domain cookie)
-
include_subdomains (path cookie) secure_flag expires_str (name cookie)
-
(value cookie)))
-
jar.cookies;
-
-
Buffer.contents buffer
-
-
let to_mozilla_format jar =
-
Eio.Mutex.lock jar.mutex;
-
let result = to_mozilla_format_internal jar in
-
Eio.Mutex.unlock jar.mutex;
-
result
-
-
let from_mozilla_format ~clock content =
-
Log.debug (fun m -> m "Parsing Mozilla format cookies");
-
let jar = create () in
-
-
let lines = String.split_on_char '\n' content in
-
List.iter
-
(fun line ->
-
let line = String.trim line in
-
if line <> "" && not (String.starts_with ~prefix:"#" line) then
-
match String.split_on_char '\t' line with
-
| [ domain; _include_subdomains; path; secure; expires; name; value ] ->
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> 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 Ptime.of_float_s (float_of_int exp_int)
-
in
-
-
let cookie =
-
make ~domain ~path ~name ~value ~secure:(secure = "TRUE")
-
~http_only:false ?expires ?same_site:None ~creation_time:now
-
~last_access:now ()
-
in
-
add_cookie jar cookie;
-
Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
-
| _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line))
-
lines;
-
-
Log.info (fun m -> m "Loaded %d cookies" (List.length jar.cookies));
-
jar
-
-
(** {1 File Operations} *)
-
-
let load ~clock path =
-
Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
-
-
try
-
let content = Eio.Path.load path in
-
from_mozilla_format ~clock content
-
with
-
| Eio.Io _ ->
-
Log.info (fun m -> m "Cookie file not found, creating empty jar");
-
create ()
-
| exn ->
-
Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
-
create ()
-
-
let save path jar =
-
Log.info (fun m ->
-
m "Saving %d cookies to %a" (List.length jar.cookies) Eio.Path.pp path);
-
-
let content = to_mozilla_format jar in
-
-
try
-
Eio.Path.save ~create:(`Or_truncate 0o600) path content;
-
Log.debug (fun m -> m "Cookies saved successfully")
-
with exn ->
-
Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
-203
lib/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
-
-
*)
-
-
type same_site = [ `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)
-
*)
-
-
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. *)
-
-
type jar
-
(** 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 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 secure : t -> bool
-
(** Check if cookie is secure only *)
-
-
val http_only : t -> bool
-
(** Check if cookie is HTTP only *)
-
-
val expires : t -> Ptime.t option
-
(** Get the expiry time of a cookie *)
-
-
val same_site : t -> same_site 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 -> path:string -> name:string -> value:string ->
-
?secure:bool -> ?http_only:bool -> ?expires:Ptime.t ->
-
?same_site:same_site -> creation_time:Ptime.t -> last_access:Ptime.t ->
-
unit -> t
-
(** Create a new cookie with the given attributes *)
-
-
(** {1 Cookie Jar Creation and Loading} *)
-
-
val create : unit -> jar
-
(** Create an empty cookie jar *)
-
-
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> jar
-
(** Load cookies from Mozilla format file.
-
-
Loads cookies from a file in Mozilla format, using the provided clock to set
-
creation and last access times. Returns an empty jar if the file doesn't
-
exist or cannot be loaded. *)
-
-
val save : Eio.Fs.dir_ty Eio.Path.t -> jar -> unit
-
(** Save cookies to Mozilla format file *)
-
-
(** {1 Cookie Jar Management} *)
-
-
val add_cookie : jar -> t -> unit
-
(** Add a cookie to the jar *)
-
-
val get_cookies :
-
jar ->
-
clock:_ Eio.Time.clock ->
-
domain:string ->
-
path:string ->
-
is_secure:bool ->
-
t list
-
(** Get cookies applicable for a URL.
-
-
Returns all cookies that match the given domain and path, and satisfy the
-
secure flag requirement. Also updates the last access time of matching
-
cookies using the provided clock. *)
-
-
val clear : jar -> unit
-
(** Clear all cookies *)
-
-
val clear_expired : jar -> clock:_ Eio.Time.clock -> unit
-
(** Clear expired cookies *)
-
-
val clear_session_cookies : jar -> unit
-
(** Clear session cookies (those without expiry) *)
-
-
val count : jar -> int
-
(** Get the number of cookies in the jar *)
-
-
val get_all_cookies : jar -> t list
-
(** Get all cookies in the jar *)
-
-
val is_empty : jar -> bool
-
(** Check if the jar is empty *)
-
-
(** {1 Cookie Creation and Parsing} *)
-
-
val parse_set_cookie :
-
clock:_ Eio.Time.clock -> domain:string -> path:string -> string -> t option
-
(** Parse Set-Cookie header value into a cookie.
-
-
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]
-
- Returns [None] if parsing fails or cookie validation fails
-
- The [domain] and [path] parameters provide the request context for default
-
values
-
- The [clock] parameter is used for calculating expiry times from [max-age]
-
attributes
-
-
Cookie validation rules:
-
- [SameSite=None] requires the [Secure] flag to be set
-
-
Example:
-
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/"
-
"session=abc123; Secure; HttpOnly"] *)
-
-
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"] *)
-
-
(** {1 Pretty Printing} *)
-
-
val pp : Format.formatter -> t -> unit
-
(** Pretty print a cookie *)
-
-
val pp_jar : Format.formatter -> jar -> unit
-
(** Pretty print a cookie jar *)
-
-
(** {1 Mozilla Format} *)
-
-
val to_mozilla_format : jar -> string
-
(** Write cookies in Mozilla format *)
-
-
val from_mozilla_format : clock:_ Eio.Time.clock -> string -> jar
-
(** 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. *)
+926
lib/core/cookeio.ml
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
+
+
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 ]
+
+
let equal = ( = )
+
+
let pp ppf = function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `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 ]
+
+
let equal e1 e2 =
+
match (e1, e2) with
+
| `Session, `Session -> true
+
| `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
+
| _ -> false
+
+
let pp ppf = function
+
| `Session -> Format.pp_print_string ppf "Session"
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
+
end
+
+
type t = {
+
domain : string;
+
path : string;
+
name : string;
+
value : string;
+
secure : bool;
+
http_only : bool;
+
partitioned : bool;
+
host_only : bool;
+
expires : Expiration.t option;
+
max_age : Ptime.Span.t option;
+
same_site : SameSite.t option;
+
creation_time : Ptime.t;
+
last_access : Ptime.t;
+
}
+
(** HTTP Cookie *)
+
+
(** {1 Cookie Accessors} *)
+
+
let domain cookie = cookie.domain
+
let path cookie = cookie.path
+
let name cookie = cookie.name
+
let value cookie = cookie.value
+
+
let value_trimmed cookie =
+
let v = cookie.value in
+
let len = String.length v in
+
if len < 2 then v
+
else
+
match (v.[0], v.[len - 1]) with
+
| '"', '"' -> String.sub v 1 (len - 2)
+
| _ -> v
+
+
let secure cookie = cookie.secure
+
let http_only cookie = cookie.http_only
+
let partitioned cookie = cookie.partitioned
+
let host_only cookie = cookie.host_only
+
let expires cookie = cookie.expires
+
let max_age cookie = cookie.max_age
+
let same_site cookie = cookie.same_site
+
let creation_time cookie = cookie.creation_time
+
let last_access cookie = cookie.last_access
+
+
let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
+
?expires ?max_age ?same_site ?(partitioned = false) ?(host_only = false)
+
~creation_time ~last_access () =
+
{
+
domain;
+
path;
+
name;
+
value;
+
secure;
+
http_only;
+
partitioned;
+
host_only;
+
expires;
+
max_age;
+
same_site;
+
creation_time;
+
last_access;
+
}
+
+
(** {1 RFC 6265 Validation}
+
+
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}.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 - Syntax *)
+
module Validate = struct
+
(** Check if a character is a valid RFC 2616 token character.
+
+
Per RFC 6265, cookie-name must be a token as defined in RFC 2616 Section 2.2:
+
token = 1*<any CHAR except CTLs or separators>
+
separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" |
+
<"> | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
+
let is_token_char = function
+
| '\x00' .. '\x1F' | '\x7F' -> false (* CTL characters *)
+
| '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '\\' | '"' | '/' | '['
+
| ']' | '?' | '=' | '{' | '}' | ' ' ->
+
false (* separators - note: HT (0x09) is already covered by CTL range *)
+
| _ -> true
+
+
(** Validate a cookie name per RFC 6265.
+
+
Cookie names must be valid RFC 2616 tokens: one or more characters
+
excluding control characters and separators.
+
+
@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 *)
+
let cookie_name name =
+
let len = String.length name in
+
if len = 0 then
+
Error "Cookie name is empty; RFC 6265 requires at least one character"
+
else
+
let rec find_invalid i acc =
+
if i >= len then acc
+
else
+
let c = String.unsafe_get name i in
+
if is_token_char c then find_invalid (i + 1) acc
+
else find_invalid (i + 1) (c :: acc)
+
in
+
match find_invalid 0 [] with
+
| [] -> Ok name
+
| invalid_chars ->
+
let chars_str =
+
invalid_chars
+
|> List.rev
+
|> List.map (fun c -> Printf.sprintf "%C" c)
+
|> String.concat ", "
+
in
+
Error
+
(Printf.sprintf
+
"Cookie name %S contains invalid characters: %s. RFC 6265 requires \
+
cookie names to be valid tokens (no control characters, spaces, \
+
or separators like ()[]{}=,;:@\\\"/?<>)"
+
name chars_str)
+
+
(** Check if a character is a valid cookie-octet.
+
+
Per RFC 6265 Section 4.1.1:
+
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
+
(US-ASCII excluding CTLs, whitespace, DQUOTE, comma, semicolon, backslash)
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
+
let is_cookie_octet = function
+
| '\x21' -> true (* ! *)
+
| '\x23' .. '\x2B' -> true (* # $ % & ' ( ) * + *)
+
| '\x2D' .. '\x3A' -> true (* - . / 0-9 : *)
+
| '\x3C' .. '\x5B' -> true (* < = > ? @ A-Z [ *)
+
| '\x5D' .. '\x7E' -> true (* ] ^ _ ` a-z { | } ~ *)
+
| _ -> false
+
+
(** Validate a cookie value per RFC 6265.
+
+
Cookie values must contain only cookie-octets, optionally wrapped in
+
double quotes. Invalid characters include: control characters, space,
+
double quote (except as wrapper), comma, semicolon, and backslash.
+
+
@param value The cookie value to validate
+
@return [Ok value] 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 *)
+
let cookie_value value =
+
(* Handle optional DQUOTE wrapper *)
+
let len = String.length value in
+
let inner_value, inner_len =
+
if len >= 2 && value.[0] = '"' && value.[len - 1] = '"' then
+
(String.sub value 1 (len - 2), len - 2)
+
else (value, len)
+
in
+
let rec find_invalid i acc =
+
if i >= inner_len then acc
+
else
+
let c = String.unsafe_get inner_value i in
+
if is_cookie_octet c then find_invalid (i + 1) acc
+
else find_invalid (i + 1) (c :: acc)
+
in
+
match find_invalid 0 [] with
+
| [] -> Ok value
+
| invalid_chars ->
+
let chars_str =
+
invalid_chars
+
|> List.rev
+
|> List.map (fun c ->
+
match c with
+
| ' ' -> "space (0x20)"
+
| '"' -> "double-quote (0x22)"
+
| ',' -> "comma (0x2C)"
+
| ';' -> "semicolon (0x3B)"
+
| '\\' -> "backslash (0x5C)"
+
| c when Char.code c < 0x20 ->
+
Printf.sprintf "control char (0x%02X)" (Char.code c)
+
| c -> Printf.sprintf "%C (0x%02X)" c (Char.code c))
+
|> String.concat ", "
+
in
+
Error
+
(Printf.sprintf
+
"Cookie value %S contains invalid characters: %s. RFC 6265 cookie \
+
values may only contain printable ASCII excluding space, \
+
double-quote, comma, semicolon, and backslash"
+
value chars_str)
+
+
(** Validate a domain attribute value.
+
+
Domain values must be either:
+
- A valid domain name per RFC 1034 Section 3.5
+
- A valid IPv4 address
+
- A valid IPv6 address
+
+
@param domain The domain value to validate (leading dot is stripped first)
+
@return [Ok domain] if valid, [Error message] with explanation if invalid
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> RFC 6265 Section 4.1.2.3
+
@see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> RFC 1034 Section 3.5 *)
+
let domain_value domain =
+
(* Strip leading dot per RFC 6265 Section 5.2.3 *)
+
let domain =
+
if String.starts_with ~prefix:"." domain && String.length domain > 1 then
+
String.sub domain 1 (String.length domain - 1)
+
else domain
+
in
+
if String.length domain = 0 then
+
Error "Domain attribute is empty"
+
else
+
(* First check if it's an IP address *)
+
match Ipaddr.of_string domain with
+
| Ok _ -> Ok domain (* Valid IP address *)
+
| Error _ -> (
+
(* Not an IP, validate as domain name using domain-name library *)
+
match Domain_name.of_string domain with
+
| Ok _ -> Ok domain
+
| Error (`Msg msg) ->
+
Error
+
(Printf.sprintf
+
"Domain %S is not a valid domain name: %s. Domain names \
+
must follow RFC 1034: labels must start with a letter, \
+
contain only letters/digits/hyphens, not end with a \
+
hyphen, and be at most 63 characters each"
+
domain msg))
+
+
(** Validate a path attribute value.
+
+
Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except
+
control characters and semicolon.
+
+
@param path The path value to validate
+
@return [Ok path] 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 *)
+
let path_value path =
+
let len = String.length path in
+
let rec find_invalid i acc =
+
if i >= len then acc
+
else
+
let c = String.unsafe_get path i in
+
match c with
+
| '\x00' .. '\x1F' | '\x7F' | ';' -> find_invalid (i + 1) (c :: acc)
+
| _ -> find_invalid (i + 1) acc
+
in
+
match find_invalid 0 [] with
+
| [] -> Ok path
+
| invalid_chars ->
+
let chars_str =
+
invalid_chars
+
|> List.rev
+
|> List.map (fun c -> Printf.sprintf "0x%02X" (Char.code c))
+
|> String.concat ", "
+
in
+
Error
+
(Printf.sprintf
+
"Path %S contains invalid characters: %s. Paths may not contain \
+
control characters or semicolons"
+
path chars_str)
+
+
(** Validate a Max-Age attribute value.
+
+
Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT.
+
However, per Section 5.2.2, user agents should treat values <= 0 as
+
"delete immediately". This function returns [Ok] for any integer since
+
the parsing code handles negative values by converting to 0.
+
+
@param seconds The Max-Age value in seconds
+
@return [Ok seconds] always (negative values are handled in parsing)
+
+
@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-5.2.2> RFC 6265 Section 5.2.2 *)
+
let max_age seconds = Ok seconds
+
end
+
+
(** {1 Public Suffix Validation}
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 5},
+
cookies with Domain attributes that are public suffixes must be rejected
+
to prevent domain-wide cookie attacks.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model
+
@see <https://publicsuffix.org/list/> Public Suffix List *)
+
+
(** Module-level Public Suffix List instance.
+
+
Lazily initialized on first use. The PSL data is compiled into the
+
publicsuffix library at build time from the Mozilla Public Suffix List. *)
+
let psl = lazy (Publicsuffix.create ())
+
+
(** Validate that a cookie domain is not a public suffix.
+
+
Per RFC 6265 Section 5.3 Step 5, user agents MUST reject cookies where
+
the Domain attribute is a public suffix (e.g., ".com", ".co.uk") unless
+
the request host exactly matches that domain.
+
+
This prevents attackers from setting domain-wide cookies that would affect
+
all sites under a TLD.
+
+
@param request_domain The host from the HTTP request
+
@param cookie_domain The Domain attribute value (already normalized, without leading dot)
+
@return [Ok ()] if the domain is allowed, [Error msg] if it's a public suffix
+
+
Examples:
+
- Request from "www.example.com", Domain=".com" โ†’ Error (public suffix)
+
- Request from "www.example.com", Domain=".example.com" โ†’ Ok (not public suffix)
+
- Request from "com", Domain=".com" โ†’ Ok (request host matches domain exactly)
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 *)
+
let validate_not_public_suffix ~request_domain ~cookie_domain =
+
(* 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 _ -> (
+
let psl = Lazy.force psl in
+
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.
+
This allows a server that IS a public suffix (rare but possible with
+
private domains like blogspot.com) to set cookies for itself. *)
+
let request_lower = String.lowercase_ascii request_domain in
+
let cookie_lower = String.lowercase_ascii cookie_domain in
+
if request_lower = cookie_lower then Ok ()
+
else
+
Error
+
(Printf.sprintf
+
"Domain %S is a public suffix; RFC 6265 Section 5.3 prohibits \
+
setting cookies for public suffixes to prevent domain-wide \
+
cookie attacks. The request host %S does not exactly match \
+
the domain."
+
cookie_domain request_domain))
+
+
(** {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
+
| "feb" -> Some 2
+
| "mar" -> Some 3
+
| "apr" -> Some 4
+
| "may" -> Some 5
+
| "jun" -> Some 6
+
| "jul" -> Some 7
+
| "aug" -> Some 8
+
| "sep" -> Some 9
+
| "oct" -> Some 10
+
| "nov" -> Some 11
+
| "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
+
else year
+
+
(** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
+
let parse_fmt1 s =
+
try
+
Scanf.sscanf s "%s %d %s %d %d:%d:%d %s"
+
(fun _wday day mon year hour min sec tz ->
+
(* Check timezone is GMT (case-insensitive) *)
+
if String.lowercase_ascii tz <> "gmt" then None
+
else
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *)
+
let parse_fmt2 s =
+
try
+
Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s"
+
(fun _wday day mon year hour min sec tz ->
+
(* Check timezone is GMT (case-insensitive) *)
+
if String.lowercase_ascii tz <> "gmt" then None
+
else
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
+
let parse_fmt3 s =
+
try
+
Scanf.sscanf s "%s %s %d %d:%d:%d %d"
+
(fun _wday mon day hour min sec year ->
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
+
let parse_fmt4 s =
+
try
+
Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s"
+
(fun _wday day mon year hour min sec tz ->
+
(* Check timezone is GMT (case-insensitive) *)
+
if String.lowercase_ascii tz <> "gmt" then None
+
else
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse HTTP date by trying all supported formats in sequence *)
+
let parse_http_date 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} *)
+
+
type cookie_attributes = {
+
mutable domain : string option;
+
mutable path : string option;
+
mutable secure : bool;
+
mutable http_only : bool;
+
mutable partitioned : bool;
+
mutable expires : Expiration.t option;
+
mutable max_age : Ptime.Span.t option;
+
mutable same_site : SameSite.t option;
+
}
+
(** Accumulated attributes from parsing Set-Cookie header *)
+
+
(** Create empty attribute accumulator *)
+
let empty_attributes () =
+
{
+
domain = None;
+
path = None;
+
secure = false;
+
http_only = false;
+
partitioned = false;
+
expires = None;
+
max_age = None;
+
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
+
| "domain" -> attrs.domain <- Some (normalize_domain attr_value)
+
| "path" -> attrs.path <- Some attr_value
+
| "expires" -> (
+
if
+
(* Special case: Expires=0 means session cookie *)
+
attr_value = "0"
+
then attrs.expires <- Some `Session
+
else
+
match Ptime.of_rfc3339 attr_value with
+
| Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
+
| Error (`RFC3339 (_, err)) -> (
+
(* Try HTTP date format as fallback *)
+
match DateParser.parse_http_date attr_value with
+
| Some time -> attrs.expires <- Some (`DateTime time)
+
| None ->
+
Log.warn (fun m ->
+
m "Failed to parse expires attribute '%s': %a" attr_value
+
Ptime.pp_rfc3339_error err)))
+
| "max-age" -> (
+
match int_of_string_opt attr_value with
+
| Some seconds ->
+
(* Handle negative values as 0 per RFC 6265 *)
+
let seconds = max 0 seconds in
+
let current_time = now () in
+
(* Store the max-age as a Ptime.Span *)
+
attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
+
(* Also compute and store expires as DateTime *)
+
let expires =
+
Ptime.add_span current_time (Ptime.Span.of_int_s seconds)
+
in
+
(match expires with
+
| Some time -> attrs.expires <- Some (`DateTime time)
+
| None -> ());
+
Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
+
| None ->
+
Log.warn (fun m ->
+
m "Failed to parse max-age attribute '%s'" attr_value))
+
| "secure" -> attrs.secure <- true
+
| "httponly" -> attrs.http_only <- true
+
| "partitioned" -> attrs.partitioned <- true
+
| "samesite" -> (
+
match String.lowercase_ascii attr_value with
+
| "strict" -> attrs.same_site <- Some `Strict
+
| "lax" -> attrs.same_site <- Some `Lax
+
| "none" -> attrs.same_site <- Some `None
+
| _ ->
+
Log.warn (fun m ->
+
m "Invalid samesite value '%s', ignoring" attr_value))
+
| _ ->
+
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 =
+
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
+
| _ -> true
+
+
(** 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
+
| Some d -> (false, normalize_domain d)
+
| None -> (true, request_domain)
+
in
+
let path = Option.value attrs.path ~default:request_path in
+
make ~domain ~path ~name ~value ~secure:attrs.secure
+
~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
+
?same_site:attrs.same_site ~partitioned:attrs.partitioned ~host_only
+
~creation_time:now ~last_access:now ()
+
+
(** {1 Pretty Printing} *)
+
+
let pp ppf cookie =
+
Format.fprintf ppf
+
"@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
+
http_only=%b;@ partitioned=%b;@ host_only=%b;@ expires=%a;@ max_age=%a;@ \
+
same_site=%a }@]"
+
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
+
(http_only cookie) (partitioned cookie) (host_only cookie)
+
(Format.pp_print_option Expiration.pp)
+
(expires cookie)
+
(Format.pp_print_option Ptime.Span.pp)
+
(max_age cookie)
+
(Format.pp_print_option SameSite.pp)
+
(same_site cookie)
+
+
(** {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 [Error msg] if
+
the cookie is invalid or fails validation, with a descriptive error message.
+
+
@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 [Ok cookie] if parsing succeeds, [Error msg] with explanation if invalid
+
+
@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);
+
+
(* Split into attributes *)
+
let parts = String.split_on_char ';' header_value |> List.map String.trim in
+
+
match parts with
+
| [] -> Error "Empty Set-Cookie header"
+
| name_value :: attrs -> (
+
(* Parse name=value *)
+
match String.index_opt name_value '=' with
+
| None ->
+
Error
+
(Printf.sprintf
+
"Set-Cookie header missing '=' separator in name-value pair: %S"
+
name_value)
+
| Some eq_pos -> (
+
let name = String.sub name_value 0 eq_pos |> String.trim in
+
let cookie_value =
+
String.sub name_value (eq_pos + 1)
+
(String.length name_value - eq_pos - 1)
+
|> String.trim
+
in
+
+
(* Validate cookie name per RFC 6265 *)
+
match Validate.cookie_name name with
+
| Error msg -> Error msg
+
| Ok name -> (
+
(* Validate cookie value per RFC 6265 *)
+
match Validate.cookie_value cookie_value with
+
| Error msg -> Error msg
+
| Ok cookie_value ->
+
let current_time = now () in
+
+
(* Parse all attributes into mutable accumulator *)
+
let accumulated_attrs = empty_attributes () in
+
let attr_errors = ref [] in
+
List.iter
+
(fun attr ->
+
match String.index_opt attr '=' with
+
| None ->
+
(* Attribute without value (e.g., Secure, HttpOnly) *)
+
parse_attribute now accumulated_attrs attr ""
+
| Some eq ->
+
let attr_name = String.sub attr 0 eq |> String.trim in
+
let attr_value =
+
String.sub attr (eq + 1)
+
(String.length attr - eq - 1)
+
|> String.trim
+
in
+
(* Validate domain and path attributes *)
+
(match String.lowercase_ascii attr_name with
+
| "domain" -> (
+
match Validate.domain_value attr_value with
+
| Error msg -> attr_errors := msg :: !attr_errors
+
| Ok _ -> ())
+
| "path" -> (
+
match Validate.path_value attr_value with
+
| Error msg -> attr_errors := msg :: !attr_errors
+
| Ok _ -> ())
+
| "max-age" -> (
+
match int_of_string_opt attr_value with
+
| Some seconds -> (
+
match Validate.max_age seconds with
+
| Error msg ->
+
attr_errors := msg :: !attr_errors
+
| Ok _ -> ())
+
| None -> ())
+
| _ -> ());
+
parse_attribute now accumulated_attrs attr_name
+
attr_value)
+
attrs;
+
+
(* Check for attribute validation errors *)
+
if List.length !attr_errors > 0 then
+
Error (String.concat "; " (List.rev !attr_errors))
+
else if not (validate_attributes accumulated_attrs) then
+
Error
+
"Cookie validation failed: SameSite=None requires \
+
Secure flag, and Partitioned requires Secure flag"
+
else
+
(* Public suffix validation per RFC 6265 Section 5.3 Step 5.
+
Only applies when Domain attribute is present. *)
+
let psl_result =
+
match accumulated_attrs.domain with
+
| None ->
+
(* No Domain attribute - cookie is host-only, no PSL check needed *)
+
Ok ()
+
| Some cookie_domain ->
+
let normalized = normalize_domain cookie_domain in
+
validate_not_public_suffix ~request_domain ~cookie_domain:normalized
+
in
+
(match psl_result with
+
| Error msg -> Error msg
+
| Ok () ->
+
let cookie =
+
build_cookie ~request_domain ~request_path ~name
+
~value:cookie_value accumulated_attrs ~now:current_time
+
in
+
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
+
Ok 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.
+
+
Validates cookie names and values per RFC 6265 and detects duplicate
+
cookie names (which is an error per Section 4.2.1).
+
+
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 [Ok cookies] if all cookies parse successfully with no duplicates,
+
[Error msg] with explanation if validation fails
+
+
@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);
+
+
(* Split on semicolons *)
+
let parts = String.split_on_char ';' header_value |> List.map String.trim in
+
+
(* Filter out empty parts *)
+
let parts = List.filter (fun s -> String.length s > 0) parts in
+
+
(* Parse each name=value pair, collecting results *)
+
let results =
+
List.fold_left
+
(fun acc name_value ->
+
match acc with
+
| Error _ -> acc (* Propagate earlier errors *)
+
| Ok (cookies, seen_names) -> (
+
match String.index_opt name_value '=' with
+
| None ->
+
Error
+
(Printf.sprintf "Cookie missing '=' separator: %S" name_value)
+
| Some eq_pos -> (
+
let cookie_name =
+
String.sub name_value 0 eq_pos |> String.trim
+
in
+
(* Validate cookie name per RFC 6265 *)
+
match Validate.cookie_name cookie_name with
+
| Error msg -> Error msg
+
| Ok cookie_name -> (
+
(* Check for duplicate names per RFC 6265 Section 4.2.1 *)
+
if List.mem cookie_name seen_names then
+
Error
+
(Printf.sprintf
+
"Duplicate cookie name %S in Cookie header; RFC \
+
6265 Section 4.2.1 forbids duplicate names"
+
cookie_name)
+
else
+
let cookie_value =
+
String.sub name_value (eq_pos + 1)
+
(String.length name_value - eq_pos - 1)
+
|> String.trim
+
in
+
(* Validate cookie value per RFC 6265 *)
+
match Validate.cookie_value cookie_value with
+
| Error msg -> Error msg
+
| Ok cookie_value ->
+
let current_time = now () in
+
(* Create cookie with defaults from Cookie header context.
+
Cookies from Cookie headers have host_only=true since we don't
+
know if they originally had a Domain attribute. *)
+
let cookie =
+
make ~domain ~path ~name:cookie_name
+
~value:cookie_value ~secure:false ~http_only:false
+
~partitioned:false ~host_only:true
+
~creation_time:current_time
+
~last_access:current_time ()
+
in
+
Ok (cookie :: cookies, cookie_name :: seen_names)))))
+
(Ok ([], []))
+
parts
+
in
+
match results with
+
| Error msg -> Error msg
+
| Ok (cookies, _) -> Ok (List.rev cookies)
+
+
(** 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));
+
+
(* Add Max-Age if present *)
+
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 *)
+
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));
+
+
(* Add Path *)
+
Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie));
+
+
(* Add Secure flag *)
+
if secure cookie then Buffer.add_string buffer "; Secure";
+
+
(* Add HttpOnly flag *)
+
if http_only cookie then Buffer.add_string buffer "; HttpOnly";
+
+
(* Add Partitioned flag *)
+
if partitioned cookie then Buffer.add_string buffer "; Partitioned";
+
+
(* Add SameSite *)
+
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
+533
lib/core/cookeio.mli
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
(** 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
+
+
{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} *)
+
+
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
+
matching pair.
+
+
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"]
+
- ["\"value\""] โ†’ ["value"]
+
- ["\"value"] โ†’ ["\"value"] (no matching pair)
+
- ["\"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 ->
+
path:string ->
+
name:string ->
+
value:string ->
+
?secure:bool ->
+
?http_only:bool ->
+
?expires:Expiration.t ->
+
?max_age:Ptime.Span.t ->
+
?same_site:SameSite.t ->
+
?partitioned:bool ->
+
?host_only:bool ->
+
creation_time:Ptime.t ->
+
last_access:Ptime.t ->
+
unit ->
+
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 RFC 6265 Validation}
+
+
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.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 - Syntax *)
+
+
module Validate : sig
+
val cookie_name : string -> (string, string) result
+
(** Validate a cookie name per RFC 6265.
+
+
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/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.
+
+
Cookie values must contain only cookie-octets, optionally wrapped in
+
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
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
+
+
val domain_value : string -> (string, string) result
+
(** Validate a domain attribute value.
+
+
Domain values must be either:
+
- 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
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> RFC 6265 Section 4.1.2.3
+
@see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> RFC 1034 Section 3.5 *)
+
+
val path_value : string -> (string, string) result
+
(** Validate a path attribute value.
+
+
Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except
+
control characters and semicolon.
+
+
@param path The path value to validate
+
@return [Ok path] 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 *)
+
+
val max_age : int -> (int, string) result
+
(** Validate a Max-Age attribute value.
+
+
Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT.
+
However, per Section 5.2.2, user agents should treat values <= 0 as
+
"delete immediately". This function returns [Ok] for any integer since
+
the parsing code handles negative values by converting to 0.
+
+
@param seconds The Max-Age value in seconds
+
@return [Ok seconds] always (negative values are handled in parsing)
+
+
@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-5.2.2> RFC 6265 Section 5.2.2 *)
+
end
+
+
(** {1 Cookie Creation and Parsing} *)
+
+
val of_set_cookie_header :
+
now:(unit -> Ptime.t) ->
+
domain:string ->
+
path:string ->
+
string ->
+
(t, string) result
+
(** 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]
+
- Returns [Error msg] if parsing fails or cookie validation fails, with
+
a detailed explanation of what was invalid
+
- The [domain] and [path] parameters provide the request context for default
+
values
+
- The [now] parameter is used for calculating expiry times from [max-age]
+
attributes and setting creation/access times
+
+
Validation rules applied:
+
- Cookie name must be a valid RFC 2616 token (no CTLs or separators)
+
- Cookie value must contain only valid cookie-octets
+
- Domain must be a valid domain name (RFC 1034) or IP address
+
- Path must not contain control characters or semicolons
+
- Max-Age must be non-negative
+
- [SameSite=None] requires the [Secure] flag to be set (RFC 6265bis)
+
- [Partitioned] requires the [Secure] flag to be set (CHIPS)
+
- Domain must not be a public suffix per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 5}
+
(unless the request host exactly matches the domain). This uses the
+
{{:https://publicsuffix.org/list/} Mozilla Public Suffix List} to prevent
+
domain-wide cookie attacks.
+
+
{3 Public Suffix Validation}
+
+
Cookies with Domain attributes that are public suffixes (e.g., [.com], [.co.uk],
+
[.github.io]) are rejected to prevent a malicious site from setting cookies
+
that would affect all sites under that TLD.
+
+
Examples:
+
- Request from [www.example.com], Domain=[.com] โ†’ rejected (public suffix)
+
- Request from [www.example.com], Domain=[.example.com] โ†’ allowed
+
- Request from [blogspot.com], Domain=[.blogspot.com] โ†’ allowed (request matches)
+
+
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
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (public suffix check)
+
@see <https://publicsuffix.org/list/> Public Suffix List *)
+
+
val of_cookie_header :
+
now:(unit -> Ptime.t) ->
+
domain:string ->
+
path:string ->
+
string ->
+
(t list, string) result
+
(** 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"]
+
+
Validates each cookie name and value per RFC 6265 and detects duplicate
+
cookie names (which is forbidden per Section 4.2.1).
+
+
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 [Ok cookies] if all cookies parse successfully with no duplicates,
+
or [Error msg] if any validation fails.
+
+
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. *)
+4
lib/core/dune
···
+
(library
+
(name cookeio)
+
(public_name cookeio)
+
(libraries logs ptime ipaddr domain-name publicsuffix))
-4
lib/dune
···
-
(library
-
(name cookeio)
-
(public_name cookeio)
-
(libraries eio logs ptime unix))
+578
lib/jar/cookeio_jar.ml
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
let src = Logs.Src.create "cookie_jar" ~doc:"Cookie jar management"
+
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
type t = {
+
mutable original_cookies : Cookeio.t list; (* from client *)
+
mutable delta_cookies : Cookeio.t list; (* to send back *)
+
mutex : Eio.Mutex.t;
+
}
+
(** Cookie jar for storing and managing cookies *)
+
+
(** {1 Cookie Jar Creation} *)
+
+
let create () =
+
Log.debug (fun m -> m "Creating new empty cookie jar");
+
{ original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () }
+
+
(** {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
+
+
(** 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 = Result.is_ok (Ipaddr.of_string domain)
+
+
(** 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 =
+
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.
+
+
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.ends_with ~suffix:"/" cookie_path
+
|| (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 *)
+
| Some `Session ->
+
false (* Session cookie - not expired until browser closes *)
+
| Some (`DateTime exp_time) ->
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
+
in
+
Ptime.compare now exp_time > 0
+
+
let pp ppf jar =
+
Eio.Mutex.lock jar.mutex;
+
let original = jar.original_cookies in
+
let delta = jar.delta_cookies in
+
Eio.Mutex.unlock jar.mutex;
+
+
let all_cookies = original @ delta in
+
Format.fprintf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@,"
+
(List.length all_cookies) (List.length original) (List.length delta);
+
List.iter
+
(fun cookie -> Format.fprintf ppf " %a@," Cookeio.pp cookie)
+
all_cookies;
+
Format.fprintf ppf "@]"
+
+
(** {1 Cookie Management} *)
+
+
(** Preserve creation time from an existing cookie when replacing.
+
+
Per RFC 6265 Section 5.3, Step 11.3: "If the newly created cookie was
+
received from a 'non-HTTP' API and the old-cookie's http-only-flag is
+
true, abort these steps and ignore the newly created cookie entirely."
+
Step 11.3 also states: "Update the creation-time of the old-cookie to
+
match the creation-time of the newly created cookie."
+
+
However, the common interpretation (and browser behavior) is to preserve
+
the original creation-time when updating a cookie. This matches what
+
Step 3 of Section 5.4 uses for ordering (creation-time stability).
+
+
@param old_cookie The existing cookie being replaced (if any)
+
@param new_cookie The new cookie to add
+
@return The new cookie with creation_time preserved from old_cookie if present
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
+
let preserve_creation_time old_cookie_opt new_cookie =
+
match old_cookie_opt with
+
| None -> new_cookie
+
| Some old_cookie ->
+
Cookeio.make ~domain:(Cookeio.domain new_cookie)
+
~path:(Cookeio.path new_cookie) ~name:(Cookeio.name new_cookie)
+
~value:(Cookeio.value new_cookie) ~secure:(Cookeio.secure new_cookie)
+
~http_only:(Cookeio.http_only new_cookie)
+
?expires:(Cookeio.expires new_cookie)
+
?max_age:(Cookeio.max_age new_cookie)
+
?same_site:(Cookeio.same_site new_cookie)
+
~partitioned:(Cookeio.partitioned new_cookie)
+
~host_only:(Cookeio.host_only new_cookie)
+
~creation_time:(Cookeio.creation_time old_cookie)
+
~last_access:(Cookeio.last_access new_cookie)
+
()
+
+
let add_cookie jar cookie =
+
Log.debug (fun m ->
+
m "Adding cookie to delta: %s=%s for domain %s" (Cookeio.name cookie)
+
(Cookeio.value cookie) (Cookeio.domain cookie));
+
+
Eio.Mutex.lock jar.mutex;
+
+
(* Find existing cookie with same identity to preserve creation_time
+
per RFC 6265 Section 5.3, Step 11.3 *)
+
let existing =
+
List.find_opt (fun c -> cookie_identity_matches c cookie) jar.delta_cookies
+
in
+
let existing =
+
match existing with
+
| Some _ -> existing
+
| None ->
+
(* Also check original cookies for creation time preservation *)
+
List.find_opt
+
(fun c -> cookie_identity_matches c cookie)
+
jar.original_cookies
+
in
+
+
let cookie = preserve_creation_time existing cookie in
+
+
(* Remove existing cookie with same identity from delta *)
+
jar.delta_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.delta_cookies;
+
jar.delta_cookies <- cookie :: jar.delta_cookies;
+
Eio.Mutex.unlock jar.mutex
+
+
let add_original jar cookie =
+
Log.debug (fun m ->
+
m "Adding original cookie: %s=%s for domain %s" (Cookeio.name cookie)
+
(Cookeio.value cookie) (Cookeio.domain cookie));
+
+
Eio.Mutex.lock jar.mutex;
+
+
(* Find existing cookie with same identity to preserve creation_time
+
per RFC 6265 Section 5.3, Step 11.3 *)
+
let existing =
+
List.find_opt
+
(fun c -> cookie_identity_matches c cookie)
+
jar.original_cookies
+
in
+
+
let cookie = preserve_creation_time existing cookie in
+
+
(* Remove existing cookie with same identity from original *)
+
jar.original_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.original_cookies;
+
jar.original_cookies <- cookie :: jar.original_cookies;
+
Eio.Mutex.unlock jar.mutex
+
+
let delta jar =
+
Eio.Mutex.lock jar.mutex;
+
let result = jar.delta_cookies in
+
Eio.Mutex.unlock jar.mutex;
+
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
+
in
+
(* Create a cookie with Max-Age=0 and past expiration (1 year ago) *)
+
let past_expiry =
+
Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60))
+
|> Option.value ~default:Ptime.epoch
+
in
+
Cookeio.make ~domain:(Cookeio.domain cookie) ~path:(Cookeio.path cookie)
+
~name:(Cookeio.name cookie) ~value:"" ~secure:(Cookeio.secure cookie)
+
~http_only:(Cookeio.http_only cookie) ~expires:(`DateTime past_expiry)
+
~max_age:(Ptime.Span.of_int_s 0) ?same_site:(Cookeio.same_site cookie)
+
~partitioned:(Cookeio.partitioned cookie)
+
~host_only:(Cookeio.host_only cookie)
+
~creation_time:now ~last_access:now ()
+
+
let remove jar ~clock cookie =
+
Log.debug (fun m ->
+
m "Removing cookie: %s=%s for domain %s" (Cookeio.name cookie)
+
(Cookeio.value cookie) (Cookeio.domain cookie));
+
+
Eio.Mutex.lock jar.mutex;
+
(* Check if this cookie exists in original_cookies *)
+
let in_original =
+
List.exists (fun c -> cookie_identity_matches c cookie) jar.original_cookies
+
in
+
+
if in_original then (
+
(* Create a removal cookie and add it to delta *)
+
let removal = make_removal_cookie cookie ~clock in
+
jar.delta_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c removal))
+
jar.delta_cookies;
+
jar.delta_cookies <- removal :: jar.delta_cookies;
+
Log.debug (fun m -> m "Created removal cookie in delta for original cookie"))
+
else (
+
(* Just remove from delta if it exists there *)
+
jar.delta_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.delta_cookies;
+
Log.debug (fun m -> m "Removed cookie from delta"));
+
+
Eio.Mutex.unlock jar.mutex
+
+
(** Compare cookies for ordering per RFC 6265 Section 5.4, Step 2.
+
+
Cookies SHOULD be sorted:
+
1. Cookies with longer paths listed first
+
2. Among equal-length paths, cookies with earlier creation-times first
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)
+
let compare_cookie_order c1 c2 =
+
let path1_len = String.length (Cookeio.path c1) in
+
let path2_len = String.length (Cookeio.path c2) in
+
(* Longer paths first (descending order) *)
+
match Int.compare path2_len path1_len with
+
| 0 ->
+
(* Equal path lengths: earlier creation time first (ascending order) *)
+
Ptime.compare (Cookeio.creation_time c1) (Cookeio.creation_time c2)
+
| n -> n
+
+
(** 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.
+
+
Cookies are sorted per Section 5.4, Step 2:
+
1. Cookies with longer paths listed first
+
2. Among equal-length paths, earlier creation-times listed first
+
+
@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, sorted
+
+
@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 ->
+
m "Getting cookies for domain=%s path=%s secure=%b" request_domain
+
request_path is_secure);
+
+
Eio.Mutex.lock jar.mutex;
+
+
(* Combine original and delta cookies, with delta taking precedence *)
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
let unique_cookies = dedup_by_identity all_cookies in
+
+
(* Filter for applicable cookies, excluding removal cookies and expired cookies *)
+
let applicable =
+
List.filter
+
(fun cookie ->
+
Cookeio.value cookie <> ""
+
(* Exclude removal cookies *)
+
&& (not (is_expired cookie clock))
+
(* Exclude expired cookies *)
+
&& domain_matches ~host_only:(Cookeio.host_only cookie)
+
(Cookeio.domain cookie) request_domain
+
&& path_matches (Cookeio.path cookie) request_path
+
&& ((not (Cookeio.secure cookie)) || is_secure))
+
unique_cookies
+
in
+
+
(* Sort cookies per RFC 6265 Section 5.4, Step 2:
+
- Longer paths first
+
- Equal paths: earlier creation time first *)
+
let sorted = List.sort compare_cookie_order applicable in
+
+
(* Update last access time in both lists *)
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
let update_last_access cookies =
+
List.map
+
(fun c ->
+
if List.exists (fun a -> cookie_identity_matches a c) applicable then
+
Cookeio.make ~domain:(Cookeio.domain c) ~path:(Cookeio.path c)
+
~name:(Cookeio.name c) ~value:(Cookeio.value c)
+
~secure:(Cookeio.secure c) ~http_only:(Cookeio.http_only c)
+
?expires:(Cookeio.expires c) ?max_age:(Cookeio.max_age c)
+
?same_site:(Cookeio.same_site c)
+
~partitioned:(Cookeio.partitioned c)
+
~host_only:(Cookeio.host_only c)
+
~creation_time:(Cookeio.creation_time c) ~last_access:now ()
+
else c)
+
cookies
+
in
+
jar.original_cookies <- update_last_access jar.original_cookies;
+
jar.delta_cookies <- update_last_access jar.delta_cookies;
+
+
Eio.Mutex.unlock jar.mutex;
+
+
Log.debug (fun m -> m "Found %d applicable cookies" (List.length sorted));
+
sorted
+
+
let clear jar =
+
Log.info (fun m -> m "Clearing all cookies");
+
Eio.Mutex.lock jar.mutex;
+
jar.original_cookies <- [];
+
jar.delta_cookies <- [];
+
Eio.Mutex.unlock jar.mutex
+
+
let clear_expired jar ~clock =
+
Eio.Mutex.lock jar.mutex;
+
let before_count =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
jar.original_cookies <-
+
List.filter (fun c -> not (is_expired c clock)) jar.original_cookies;
+
jar.delta_cookies <-
+
List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies;
+
let removed =
+
before_count
+
- (List.length jar.original_cookies + List.length jar.delta_cookies)
+
in
+
Eio.Mutex.unlock jar.mutex;
+
Log.info (fun m -> m "Cleared %d expired cookies" removed)
+
+
let clear_session_cookies jar =
+
Eio.Mutex.lock jar.mutex;
+
let before_count =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
(* Keep only cookies that are NOT session cookies *)
+
let is_not_session c =
+
match Cookeio.expires c with
+
| Some `Session -> false (* This is a session cookie, remove it *)
+
| None | Some (`DateTime _) -> true (* Keep these *)
+
in
+
jar.original_cookies <- List.filter is_not_session jar.original_cookies;
+
jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
+
let removed =
+
before_count
+
- (List.length jar.original_cookies + List.length jar.delta_cookies)
+
in
+
Eio.Mutex.unlock jar.mutex;
+
Log.info (fun m -> m "Cleared %d session cookies" removed)
+
+
let count jar =
+
Eio.Mutex.lock jar.mutex;
+
let all_cookies = jar.original_cookies @ jar.delta_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;
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
let unique = dedup_by_identity all_cookies in
+
Eio.Mutex.unlock jar.mutex;
+
unique
+
+
let is_empty jar =
+
Eio.Mutex.lock jar.mutex;
+
let empty = jar.original_cookies = [] && jar.delta_cookies = [] in
+
Eio.Mutex.unlock jar.mutex;
+
empty
+
+
(** {1 Mozilla Format} *)
+
+
let to_mozilla_format_internal jar =
+
let buffer = Buffer.create 1024 in
+
Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
+
Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
+
+
(* Combine and deduplicate cookies *)
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
let unique = dedup_by_identity all_cookies in
+
+
List.iter
+
(fun cookie ->
+
(* Mozilla format: include_subdomains=TRUE means host_only=false *)
+
let include_subdomains = if Cookeio.host_only cookie then "FALSE" else "TRUE" in
+
let secure_flag = if Cookeio.secure cookie then "TRUE" else "FALSE" in
+
let expires_str =
+
match Cookeio.expires cookie with
+
| None -> "0" (* No expiration *)
+
| Some `Session -> "0" (* Session cookie *)
+
| Some (`DateTime t) ->
+
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
+
epoch
+
in
+
+
Buffer.add_string buffer
+
(Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (Cookeio.domain cookie)
+
include_subdomains (Cookeio.path cookie) secure_flag expires_str
+
(Cookeio.name cookie) (Cookeio.value cookie)))
+
unique;
+
+
Buffer.contents buffer
+
+
let to_mozilla_format jar =
+
Eio.Mutex.lock jar.mutex;
+
let result = to_mozilla_format_internal jar in
+
Eio.Mutex.unlock jar.mutex;
+
result
+
+
let from_mozilla_format ~clock content =
+
Log.debug (fun m -> m "Parsing Mozilla format cookies");
+
let jar = create () in
+
+
let lines = String.split_on_char '\n' content in
+
List.iter
+
(fun line ->
+
let line = String.trim line in
+
if line <> "" && not (String.starts_with ~prefix:"#" line) then
+
match String.split_on_char '\t' line with
+
| [ domain; include_subdomains; path; secure; expires; name; value ] ->
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
+
in
+
let expires =
+
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
+
+
let cookie =
+
Cookeio.make ~domain:(normalize_domain domain) ~path ~name ~value
+
~secure:(secure = "TRUE") ~http_only:false ?expires
+
?max_age:None ?same_site:None ~partitioned:false ~host_only
+
~creation_time:now ~last_access:now ()
+
in
+
add_original jar cookie;
+
Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
+
| _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line))
+
lines;
+
+
Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies));
+
jar
+
+
(** {1 File Operations} *)
+
+
let load ~clock path =
+
Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
+
+
try
+
let content = Eio.Path.load path in
+
from_mozilla_format ~clock content
+
with
+
| Eio.Io _ ->
+
Log.info (fun m -> m "Cookie file not found, creating empty jar");
+
create ()
+
| exn ->
+
Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
+
create ()
+
+
let save path jar =
+
Eio.Mutex.lock jar.mutex;
+
let total_cookies =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
Eio.Mutex.unlock jar.mutex;
+
Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path);
+
+
let content = to_mozilla_format jar in
+
+
try
+
Eio.Path.save ~create:(`Or_truncate 0o600) path content;
+
Log.debug (fun m -> m "Cookies saved successfully")
+
with exn ->
+
Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+220
lib/jar/cookeio_jar.mli
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
(** 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
+
+
{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.
+
+
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.
+
+
Loads cookies from a file in Mozilla format, using the provided clock to set
+
creation and last access times. Returns an empty jar if the file doesn't
+
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} *)
+
+
val add_cookie : t -> Cookeio.t -> unit
+
(** Add a cookie to the jar.
+
+
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, it will be replaced per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}.
+
+
Per Section 5.3, Step 11.3, when replacing an existing cookie, the original
+
creation-time is preserved. This ensures stable cookie ordering per
+
Section 5.4, Step 2.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
+
+
val add_original : t -> Cookeio.t -> unit
+
(** Add an original cookie to the jar.
+
+
Original cookies are those received from the client (via Cookie header).
+
They do not appear in the delta. This method should be used when loading
+
cookies from incoming HTTP requests.
+
+
Per Section 5.3, Step 11.3, when replacing an existing cookie, the original
+
creation-time is preserved.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
+
+
val delta : t -> Cookeio.t list
+
(** Get cookies that need to be sent in Set-Cookie headers.
+
+
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 ->
+
clock:_ Eio.Time.clock ->
+
domain:string ->
+
path:string ->
+
is_secure:bool ->
+
Cookeio.t list
+
(** Get cookies applicable for a URL.
+
+
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
+
+
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 *)
+
+
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.
+
+
Returns all cookies including expired ones (for inspection/debugging).
+
Use {!get_cookies} with appropriate domain/path for filtered results that
+
exclude expired cookies, or call {!clear_expired} first. *)
+
+
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}. *)
+4
lib/jar/dune
···
+
(library
+
(name cookeio_jar)
+
(public_name cookeio.jar)
+
(libraries cookeio eio logs ptime unix ipaddr))
+2075
spec/rfc6265.txt
···
+
+
+
+
+
+
+
Internet Engineering Task Force (IETF) A. Barth
+
Request for Comments: 6265 U.C. Berkeley
+
Obsoletes: 2965 April 2011
+
Category: Standards Track
+
ISSN: 2070-1721
+
+
+
HTTP State Management Mechanism
+
+
Abstract
+
+
This document defines the HTTP Cookie and Set-Cookie header fields.
+
These header fields can be used by HTTP servers to store state
+
(called cookies) at HTTP user agents, letting the servers maintain a
+
stateful session over the mostly stateless HTTP protocol. Although
+
cookies have many historical infelicities that degrade their security
+
and privacy, the Cookie and Set-Cookie header fields are widely used
+
on the Internet. This document obsoletes RFC 2965.
+
+
Status of This Memo
+
+
This is an Internet Standards Track document.
+
+
This document is a product of the Internet Engineering Task Force
+
(IETF). It represents the consensus of the IETF community. It has
+
received public review and has been approved for publication by the
+
Internet Engineering Steering Group (IESG). Further information on
+
Internet Standards is available in Section 2 of RFC 5741.
+
+
Information about the current status of this document, any errata,
+
and how to provide feedback on it may be obtained at
+
http://www.rfc-editor.org/info/rfc6265.
+
+
Copyright Notice
+
+
Copyright (c) 2011 IETF Trust and the persons identified as the
+
document authors. All rights reserved.
+
+
This document is subject to BCP 78 and the IETF Trust's Legal
+
Provisions Relating to IETF Documents
+
(http://trustee.ietf.org/license-info) in effect on the date of
+
publication of this document. Please review these documents
+
carefully, as they describe your rights and restrictions with respect
+
to this document. Code Components extracted from this document must
+
include Simplified BSD License text as described in Section 4.e of
+
the Trust Legal Provisions and are provided without warranty as
+
described in the Simplified BSD License.
+
+
+
+
+
Barth Standards Track [Page 1]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
This document may contain material from IETF Documents or IETF
+
Contributions published or made publicly available before November
+
10, 2008. The person(s) controlling the copyright in some of this
+
material may not have granted the IETF Trust the right to allow
+
modifications of such material outside the IETF Standards Process.
+
Without obtaining an adequate license from the person(s) controlling
+
the copyright in such materials, this document may not be modified
+
outside the IETF Standards Process, and derivative works of it may
+
not be created outside the IETF Standards Process, except to format
+
it for publication as an RFC or to translate it into languages other
+
than English.
+
+
Table of Contents
+
+
1. Introduction ....................................................3
+
2. Conventions .....................................................4
+
2.1. Conformance Criteria .......................................4
+
2.2. Syntax Notation ............................................5
+
2.3. Terminology ................................................5
+
3. Overview ........................................................6
+
3.1. Examples ...................................................6
+
4. Server Requirements .............................................8
+
4.1. Set-Cookie .................................................8
+
4.1.1. Syntax ..............................................8
+
4.1.2. Semantics (Non-Normative) ..........................10
+
4.2. Cookie ....................................................13
+
4.2.1. Syntax .............................................13
+
4.2.2. Semantics ..........................................13
+
5. User Agent Requirements ........................................14
+
5.1. Subcomponent Algorithms ...................................14
+
5.1.1. Dates ..............................................14
+
5.1.2. Canonicalized Host Names ...........................16
+
5.1.3. Domain Matching ....................................16
+
5.1.4. Paths and Path-Match ...............................16
+
5.2. The Set-Cookie Header .....................................17
+
5.2.1. The Expires Attribute ..............................19
+
5.2.2. The Max-Age Attribute ..............................20
+
5.2.3. The Domain Attribute ...............................20
+
5.2.4. The Path Attribute .................................21
+
5.2.5. The Secure Attribute ...............................21
+
5.2.6. The HttpOnly Attribute .............................21
+
5.3. Storage Model .............................................21
+
5.4. The Cookie Header .........................................25
+
6. Implementation Considerations ..................................27
+
6.1. Limits ....................................................27
+
6.2. Application Programming Interfaces ........................27
+
6.3. IDNA Dependency and Migration .............................27
+
7. Privacy Considerations .........................................28
+
+
+
+
Barth Standards Track [Page 2]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
7.1. Third-Party Cookies .......................................28
+
7.2. User Controls .............................................28
+
7.3. Expiration Dates ..........................................29
+
8. Security Considerations ........................................29
+
8.1. Overview ..................................................29
+
8.2. Ambient Authority .........................................30
+
8.3. Clear Text ................................................30
+
8.4. Session Identifiers .......................................31
+
8.5. Weak Confidentiality ......................................32
+
8.6. Weak Integrity ............................................32
+
8.7. Reliance on DNS ...........................................33
+
9. IANA Considerations ............................................33
+
9.1. Cookie ....................................................34
+
9.2. Set-Cookie ................................................34
+
9.3. Cookie2 ...................................................34
+
9.4. Set-Cookie2 ...............................................34
+
10. References ....................................................35
+
10.1. Normative References .....................................35
+
10.2. Informative References ...................................35
+
Appendix A. Acknowledgements ......................................37
+
+
1. Introduction
+
+
This document defines the HTTP Cookie and Set-Cookie header fields.
+
Using the Set-Cookie header field, an HTTP server can pass name/value
+
pairs and associated metadata (called cookies) to a user agent. When
+
the user agent makes subsequent requests to the server, the user
+
agent uses the metadata and other information to determine whether to
+
return the name/value pairs in the Cookie header.
+
+
Although simple on their surface, cookies have a number of
+
complexities. For example, the server indicates a scope for each
+
cookie when sending it to the user agent. The scope indicates the
+
maximum amount of time in which the user agent should return the
+
cookie, the servers to which the user agent should return the cookie,
+
and the URI schemes for which the cookie is applicable.
+
+
For historical reasons, cookies contain a number of security and
+
privacy infelicities. For example, a server can indicate that a
+
given cookie is intended for "secure" connections, but the Secure
+
attribute does not provide integrity in the presence of an active
+
network attacker. Similarly, cookies for a given host are shared
+
across all the ports on that host, even though the usual "same-origin
+
policy" used by web browsers isolates content retrieved via different
+
ports.
+
+
There are two audiences for this specification: developers of cookie-
+
generating servers and developers of cookie-consuming user agents.
+
+
+
+
Barth Standards Track [Page 3]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
To maximize interoperability with user agents, servers SHOULD limit
+
themselves to the well-behaved profile defined in Section 4 when
+
generating cookies.
+
+
User agents MUST implement the more liberal processing rules defined
+
in Section 5, in order to maximize interoperability with existing
+
servers that do not conform to the well-behaved profile defined in
+
Section 4.
+
+
This document specifies the syntax and semantics of these headers as
+
they are actually used on the Internet. In particular, this document
+
does not create new syntax or semantics beyond those in use today.
+
The recommendations for cookie generation provided in Section 4
+
represent a preferred subset of current server behavior, and even the
+
more liberal cookie processing algorithm provided in Section 5 does
+
not recommend all of the syntactic and semantic variations in use
+
today. Where some existing software differs from the recommended
+
protocol in significant ways, the document contains a note explaining
+
the difference.
+
+
Prior to this document, there were at least three descriptions of
+
cookies: the so-called "Netscape cookie specification" [Netscape],
+
RFC 2109 [RFC2109], and RFC 2965 [RFC2965]. However, none of these
+
documents describe how the Cookie and Set-Cookie headers are actually
+
used on the Internet (see [Kri2001] for historical context). In
+
relation to previous IETF specifications of HTTP state management
+
mechanisms, this document requests the following actions:
+
+
1. Change the status of [RFC2109] to Historic (it has already been
+
obsoleted by [RFC2965]).
+
+
2. Change the status of [RFC2965] to Historic.
+
+
3. Indicate that [RFC2965] has been obsoleted by this document.
+
+
In particular, in moving RFC 2965 to Historic and obsoleting it, this
+
document deprecates the use of the Cookie2 and Set-Cookie2 header
+
fields.
+
+
2. Conventions
+
+
2.1. Conformance Criteria
+
+
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
+
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
+
document are to be interpreted as described in [RFC2119].
+
+
+
+
+
+
Barth Standards Track [Page 4]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Requirements phrased in the imperative as part of algorithms (such as
+
"strip any leading space characters" or "return false and abort these
+
steps") are to be interpreted with the meaning of the key word
+
("MUST", "SHOULD", "MAY", etc.) used in introducing the algorithm.
+
+
Conformance requirements phrased as algorithms or specific steps can
+
be implemented in any manner, so long as the end result is
+
equivalent. In particular, the algorithms defined in this
+
specification are intended to be easy to understand and are not
+
intended to be performant.
+
+
2.2. Syntax Notation
+
+
This specification uses the Augmented Backus-Naur Form (ABNF)
+
notation of [RFC5234].
+
+
The following core rules are included by reference, as defined in
+
[RFC5234], Appendix B.1: ALPHA (letters), CR (carriage return), CRLF
+
(CR LF), CTLs (controls), DIGIT (decimal 0-9), DQUOTE (double quote),
+
HEXDIG (hexadecimal 0-9/A-F/a-f), LF (line feed), NUL (null octet),
+
OCTET (any 8-bit sequence of data except NUL), SP (space), HTAB
+
(horizontal tab), CHAR (any [USASCII] character), VCHAR (any visible
+
[USASCII] character), and WSP (whitespace).
+
+
The OWS (optional whitespace) rule is used where zero or more linear
+
whitespace characters MAY appear:
+
+
OWS = *( [ obs-fold ] WSP )
+
; "optional" whitespace
+
obs-fold = CRLF
+
+
OWS SHOULD either not be produced or be produced as a single SP
+
character.
+
+
2.3. Terminology
+
+
The terms user agent, client, server, proxy, and origin server have
+
the same meaning as in the HTTP/1.1 specification ([RFC2616], Section
+
1.3).
+
+
The request-host is the name of the host, as known by the user agent,
+
to which the user agent is sending an HTTP request or from which it
+
is receiving an HTTP response (i.e., the name of the host to which it
+
sent the corresponding HTTP request).
+
+
The term request-uri is defined in Section 5.1.2 of [RFC2616].
+
+
+
+
+
+
Barth Standards Track [Page 5]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Two sequences of octets are said to case-insensitively match each
+
other if and only if they are equivalent under the i;ascii-casemap
+
collation defined in [RFC4790].
+
+
The term string means a sequence of non-NUL octets.
+
+
3. Overview
+
+
This section outlines a way for an origin server to send state
+
information to a user agent and for the user agent to return the
+
state information to the origin server.
+
+
To store state, the origin server includes a Set-Cookie header in an
+
HTTP response. In subsequent requests, the user agent returns a
+
Cookie request header to the origin server. The Cookie header
+
contains cookies the user agent received in previous Set-Cookie
+
headers. The origin server is free to ignore the Cookie header or
+
use its contents for an application-defined purpose.
+
+
Origin servers MAY send a Set-Cookie response header with any
+
response. User agents MAY ignore Set-Cookie headers contained in
+
responses with 100-level status codes but MUST process Set-Cookie
+
headers contained in other responses (including responses with 400-
+
and 500-level status codes). An origin server can include multiple
+
Set-Cookie header fields in a single response. The presence of a
+
Cookie or a Set-Cookie header field does not preclude HTTP caches
+
from storing and reusing a response.
+
+
Origin servers SHOULD NOT fold multiple Set-Cookie header fields into
+
a single header field. The usual mechanism for folding HTTP headers
+
fields (i.e., as defined in [RFC2616]) might change the semantics of
+
the Set-Cookie header field because the %x2C (",") character is used
+
by Set-Cookie in a way that conflicts with such folding.
+
+
3.1. Examples
+
+
Using the Set-Cookie header, a server can send the user agent a short
+
string in an HTTP response that the user agent will return in future
+
HTTP requests that are within the scope of the cookie. For example,
+
the server can send the user agent a "session identifier" named SID
+
with the value 31d4d96e407aad42. The user agent then returns the
+
session identifier in subsequent requests.
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 6]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
== Server -> User Agent ==
+
+
Set-Cookie: SID=31d4d96e407aad42
+
+
== User Agent -> Server ==
+
+
Cookie: SID=31d4d96e407aad42
+
+
The server can alter the default scope of the cookie using the Path
+
and Domain attributes. For example, the server can instruct the user
+
agent to return the cookie to every path and every subdomain of
+
example.com.
+
+
== Server -> User Agent ==
+
+
Set-Cookie: SID=31d4d96e407aad42; Path=/; Domain=example.com
+
+
== User Agent -> Server ==
+
+
Cookie: SID=31d4d96e407aad42
+
+
As shown in the next example, the server can store multiple cookies
+
at the user agent. For example, the server can store a session
+
identifier as well as the user's preferred language by returning two
+
Set-Cookie header fields. Notice that the server uses the Secure and
+
HttpOnly attributes to provide additional security protections for
+
the more sensitive session identifier (see Section 4.1.2.)
+
+
== Server -> User Agent ==
+
+
Set-Cookie: SID=31d4d96e407aad42; Path=/; Secure; HttpOnly
+
Set-Cookie: lang=en-US; Path=/; Domain=example.com
+
+
== User Agent -> Server ==
+
+
Cookie: SID=31d4d96e407aad42; lang=en-US
+
+
Notice that the Cookie header above contains two cookies, one named
+
SID and one named lang. If the server wishes the user agent to
+
persist the cookie over multiple "sessions" (e.g., user agent
+
restarts), the server can specify an expiration date in the Expires
+
attribute. Note that the user agent might delete the cookie before
+
the expiration date if the user agent's cookie store exceeds its
+
quota or if the user manually deletes the server's cookie.
+
+
+
+
+
+
+
+
Barth Standards Track [Page 7]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
== Server -> User Agent ==
+
+
Set-Cookie: lang=en-US; Expires=Wed, 09 Jun 2021 10:18:14 GMT
+
+
== User Agent -> Server ==
+
+
Cookie: SID=31d4d96e407aad42; lang=en-US
+
+
Finally, to remove a cookie, the server returns a Set-Cookie header
+
with an expiration date in the past. The server will be successful
+
in removing the cookie only if the Path and the Domain attribute in
+
the Set-Cookie header match the values used when the cookie was
+
created.
+
+
== Server -> User Agent ==
+
+
Set-Cookie: lang=; Expires=Sun, 06 Nov 1994 08:49:37 GMT
+
+
== User Agent -> Server ==
+
+
Cookie: SID=31d4d96e407aad42
+
+
4. Server Requirements
+
+
This section describes the syntax and semantics of a well-behaved
+
profile of the Cookie and Set-Cookie headers.
+
+
4.1. Set-Cookie
+
+
The Set-Cookie HTTP response header is used to send cookies from the
+
server to the user agent.
+
+
4.1.1. Syntax
+
+
Informally, the Set-Cookie response header contains the header name
+
"Set-Cookie" followed by a ":" and a cookie. Each cookie begins with
+
a name-value-pair, followed by zero or more attribute-value pairs.
+
Servers SHOULD NOT send Set-Cookie headers that fail to conform to
+
the following grammar:
+
+
+
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 8]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
set-cookie-header = "Set-Cookie:" SP set-cookie-string
+
set-cookie-string = cookie-pair *( ";" SP cookie-av )
+
cookie-pair = cookie-name "=" cookie-value
+
cookie-name = token
+
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
+
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
+
; US-ASCII characters excluding CTLs,
+
; whitespace DQUOTE, comma, semicolon,
+
; and backslash
+
token = <token, defined in [RFC2616], Section 2.2>
+
+
cookie-av = expires-av / max-age-av / domain-av /
+
path-av / secure-av / httponly-av /
+
extension-av
+
expires-av = "Expires=" sane-cookie-date
+
sane-cookie-date = <rfc1123-date, defined in [RFC2616], Section 3.3.1>
+
max-age-av = "Max-Age=" non-zero-digit *DIGIT
+
; In practice, both expires-av and max-age-av
+
; are limited to dates representable by the
+
; user agent.
+
non-zero-digit = %x31-39
+
; digits 1 through 9
+
domain-av = "Domain=" domain-value
+
domain-value = <subdomain>
+
; defined in [RFC1034], Section 3.5, as
+
; enhanced by [RFC1123], Section 2.1
+
path-av = "Path=" path-value
+
path-value = <any CHAR except CTLs or ";">
+
secure-av = "Secure"
+
httponly-av = "HttpOnly"
+
extension-av = <any CHAR except CTLs or ";">
+
+
Note that some of the grammatical terms above reference documents
+
that use different grammatical notations than this document (which
+
uses ABNF from [RFC5234]).
+
+
The semantics of the cookie-value are not defined by this document.
+
+
To maximize compatibility with user agents, servers that wish to
+
store arbitrary data in a cookie-value SHOULD encode that data, for
+
example, using Base64 [RFC4648].
+
+
The portions of the set-cookie-string produced by the cookie-av term
+
are known as attributes. To maximize compatibility with user agents,
+
servers SHOULD NOT produce two attributes with the same name in the
+
same set-cookie-string. (See Section 5.3 for how user agents handle
+
this case.)
+
+
+
+
+
Barth Standards Track [Page 9]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Servers SHOULD NOT include more than one Set-Cookie header field in
+
the same response with the same cookie-name. (See Section 5.2 for
+
how user agents handle this case.)
+
+
If a server sends multiple responses containing Set-Cookie headers
+
concurrently to the user agent (e.g., when communicating with the
+
user agent over multiple sockets), these responses create a "race
+
condition" that can lead to unpredictable behavior.
+
+
NOTE: Some existing user agents differ in their interpretation of
+
two-digit years. To avoid compatibility issues, servers SHOULD use
+
the rfc1123-date format, which requires a four-digit year.
+
+
NOTE: Some user agents store and process dates in cookies as 32-bit
+
UNIX time_t values. Implementation bugs in the libraries supporting
+
time_t processing on some systems might cause such user agents to
+
process dates after the year 2038 incorrectly.
+
+
4.1.2. Semantics (Non-Normative)
+
+
This section describes simplified semantics of the Set-Cookie header.
+
These semantics are detailed enough to be useful for understanding
+
the most common uses of cookies by servers. The full semantics are
+
described in Section 5.
+
+
When the user agent receives a Set-Cookie header, the user agent
+
stores the cookie together with its attributes. Subsequently, when
+
the user agent makes an HTTP request, the user agent includes the
+
applicable, non-expired cookies in the Cookie header.
+
+
If the user agent receives a new cookie with the same cookie-name,
+
domain-value, and path-value as a cookie that it has already stored,
+
the existing cookie is evicted and replaced with the new cookie.
+
Notice that servers can delete cookies by sending the user agent a
+
new cookie with an Expires attribute with a value in the past.
+
+
Unless the cookie's attributes indicate otherwise, the cookie is
+
returned only to the origin server (and not, for example, to any
+
subdomains), and it expires at the end of the current session (as
+
defined by the user agent). User agents ignore unrecognized cookie
+
attributes (but not the entire cookie).
+
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 10]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
4.1.2.1. The Expires Attribute
+
+
The Expires attribute indicates the maximum lifetime of the cookie,
+
represented as the date and time at which the cookie expires. The
+
user agent is not required to retain the cookie until the specified
+
date has passed. In fact, user agents often evict cookies due to
+
memory pressure or privacy concerns.
+
+
4.1.2.2. The Max-Age Attribute
+
+
The Max-Age attribute indicates the maximum lifetime of the cookie,
+
represented as the number of seconds until the cookie expires. The
+
user agent is not required to retain the cookie for the specified
+
duration. In fact, user agents often evict cookies due to memory
+
pressure or privacy concerns.
+
+
NOTE: Some existing user agents do not support the Max-Age
+
attribute. User agents that do not support the Max-Age attribute
+
ignore the attribute.
+
+
If a cookie has both the Max-Age and the Expires attribute, the Max-
+
Age attribute has precedence and controls the expiration date of the
+
cookie. If a cookie has neither the Max-Age nor the Expires
+
attribute, the user agent will retain the cookie until "the current
+
session is over" (as defined by the user agent).
+
+
4.1.2.3. The Domain Attribute
+
+
The Domain attribute specifies those hosts to which the cookie will
+
be sent. For example, if the value of the Domain attribute is
+
"example.com", the user agent will include the cookie in the Cookie
+
header when making HTTP requests to example.com, www.example.com, and
+
www.corp.example.com. (Note that a leading %x2E ("."), if present,
+
is ignored even though that character is not permitted, but a
+
trailing %x2E ("."), if present, will cause the user agent to ignore
+
the attribute.) If the server omits the Domain attribute, the user
+
agent will return the cookie only to the origin server.
+
+
WARNING: Some existing user agents treat an absent Domain
+
attribute as if the Domain attribute were present and contained
+
the current host name. For example, if example.com returns a Set-
+
Cookie header without a Domain attribute, these user agents will
+
erroneously send the cookie to www.example.com as well.
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 11]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
The user agent will reject cookies unless the Domain attribute
+
specifies a scope for the cookie that would include the origin
+
server. For example, the user agent will accept a cookie with a
+
Domain attribute of "example.com" or of "foo.example.com" from
+
foo.example.com, but the user agent will not accept a cookie with a
+
Domain attribute of "bar.example.com" or of "baz.foo.example.com".
+
+
NOTE: For security reasons, many user agents are configured to reject
+
Domain attributes that correspond to "public suffixes". For example,
+
some user agents will reject Domain attributes of "com" or "co.uk".
+
(See Section 5.3 for more information.)
+
+
4.1.2.4. The Path Attribute
+
+
The scope of each cookie is limited to a set of paths, controlled by
+
the Path attribute. If the server omits the Path attribute, the user
+
agent will use the "directory" of the request-uri's path component as
+
the default value. (See Section 5.1.4 for more details.)
+
+
The user agent will include the cookie in an HTTP request only if the
+
path portion of the request-uri matches (or is a subdirectory of) the
+
cookie's Path attribute, where the %x2F ("/") character is
+
interpreted as a directory separator.
+
+
Although seemingly useful for isolating cookies between different
+
paths within a given host, the Path attribute cannot be relied upon
+
for security (see Section 8).
+
+
4.1.2.5. The Secure Attribute
+
+
The Secure attribute limits the scope of the cookie to "secure"
+
channels (where "secure" is defined by the user agent). When a
+
cookie has the Secure attribute, the user agent will include the
+
cookie in an HTTP request only if the request is transmitted over a
+
secure channel (typically HTTP over Transport Layer Security (TLS)
+
[RFC2818]).
+
+
Although seemingly useful for protecting cookies from active network
+
attackers, the Secure attribute protects only the cookie's
+
confidentiality. An active network attacker can overwrite Secure
+
cookies from an insecure channel, disrupting their integrity (see
+
Section 8.6 for more details).
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 12]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
4.1.2.6. The HttpOnly Attribute
+
+
The HttpOnly attribute limits the scope of the cookie to HTTP
+
requests. In particular, the attribute instructs the user agent to
+
omit the cookie when providing access to cookies via "non-HTTP" APIs
+
(such as a web browser API that exposes cookies to scripts).
+
+
Note that the HttpOnly attribute is independent of the Secure
+
attribute: a cookie can have both the HttpOnly and the Secure
+
attribute.
+
+
4.2. Cookie
+
+
4.2.1. Syntax
+
+
The user agent sends stored cookies to the origin server in the
+
Cookie header. If the server conforms to the requirements in
+
Section 4.1 (and the user agent conforms to the requirements in
+
Section 5), the user agent will send a Cookie header that conforms to
+
the following grammar:
+
+
cookie-header = "Cookie:" OWS cookie-string OWS
+
cookie-string = cookie-pair *( ";" SP cookie-pair )
+
+
4.2.2. Semantics
+
+
Each cookie-pair represents a cookie stored by the user agent. The
+
cookie-pair contains the cookie-name and cookie-value the user agent
+
received in the Set-Cookie header.
+
+
Notice that the cookie attributes are not returned. In particular,
+
the server cannot determine from the Cookie header alone when a
+
cookie will expire, for which hosts the cookie is valid, for which
+
paths the cookie is valid, or whether the cookie was set with the
+
Secure or HttpOnly attributes.
+
+
The semantics of individual cookies in the Cookie header are not
+
defined by this document. Servers are expected to imbue these
+
cookies with application-specific semantics.
+
+
Although cookies are serialized linearly in the Cookie header,
+
servers SHOULD NOT rely upon the serialization order. In particular,
+
if the Cookie header contains two cookies with the same name (e.g.,
+
that were set with different Path or Domain attributes), servers
+
SHOULD NOT rely upon the order in which these cookies appear in the
+
header.
+
+
+
+
+
+
Barth Standards Track [Page 13]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
5. User Agent Requirements
+
+
This section specifies the Cookie and Set-Cookie headers in
+
sufficient detail that a user agent implementing these requirements
+
precisely can interoperate with existing servers (even those that do
+
not conform to the well-behaved profile described in Section 4).
+
+
A user agent could enforce more restrictions than those specified
+
herein (e.g., for the sake of improved security); however,
+
experiments have shown that such strictness reduces the likelihood
+
that a user agent will be able to interoperate with existing servers.
+
+
5.1. Subcomponent Algorithms
+
+
This section defines some algorithms used by user agents to process
+
specific subcomponents of the Cookie and Set-Cookie headers.
+
+
5.1.1. Dates
+
+
The user agent MUST use an algorithm equivalent to the following
+
algorithm to parse a cookie-date. Note that the various boolean
+
flags defined as a part of the algorithm (i.e., found-time, found-
+
day-of-month, found-month, found-year) are initially "not set".
+
+
1. Using the grammar below, divide the cookie-date into date-tokens.
+
+
cookie-date = *delimiter date-token-list *delimiter
+
date-token-list = date-token *( 1*delimiter date-token )
+
date-token = 1*non-delimiter
+
+
delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E
+
non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA / %x7F-FF
+
non-digit = %x00-2F / %x3A-FF
+
+
day-of-month = 1*2DIGIT ( non-digit *OCTET )
+
month = ( "jan" / "feb" / "mar" / "apr" /
+
"may" / "jun" / "jul" / "aug" /
+
"sep" / "oct" / "nov" / "dec" ) *OCTET
+
year = 2*4DIGIT ( non-digit *OCTET )
+
time = hms-time ( non-digit *OCTET )
+
hms-time = time-field ":" time-field ":" time-field
+
time-field = 1*2DIGIT
+
+
2. Process each date-token sequentially in the order the date-tokens
+
appear in the cookie-date:
+
+
+
+
+
+
+
Barth Standards Track [Page 14]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
1. If the found-time flag is not set and the token matches the
+
time production, set the found-time flag and set the hour-
+
value, minute-value, and second-value to the numbers denoted
+
by the digits in the date-token, respectively. Skip the
+
remaining sub-steps and continue to the next date-token.
+
+
2. If the found-day-of-month flag is not set and the date-token
+
matches the day-of-month production, set the found-day-of-
+
month flag and set the day-of-month-value to the number
+
denoted by the date-token. Skip the remaining sub-steps and
+
continue to the next date-token.
+
+
3. If the found-month flag is not set and the date-token matches
+
the month production, set the found-month flag and set the
+
month-value to the month denoted by the date-token. Skip the
+
remaining sub-steps and continue to the next date-token.
+
+
4. If the found-year flag is not set and the date-token matches
+
the year production, set the found-year flag and set the
+
year-value to the number denoted by the date-token. Skip the
+
remaining sub-steps and continue to the next date-token.
+
+
3. If the year-value is greater than or equal to 70 and less than or
+
equal to 99, increment the year-value by 1900.
+
+
4. If the year-value is greater than or equal to 0 and less than or
+
equal to 69, increment the year-value by 2000.
+
+
1. NOTE: Some existing user agents interpret two-digit years
+
differently.
+
+
5. Abort these steps and fail to parse the cookie-date if:
+
+
* at least one of the found-day-of-month, found-month, found-
+
year, or found-time flags is not set,
+
+
* the day-of-month-value is less than 1 or greater than 31,
+
+
* the year-value is less than 1601,
+
+
* the hour-value is greater than 23,
+
+
* the minute-value is greater than 59, or
+
+
* the second-value is greater than 59.
+
+
(Note that leap seconds cannot be represented in this syntax.)
+
+
+
+
+
Barth Standards Track [Page 15]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
6. Let the parsed-cookie-date be the date whose day-of-month, month,
+
year, hour, minute, and second (in UTC) are the day-of-month-
+
value, the month-value, the year-value, the hour-value, the
+
minute-value, and the second-value, respectively. If no such
+
date exists, abort these steps and fail to parse the cookie-date.
+
+
7. Return the parsed-cookie-date as the result of this algorithm.
+
+
5.1.2. Canonicalized Host Names
+
+
A canonicalized host name is the string generated by the following
+
algorithm:
+
+
1. Convert the host name to a sequence of individual domain name
+
labels.
+
+
2. Convert each label that is not a Non-Reserved LDH (NR-LDH) label,
+
to an A-label (see Section 2.3.2.1 of [RFC5890] for the former
+
and latter), or to a "punycode label" (a label resulting from the
+
"ToASCII" conversion in Section 4 of [RFC3490]), as appropriate
+
(see Section 6.3 of this specification).
+
+
3. Concatenate the resulting labels, separated by a %x2E (".")
+
character.
+
+
5.1.3. Domain Matching
+
+
A string domain-matches a given domain string if at least one of the
+
following conditions hold:
+
+
o The domain string and the string are identical. (Note that both
+
the domain string and the string will have been canonicalized to
+
lower case at this point.)
+
+
o All of the following conditions hold:
+
+
* The domain string is a suffix of the string.
+
+
* The last character of the string that is not included in the
+
domain string is a %x2E (".") character.
+
+
* The string is a host name (i.e., not an IP address).
+
+
5.1.4. Paths and Path-Match
+
+
The user agent MUST use an algorithm equivalent to the following
+
algorithm to compute the default-path of a cookie:
+
+
+
+
+
Barth Standards Track [Page 16]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
1. Let uri-path be the path portion of the request-uri if such a
+
portion exists (and empty otherwise). For example, if the
+
request-uri contains just a path (and optional query string),
+
then the uri-path is that path (without the %x3F ("?") character
+
or query string), and if the request-uri contains a full
+
absoluteURI, the uri-path is the path component of that URI.
+
+
2. If the uri-path is empty or if the first character of the uri-
+
path is not a %x2F ("/") character, output %x2F ("/") and skip
+
the remaining steps.
+
+
3. If the uri-path contains no more than one %x2F ("/") character,
+
output %x2F ("/") and skip the remaining step.
+
+
4. Output the characters of the uri-path from the first character up
+
to, but not including, the right-most %x2F ("/").
+
+
A request-path path-matches a given cookie-path if at least one of
+
the following conditions holds:
+
+
o The cookie-path and the request-path are identical.
+
+
o The cookie-path is a prefix of the request-path, and the last
+
character of the cookie-path is %x2F ("/").
+
+
o The cookie-path is a prefix of the request-path, and the first
+
character of the request-path that is not included in the cookie-
+
path is a %x2F ("/") character.
+
+
5.2. The Set-Cookie Header
+
+
When a user agent receives a Set-Cookie header field in an HTTP
+
response, the user agent MAY ignore the Set-Cookie header field in
+
its entirety. For example, the user agent might wish to block
+
responses to "third-party" requests from setting cookies (see
+
Section 7.1).
+
+
If the user agent does not ignore the Set-Cookie header field in its
+
entirety, the user agent MUST parse the field-value of the Set-Cookie
+
header field as a set-cookie-string (defined below).
+
+
NOTE: The algorithm below is more permissive than the grammar in
+
Section 4.1. For example, the algorithm strips leading and trailing
+
whitespace from the cookie name and value (but maintains internal
+
whitespace), whereas the grammar in Section 4.1 forbids whitespace in
+
these positions. User agents use this algorithm so as to
+
interoperate with servers that do not follow the recommendations in
+
Section 4.
+
+
+
+
Barth Standards Track [Page 17]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
A user agent MUST use an algorithm equivalent to the following
+
algorithm to parse a "set-cookie-string":
+
+
1. If the set-cookie-string contains a %x3B (";") character:
+
+
The name-value-pair string consists of the characters up to,
+
but not including, the first %x3B (";"), and the unparsed-
+
attributes consist of the remainder of the set-cookie-string
+
(including the %x3B (";") in question).
+
+
Otherwise:
+
+
The name-value-pair string consists of all the characters
+
contained in the set-cookie-string, and the unparsed-
+
attributes is the empty string.
+
+
2. If the name-value-pair string lacks a %x3D ("=") character,
+
ignore the set-cookie-string entirely.
+
+
3. The (possibly empty) name string consists of the characters up
+
to, but not including, the first %x3D ("=") character, and the
+
(possibly empty) value string consists of the characters after
+
the first %x3D ("=") character.
+
+
4. Remove any leading or trailing WSP characters from the name
+
string and the value string.
+
+
5. If the name string is empty, ignore the set-cookie-string
+
entirely.
+
+
6. The cookie-name is the name string, and the cookie-value is the
+
value string.
+
+
The user agent MUST use an algorithm equivalent to the following
+
algorithm to parse the unparsed-attributes:
+
+
1. If the unparsed-attributes string is empty, skip the rest of
+
these steps.
+
+
2. Discard the first character of the unparsed-attributes (which
+
will be a %x3B (";") character).
+
+
3. If the remaining unparsed-attributes contains a %x3B (";")
+
character:
+
+
Consume the characters of the unparsed-attributes up to, but
+
not including, the first %x3B (";") character.
+
+
+
+
+
Barth Standards Track [Page 18]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Otherwise:
+
+
Consume the remainder of the unparsed-attributes.
+
+
Let the cookie-av string be the characters consumed in this step.
+
+
4. If the cookie-av string contains a %x3D ("=") character:
+
+
The (possibly empty) attribute-name string consists of the
+
characters up to, but not including, the first %x3D ("=")
+
character, and the (possibly empty) attribute-value string
+
consists of the characters after the first %x3D ("=")
+
character.
+
+
Otherwise:
+
+
The attribute-name string consists of the entire cookie-av
+
string, and the attribute-value string is empty.
+
+
5. Remove any leading or trailing WSP characters from the attribute-
+
name string and the attribute-value string.
+
+
6. Process the attribute-name and attribute-value according to the
+
requirements in the following subsections. (Notice that
+
attributes with unrecognized attribute-names are ignored.)
+
+
7. Return to Step 1 of this algorithm.
+
+
When the user agent finishes parsing the set-cookie-string, the user
+
agent is said to "receive a cookie" from the request-uri with name
+
cookie-name, value cookie-value, and attributes cookie-attribute-
+
list. (See Section 5.3 for additional requirements triggered by
+
receiving a cookie.)
+
+
5.2.1. The Expires Attribute
+
+
If the attribute-name case-insensitively matches the string
+
"Expires", the user agent MUST process the cookie-av as follows.
+
+
Let the expiry-time be the result of parsing the attribute-value as
+
cookie-date (see Section 5.1.1).
+
+
If the attribute-value failed to parse as a cookie date, ignore the
+
cookie-av.
+
+
If the expiry-time is later than the last date the user agent can
+
represent, the user agent MAY replace the expiry-time with the last
+
representable date.
+
+
+
+
Barth Standards Track [Page 19]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
If the expiry-time is earlier than the earliest date the user agent
+
can represent, the user agent MAY replace the expiry-time with the
+
earliest representable date.
+
+
Append an attribute to the cookie-attribute-list with an attribute-
+
name of Expires and an attribute-value of expiry-time.
+
+
5.2.2. The Max-Age Attribute
+
+
If the attribute-name case-insensitively matches the string "Max-
+
Age", the user agent MUST process the cookie-av as follows.
+
+
If the first character of the attribute-value is not a DIGIT or a "-"
+
character, ignore the cookie-av.
+
+
If the remainder of attribute-value contains a non-DIGIT character,
+
ignore the cookie-av.
+
+
Let delta-seconds be the attribute-value converted to an integer.
+
+
If delta-seconds is less than or equal to zero (0), let expiry-time
+
be the earliest representable date and time. Otherwise, let the
+
expiry-time be the current date and time plus delta-seconds seconds.
+
+
Append an attribute to the cookie-attribute-list with an attribute-
+
name of Max-Age and an attribute-value of expiry-time.
+
+
5.2.3. The Domain Attribute
+
+
If the attribute-name case-insensitively matches the string "Domain",
+
the user agent MUST process the cookie-av as follows.
+
+
If the attribute-value is empty, the behavior is undefined. However,
+
the user agent SHOULD ignore the cookie-av entirely.
+
+
If the first character of the attribute-value string is %x2E ("."):
+
+
Let cookie-domain be the attribute-value without the leading %x2E
+
(".") character.
+
+
Otherwise:
+
+
Let cookie-domain be the entire attribute-value.
+
+
Convert the cookie-domain to lower case.
+
+
Append an attribute to the cookie-attribute-list with an attribute-
+
name of Domain and an attribute-value of cookie-domain.
+
+
+
+
Barth Standards Track [Page 20]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
5.2.4. The Path Attribute
+
+
If the attribute-name case-insensitively matches the string "Path",
+
the user agent MUST process the cookie-av as follows.
+
+
If the attribute-value is empty or if the first character of the
+
attribute-value is not %x2F ("/"):
+
+
Let cookie-path be the default-path.
+
+
Otherwise:
+
+
Let cookie-path be the attribute-value.
+
+
Append an attribute to the cookie-attribute-list with an attribute-
+
name of Path and an attribute-value of cookie-path.
+
+
5.2.5. The Secure Attribute
+
+
If the attribute-name case-insensitively matches the string "Secure",
+
the user agent MUST append an attribute to the cookie-attribute-list
+
with an attribute-name of Secure and an empty attribute-value.
+
+
5.2.6. The HttpOnly Attribute
+
+
If the attribute-name case-insensitively matches the string
+
"HttpOnly", the user agent MUST append an attribute to the cookie-
+
attribute-list with an attribute-name of HttpOnly and an empty
+
attribute-value.
+
+
5.3. Storage Model
+
+
The user agent stores the following fields about each cookie: name,
+
value, expiry-time, domain, path, creation-time, last-access-time,
+
persistent-flag, host-only-flag, secure-only-flag, and http-only-
+
flag.
+
+
When the user agent "receives a cookie" from a request-uri with name
+
cookie-name, value cookie-value, and attributes cookie-attribute-
+
list, the user agent MUST process the cookie as follows:
+
+
1. A user agent MAY ignore a received cookie in its entirety. For
+
example, the user agent might wish to block receiving cookies
+
from "third-party" responses or the user agent might not wish to
+
store cookies that exceed some size.
+
+
+
+
+
+
+
Barth Standards Track [Page 21]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
2. Create a new cookie with name cookie-name, value cookie-value.
+
Set the creation-time and the last-access-time to the current
+
date and time.
+
+
3. If the cookie-attribute-list contains an attribute with an
+
attribute-name of "Max-Age":
+
+
Set the cookie's persistent-flag to true.
+
+
Set the cookie's expiry-time to attribute-value of the last
+
attribute in the cookie-attribute-list with an attribute-name
+
of "Max-Age".
+
+
Otherwise, if the cookie-attribute-list contains an attribute
+
with an attribute-name of "Expires" (and does not contain an
+
attribute with an attribute-name of "Max-Age"):
+
+
Set the cookie's persistent-flag to true.
+
+
Set the cookie's expiry-time to attribute-value of the last
+
attribute in the cookie-attribute-list with an attribute-name
+
of "Expires".
+
+
Otherwise:
+
+
Set the cookie's persistent-flag to false.
+
+
Set the cookie's expiry-time to the latest representable
+
date.
+
+
4. If the cookie-attribute-list contains an attribute with an
+
attribute-name of "Domain":
+
+
Let the domain-attribute be the attribute-value of the last
+
attribute in the cookie-attribute-list with an attribute-name
+
of "Domain".
+
+
Otherwise:
+
+
Let the domain-attribute be the empty string.
+
+
5. If the user agent is configured to reject "public suffixes" and
+
the domain-attribute is a public suffix:
+
+
If the domain-attribute is identical to the canonicalized
+
request-host:
+
+
Let the domain-attribute be the empty string.
+
+
+
+
Barth Standards Track [Page 22]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Otherwise:
+
+
Ignore the cookie entirely and abort these steps.
+
+
NOTE: A "public suffix" is a domain that is controlled by a
+
public registry, such as "com", "co.uk", and "pvt.k12.wy.us".
+
This step is essential for preventing attacker.com from
+
disrupting the integrity of example.com by setting a cookie
+
with a Domain attribute of "com". Unfortunately, the set of
+
public suffixes (also known as "registry controlled domains")
+
changes over time. If feasible, user agents SHOULD use an
+
up-to-date public suffix list, such as the one maintained by
+
the Mozilla project at <http://publicsuffix.org/>.
+
+
6. If the domain-attribute is non-empty:
+
+
If the canonicalized request-host does not domain-match the
+
domain-attribute:
+
+
Ignore the cookie entirely and abort these steps.
+
+
Otherwise:
+
+
Set the cookie's host-only-flag to false.
+
+
Set the cookie's domain to the domain-attribute.
+
+
Otherwise:
+
+
Set the cookie's host-only-flag to true.
+
+
Set the cookie's domain to the canonicalized request-host.
+
+
7. If the cookie-attribute-list contains an attribute with an
+
attribute-name of "Path", set the cookie's path to attribute-
+
value of the last attribute in the cookie-attribute-list with an
+
attribute-name of "Path". Otherwise, set the cookie's path to
+
the default-path of the request-uri.
+
+
8. If the cookie-attribute-list contains an attribute with an
+
attribute-name of "Secure", set the cookie's secure-only-flag to
+
true. Otherwise, set the cookie's secure-only-flag to false.
+
+
9. If the cookie-attribute-list contains an attribute with an
+
attribute-name of "HttpOnly", set the cookie's http-only-flag to
+
true. Otherwise, set the cookie's http-only-flag to false.
+
+
+
+
+
+
Barth Standards Track [Page 23]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
10. If the cookie was received from a "non-HTTP" API and the
+
cookie's http-only-flag is set, abort these steps and ignore the
+
cookie entirely.
+
+
11. If the cookie store contains a cookie with the same name,
+
domain, and path as the newly created cookie:
+
+
1. Let old-cookie be the existing cookie with the same name,
+
domain, and path as the newly created cookie. (Notice that
+
this algorithm maintains the invariant that there is at most
+
one such cookie.)
+
+
2. If the newly created cookie was received from a "non-HTTP"
+
API and the old-cookie's http-only-flag is set, abort these
+
steps and ignore the newly created cookie entirely.
+
+
3. Update the creation-time of the newly created cookie to
+
match the creation-time of the old-cookie.
+
+
4. Remove the old-cookie from the cookie store.
+
+
12. Insert the newly created cookie into the cookie store.
+
+
A cookie is "expired" if the cookie has an expiry date in the past.
+
+
The user agent MUST evict all expired cookies from the cookie store
+
if, at any time, an expired cookie exists in the cookie store.
+
+
At any time, the user agent MAY "remove excess cookies" from the
+
cookie store if the number of cookies sharing a domain field exceeds
+
some implementation-defined upper bound (such as 50 cookies).
+
+
At any time, the user agent MAY "remove excess cookies" from the
+
cookie store if the cookie store exceeds some predetermined upper
+
bound (such as 3000 cookies).
+
+
When the user agent removes excess cookies from the cookie store, the
+
user agent MUST evict cookies in the following priority order:
+
+
1. Expired cookies.
+
+
2. Cookies that share a domain field with more than a predetermined
+
number of other cookies.
+
+
3. All cookies.
+
+
If two cookies have the same removal priority, the user agent MUST
+
evict the cookie with the earliest last-access date first.
+
+
+
+
Barth Standards Track [Page 24]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
When "the current session is over" (as defined by the user agent),
+
the user agent MUST remove from the cookie store all cookies with the
+
persistent-flag set to false.
+
+
5.4. The Cookie Header
+
+
The user agent includes stored cookies in the Cookie HTTP request
+
header.
+
+
When the user agent generates an HTTP request, the user agent MUST
+
NOT attach more than one Cookie header field.
+
+
A user agent MAY omit the Cookie header in its entirety. For
+
example, the user agent might wish to block sending cookies during
+
"third-party" requests from setting cookies (see Section 7.1).
+
+
If the user agent does attach a Cookie header field to an HTTP
+
request, the user agent MUST send the cookie-string (defined below)
+
as the value of the header field.
+
+
The user agent MUST use an algorithm equivalent to the following
+
algorithm to compute the "cookie-string" from a cookie store and a
+
request-uri:
+
+
1. Let cookie-list be the set of cookies from the cookie store that
+
meets all of the following requirements:
+
+
* Either:
+
+
The cookie's host-only-flag is true and the canonicalized
+
request-host is identical to the cookie's domain.
+
+
Or:
+
+
The cookie's host-only-flag is false and the canonicalized
+
request-host domain-matches the cookie's domain.
+
+
* The request-uri's path path-matches the cookie's path.
+
+
* If the cookie's secure-only-flag is true, then the request-
+
uri's scheme must denote a "secure" protocol (as defined by
+
the user agent).
+
+
NOTE: The notion of a "secure" protocol is not defined by
+
this document. Typically, user agents consider a protocol
+
secure if the protocol makes use of transport-layer
+
+
+
+
+
+
Barth Standards Track [Page 25]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
security, such as SSL or TLS. For example, most user
+
agents consider "https" to be a scheme that denotes a
+
secure protocol.
+
+
* If the cookie's http-only-flag is true, then exclude the
+
cookie if the cookie-string is being generated for a "non-
+
HTTP" API (as defined by the user agent).
+
+
2. The user agent SHOULD sort the cookie-list in the following
+
order:
+
+
* Cookies with longer paths are listed before cookies with
+
shorter paths.
+
+
* Among cookies that have equal-length path fields, cookies with
+
earlier creation-times are listed before cookies with later
+
creation-times.
+
+
NOTE: Not all user agents sort the cookie-list in this order, but
+
this order reflects common practice when this document was
+
written, and, historically, there have been servers that
+
(erroneously) depended on this order.
+
+
3. Update the last-access-time of each cookie in the cookie-list to
+
the current date and time.
+
+
4. Serialize the cookie-list into a cookie-string by processing each
+
cookie in the cookie-list in order:
+
+
1. Output the cookie's name, the %x3D ("=") character, and the
+
cookie's value.
+
+
2. If there is an unprocessed cookie in the cookie-list, output
+
the characters %x3B and %x20 ("; ").
+
+
NOTE: Despite its name, the cookie-string is actually a sequence of
+
octets, not a sequence of characters. To convert the cookie-string
+
(or components thereof) into a sequence of characters (e.g., for
+
presentation to the user), the user agent might wish to try using the
+
UTF-8 character encoding [RFC3629] to decode the octet sequence.
+
This decoding might fail, however, because not every sequence of
+
octets is valid UTF-8.
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 26]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
6. Implementation Considerations
+
+
6.1. Limits
+
+
Practical user agent implementations have limits on the number and
+
size of cookies that they can store. General-use user agents SHOULD
+
provide each of the following minimum capabilities:
+
+
o At least 4096 bytes per cookie (as measured by the sum of the
+
length of the cookie's name, value, and attributes).
+
+
o At least 50 cookies per domain.
+
+
o At least 3000 cookies total.
+
+
Servers SHOULD use as few and as small cookies as possible to avoid
+
reaching these implementation limits and to minimize network
+
bandwidth due to the Cookie header being included in every request.
+
+
Servers SHOULD gracefully degrade if the user agent fails to return
+
one or more cookies in the Cookie header because the user agent might
+
evict any cookie at any time on orders from the user.
+
+
6.2. Application Programming Interfaces
+
+
One reason the Cookie and Set-Cookie headers use such esoteric syntax
+
is that many platforms (both in servers and user agents) provide a
+
string-based application programming interface (API) to cookies,
+
requiring application-layer programmers to generate and parse the
+
syntax used by the Cookie and Set-Cookie headers, which many
+
programmers have done incorrectly, resulting in interoperability
+
problems.
+
+
Instead of providing string-based APIs to cookies, platforms would be
+
well-served by providing more semantic APIs. It is beyond the scope
+
of this document to recommend specific API designs, but there are
+
clear benefits to accepting an abstract "Date" object instead of a
+
serialized date string.
+
+
6.3. IDNA Dependency and Migration
+
+
IDNA2008 [RFC5890] supersedes IDNA2003 [RFC3490]. However, there are
+
differences between the two specifications, and thus there can be
+
differences in processing (e.g., converting) domain name labels that
+
have been registered under one from those registered under the other.
+
There will be a transition period of some time during which IDNA2003-
+
based domain name labels will exist in the wild. User agents SHOULD
+
implement IDNA2008 [RFC5890] and MAY implement [UTS46] or [RFC5895]
+
+
+
+
Barth Standards Track [Page 27]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
in order to facilitate their IDNA transition. If a user agent does
+
not implement IDNA2008, the user agent MUST implement IDNA2003
+
[RFC3490].
+
+
7. Privacy Considerations
+
+
Cookies are often criticized for letting servers track users. For
+
example, a number of "web analytics" companies use cookies to
+
recognize when a user returns to a web site or visits another web
+
site. Although cookies are not the only mechanism servers can use to
+
track users across HTTP requests, cookies facilitate tracking because
+
they are persistent across user agent sessions and can be shared
+
between hosts.
+
+
7.1. Third-Party Cookies
+
+
Particularly worrisome are so-called "third-party" cookies. In
+
rendering an HTML document, a user agent often requests resources
+
from other servers (such as advertising networks). These third-party
+
servers can use cookies to track the user even if the user never
+
visits the server directly. For example, if a user visits a site
+
that contains content from a third party and then later visits
+
another site that contains content from the same third party, the
+
third party can track the user between the two sites.
+
+
Some user agents restrict how third-party cookies behave. For
+
example, some of these user agents refuse to send the Cookie header
+
in third-party requests. Others refuse to process the Set-Cookie
+
header in responses to third-party requests. User agents vary widely
+
in their third-party cookie policies. This document grants user
+
agents wide latitude to experiment with third-party cookie policies
+
that balance the privacy and compatibility needs of their users.
+
However, this document does not endorse any particular third-party
+
cookie policy.
+
+
Third-party cookie blocking policies are often ineffective at
+
achieving their privacy goals if servers attempt to work around their
+
restrictions to track users. In particular, two collaborating
+
servers can often track users without using cookies at all by
+
injecting identifying information into dynamic URLs.
+
+
7.2. User Controls
+
+
User agents SHOULD provide users with a mechanism for managing the
+
cookies stored in the cookie store. For example, a user agent might
+
let users delete all cookies received during a specified time period
+
+
+
+
+
+
Barth Standards Track [Page 28]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
or all the cookies related to a particular domain. In addition, many
+
user agents include a user interface element that lets users examine
+
the cookies stored in their cookie store.
+
+
User agents SHOULD provide users with a mechanism for disabling
+
cookies. When cookies are disabled, the user agent MUST NOT include
+
a Cookie header in outbound HTTP requests and the user agent MUST NOT
+
process Set-Cookie headers in inbound HTTP responses.
+
+
Some user agents provide users the option of preventing persistent
+
storage of cookies across sessions. When configured thusly, user
+
agents MUST treat all received cookies as if the persistent-flag were
+
set to false. Some popular user agents expose this functionality via
+
"private browsing" mode [Aggarwal2010].
+
+
Some user agents provide users with the ability to approve individual
+
writes to the cookie store. In many common usage scenarios, these
+
controls generate a large number of prompts. However, some privacy-
+
conscious users find these controls useful nonetheless.
+
+
7.3. Expiration Dates
+
+
Although servers can set the expiration date for cookies to the
+
distant future, most user agents do not actually retain cookies for
+
multiple decades. Rather than choosing gratuitously long expiration
+
periods, servers SHOULD promote user privacy by selecting reasonable
+
cookie expiration periods based on the purpose of the cookie. For
+
example, a typical session identifier might reasonably be set to
+
expire in two weeks.
+
+
8. Security Considerations
+
+
8.1. Overview
+
+
Cookies have a number of security pitfalls. This section overviews a
+
few of the more salient issues.
+
+
In particular, cookies encourage developers to rely on ambient
+
authority for authentication, often becoming vulnerable to attacks
+
such as cross-site request forgery [CSRF]. Also, when storing
+
session identifiers in cookies, developers often create session
+
fixation vulnerabilities.
+
+
Transport-layer encryption, such as that employed in HTTPS, is
+
insufficient to prevent a network attacker from obtaining or altering
+
a victim's cookies because the cookie protocol itself has various
+
vulnerabilities (see "Weak Confidentiality" and "Weak Integrity",
+
+
+
+
+
Barth Standards Track [Page 29]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
below). In addition, by default, cookies do not provide
+
confidentiality or integrity from network attackers, even when used
+
in conjunction with HTTPS.
+
+
8.2. Ambient Authority
+
+
A server that uses cookies to authenticate users can suffer security
+
vulnerabilities because some user agents let remote parties issue
+
HTTP requests from the user agent (e.g., via HTTP redirects or HTML
+
forms). When issuing those requests, user agents attach cookies even
+
if the remote party does not know the contents of the cookies,
+
potentially letting the remote party exercise authority at an unwary
+
server.
+
+
Although this security concern goes by a number of names (e.g.,
+
cross-site request forgery, confused deputy), the issue stems from
+
cookies being a form of ambient authority. Cookies encourage server
+
operators to separate designation (in the form of URLs) from
+
authorization (in the form of cookies). Consequently, the user agent
+
might supply the authorization for a resource designated by the
+
attacker, possibly causing the server or its clients to undertake
+
actions designated by the attacker as though they were authorized by
+
the user.
+
+
Instead of using cookies for authorization, server operators might
+
wish to consider entangling designation and authorization by treating
+
URLs as capabilities. Instead of storing secrets in cookies, this
+
approach stores secrets in URLs, requiring the remote entity to
+
supply the secret itself. Although this approach is not a panacea,
+
judicious application of these principles can lead to more robust
+
security.
+
+
8.3. Clear Text
+
+
Unless sent over a secure channel (such as TLS), the information in
+
the Cookie and Set-Cookie headers is transmitted in the clear.
+
+
1. All sensitive information conveyed in these headers is exposed to
+
an eavesdropper.
+
+
2. A malicious intermediary could alter the headers as they travel
+
in either direction, with unpredictable results.
+
+
3. A malicious client could alter the Cookie header before
+
transmission, with unpredictable results.
+
+
+
+
+
+
+
Barth Standards Track [Page 30]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Servers SHOULD encrypt and sign the contents of cookies (using
+
whatever format the server desires) when transmitting them to the
+
user agent (even when sending the cookies over a secure channel).
+
However, encrypting and signing cookie contents does not prevent an
+
attacker from transplanting a cookie from one user agent to another
+
or from replaying the cookie at a later time.
+
+
In addition to encrypting and signing the contents of every cookie,
+
servers that require a higher level of security SHOULD use the Cookie
+
and Set-Cookie headers only over a secure channel. When using
+
cookies over a secure channel, servers SHOULD set the Secure
+
attribute (see Section 4.1.2.5) for every cookie. If a server does
+
not set the Secure attribute, the protection provided by the secure
+
channel will be largely moot.
+
+
For example, consider a webmail server that stores a session
+
identifier in a cookie and is typically accessed over HTTPS. If the
+
server does not set the Secure attribute on its cookies, an active
+
network attacker can intercept any outbound HTTP request from the
+
user agent and redirect that request to the webmail server over HTTP.
+
Even if the webmail server is not listening for HTTP connections, the
+
user agent will still include cookies in the request. The active
+
network attacker can intercept these cookies, replay them against the
+
server, and learn the contents of the user's email. If, instead, the
+
server had set the Secure attribute on its cookies, the user agent
+
would not have included the cookies in the clear-text request.
+
+
8.4. Session Identifiers
+
+
Instead of storing session information directly in a cookie (where it
+
might be exposed to or replayed by an attacker), servers commonly
+
store a nonce (or "session identifier") in a cookie. When the server
+
receives an HTTP request with a nonce, the server can look up state
+
information associated with the cookie using the nonce as a key.
+
+
Using session identifier cookies limits the damage an attacker can
+
cause if the attacker learns the contents of a cookie because the
+
nonce is useful only for interacting with the server (unlike non-
+
nonce cookie content, which might itself be sensitive). Furthermore,
+
using a single nonce prevents an attacker from "splicing" together
+
cookie content from two interactions with the server, which could
+
cause the server to behave unexpectedly.
+
+
Using session identifiers is not without risk. For example, the
+
server SHOULD take care to avoid "session fixation" vulnerabilities.
+
A session fixation attack proceeds in three steps. First, the
+
attacker transplants a session identifier from his or her user agent
+
to the victim's user agent. Second, the victim uses that session
+
+
+
+
Barth Standards Track [Page 31]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
identifier to interact with the server, possibly imbuing the session
+
identifier with the user's credentials or confidential information.
+
Third, the attacker uses the session identifier to interact with
+
server directly, possibly obtaining the user's authority or
+
confidential information.
+
+
8.5. Weak Confidentiality
+
+
Cookies do not provide isolation by port. If a cookie is readable by
+
a service running on one port, the cookie is also readable by a
+
service running on another port of the same server. If a cookie is
+
writable by a service on one port, the cookie is also writable by a
+
service running on another port of the same server. For this reason,
+
servers SHOULD NOT both run mutually distrusting services on
+
different ports of the same host and use cookies to store security-
+
sensitive information.
+
+
Cookies do not provide isolation by scheme. Although most commonly
+
used with the http and https schemes, the cookies for a given host
+
might also be available to other schemes, such as ftp and gopher.
+
Although this lack of isolation by scheme is most apparent in non-
+
HTTP APIs that permit access to cookies (e.g., HTML's document.cookie
+
API), the lack of isolation by scheme is actually present in
+
requirements for processing cookies themselves (e.g., consider
+
retrieving a URI with the gopher scheme via HTTP).
+
+
Cookies do not always provide isolation by path. Although the
+
network-level protocol does not send cookies stored for one path to
+
another, some user agents expose cookies via non-HTTP APIs, such as
+
HTML's document.cookie API. Because some of these user agents (e.g.,
+
web browsers) do not isolate resources received from different paths,
+
a resource retrieved from one path might be able to access cookies
+
stored for another path.
+
+
8.6. Weak Integrity
+
+
Cookies do not provide integrity guarantees for sibling domains (and
+
their subdomains). For example, consider foo.example.com and
+
bar.example.com. The foo.example.com server can set a cookie with a
+
Domain attribute of "example.com" (possibly overwriting an existing
+
"example.com" cookie set by bar.example.com), and the user agent will
+
include that cookie in HTTP requests to bar.example.com. In the
+
worst case, bar.example.com will be unable to distinguish this cookie
+
from a cookie it set itself. The foo.example.com server might be
+
able to leverage this ability to mount an attack against
+
bar.example.com.
+
+
+
+
+
+
Barth Standards Track [Page 32]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Even though the Set-Cookie header supports the Path attribute, the
+
Path attribute does not provide any integrity protection because the
+
user agent will accept an arbitrary Path attribute in a Set-Cookie
+
header. For example, an HTTP response to a request for
+
http://example.com/foo/bar can set a cookie with a Path attribute of
+
"/qux". Consequently, servers SHOULD NOT both run mutually
+
distrusting services on different paths of the same host and use
+
cookies to store security-sensitive information.
+
+
An active network attacker can also inject cookies into the Cookie
+
header sent to https://example.com/ by impersonating a response from
+
http://example.com/ and injecting a Set-Cookie header. The HTTPS
+
server at example.com will be unable to distinguish these cookies
+
from cookies that it set itself in an HTTPS response. An active
+
network attacker might be able to leverage this ability to mount an
+
attack against example.com even if example.com uses HTTPS
+
exclusively.
+
+
Servers can partially mitigate these attacks by encrypting and
+
signing the contents of their cookies. However, using cryptography
+
does not mitigate the issue completely because an attacker can replay
+
a cookie he or she received from the authentic example.com server in
+
the user's session, with unpredictable results.
+
+
Finally, an attacker might be able to force the user agent to delete
+
cookies by storing a large number of cookies. Once the user agent
+
reaches its storage limit, the user agent will be forced to evict
+
some cookies. Servers SHOULD NOT rely upon user agents retaining
+
cookies.
+
+
8.7. Reliance on DNS
+
+
Cookies rely upon the Domain Name System (DNS) for security. If the
+
DNS is partially or fully compromised, the cookie protocol might fail
+
to provide the security properties required by applications.
+
+
9. IANA Considerations
+
+
The permanent message header field registry (see [RFC3864]) has been
+
updated with the following registrations.
+
+
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 33]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
9.1. Cookie
+
+
Header field name: Cookie
+
+
Applicable protocol: http
+
+
Status: standard
+
+
Author/Change controller: IETF
+
+
Specification document: this specification (Section 5.4)
+
+
9.2. Set-Cookie
+
+
Header field name: Set-Cookie
+
+
Applicable protocol: http
+
+
Status: standard
+
+
Author/Change controller: IETF
+
+
Specification document: this specification (Section 5.2)
+
+
9.3. Cookie2
+
+
Header field name: Cookie2
+
+
Applicable protocol: http
+
+
Status: obsoleted
+
+
Author/Change controller: IETF
+
+
Specification document: [RFC2965]
+
+
9.4. Set-Cookie2
+
+
Header field name: Set-Cookie2
+
+
Applicable protocol: http
+
+
Status: obsoleted
+
+
Author/Change controller: IETF
+
+
Specification document: [RFC2965]
+
+
+
+
+
Barth Standards Track [Page 34]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
10. References
+
+
10.1. Normative References
+
+
[RFC1034] Mockapetris, P., "Domain names - concepts and facilities",
+
STD 13, RFC 1034, November 1987.
+
+
[RFC1123] Braden, R., "Requirements for Internet Hosts - Application
+
and Support", STD 3, RFC 1123, October 1989.
+
+
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
+
Requirement Levels", BCP 14, RFC 2119, March 1997.
+
+
[RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H.,
+
Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext
+
Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999.
+
+
[RFC3490] Faltstrom, P., Hoffman, P., and A. Costello,
+
"Internationalizing Domain Names in Applications (IDNA)",
+
RFC 3490, March 2003.
+
+
See Section 6.3 for an explanation why the normative
+
reference to an obsoleted specification is needed.
+
+
[RFC4790] Newman, C., Duerst, M., and A. Gulbrandsen, "Internet
+
Application Protocol Collation Registry", RFC 4790,
+
March 2007.
+
+
[RFC5234] Crocker, D., Ed. and P. Overell, "Augmented BNF for Syntax
+
Specifications: ABNF", STD 68, RFC 5234, January 2008.
+
+
[RFC5890] Klensin, J., "Internationalized Domain Names for
+
Applications (IDNA): Definitions and Document Framework",
+
RFC 5890, August 2010.
+
+
[USASCII] American National Standards Institute, "Coded Character
+
Set -- 7-bit American Standard Code for Information
+
Interchange", ANSI X3.4, 1986.
+
+
10.2. Informative References
+
+
[RFC2109] Kristol, D. and L. Montulli, "HTTP State Management
+
Mechanism", RFC 2109, February 1997.
+
+
[RFC2965] Kristol, D. and L. Montulli, "HTTP State Management
+
Mechanism", RFC 2965, October 2000.
+
+
+
+
+
+
Barth Standards Track [Page 35]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
[RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, May 2000.
+
+
[Netscape] Netscape Communications Corp., "Persistent Client State --
+
HTTP Cookies", 1999, <http://web.archive.org/web/
+
20020803110822/http://wp.netscape.com/newsref/std/
+
cookie_spec.html>.
+
+
[Kri2001] Kristol, D., "HTTP Cookies: Standards, Privacy, and
+
Politics", ACM Transactions on Internet Technology Vol. 1,
+
#2, November 2001, <http://arxiv.org/abs/cs.SE/0105018>.
+
+
[RFC3629] Yergeau, F., "UTF-8, a transformation format of ISO
+
10646", STD 63, RFC 3629, November 2003.
+
+
[RFC4648] Josefsson, S., "The Base16, Base32, and Base64 Data
+
Encodings", RFC 4648, October 2006.
+
+
[RFC3864] Klyne, G., Nottingham, M., and J. Mogul, "Registration
+
Procedures for Message Header Fields", BCP 90, RFC 3864,
+
September 2004.
+
+
[RFC5895] Resnick, P. and P. Hoffman, "Mapping Characters for
+
Internationalized Domain Names in Applications (IDNA)
+
2008", RFC 5895, September 2010.
+
+
[UTS46] Davis, M. and M. Suignard, "Unicode IDNA Compatibility
+
Processing", Unicode Technical Standards # 46, 2010,
+
<http://unicode.org/reports/tr46/>.
+
+
[CSRF] Barth, A., Jackson, C., and J. Mitchell, "Robust Defenses
+
for Cross-Site Request Forgery", 2008,
+
<http://portal.acm.org/citation.cfm?id=1455770.1455782>.
+
+
[Aggarwal2010]
+
Aggarwal, G., Burzstein, E., Jackson, C., and D. Boneh,
+
"An Analysis of Private Browsing Modes in Modern
+
Browsers", 2010, <http://www.usenix.org/events/sec10/tech/
+
full_papers/Aggarwal.pdf>.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 36]
+
+
RFC 6265 HTTP State Management Mechanism April 2011
+
+
+
Appendix A. Acknowledgements
+
+
This document borrows heavily from RFC 2109 [RFC2109]. We are
+
indebted to David M. Kristol and Lou Montulli for their efforts to
+
specify cookies. David M. Kristol, in particular, provided
+
invaluable advice on navigating the IETF process. We would also like
+
to thank Thomas Broyer, Tyler Close, Alissa Cooper, Bil Corry,
+
corvid, Lisa Dusseault, Roy T. Fielding, Blake Frantz, Anne van
+
Kesteren, Eran Hammer-Lahav, Jeff Hodges, Bjoern Hoehrmann, Achim
+
Hoffmann, Georg Koppen, Dean McNamee, Alexey Melnikov, Mark Miller,
+
Mark Pauley, Yngve N. Pettersen, Julian Reschke, Peter Saint-Andre,
+
Mark Seaborn, Maciej Stachowiak, Daniel Stenberg, Tatsuhiro
+
Tsujikawa, David Wagner, Dan Winship, and Dan Witte for their
+
valuable feedback on this document.
+
+
Author's Address
+
+
Adam Barth
+
University of California, Berkeley
+
+
EMail: abarth@eecs.berkeley.edu
+
URI: http://www.adambarth.com/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Barth Standards Track [Page 37]
+
+10 -1
test/dune
···
(test
(name test_cookeio)
-
(libraries cookeio alcotest eio eio.unix eio_main ptime)
+
(libraries
+
cookeio
+
cookeio_jar
+
alcotest
+
eio
+
eio.unix
+
eio_main
+
eio.mock
+
ptime
+
str)
(deps cookies.txt))
+3040 -45
test/test_cookeio.ml
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
open Cookeio
+
open Cookeio_jar
+
+
(* Testable helpers for Priority 2 types *)
+
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
+
Alcotest.testable Cookeio.Expiration.pp Cookeio.Expiration.equal
+
+
let span_testable : Ptime.Span.t Alcotest.testable =
+
Alcotest.testable Ptime.Span.pp Ptime.Span.equal
+
+
let same_site_testable : Cookeio.SameSite.t Alcotest.testable =
+
Alcotest.testable Cookeio.SameSite.pp Cookeio.SameSite.equal
let cookie_testable : Cookeio.t Alcotest.testable =
Alcotest.testable
(fun ppf c ->
Format.fprintf ppf
"{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \
-
expires=%a; same_site=%a }"
-
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c) (Cookeio.secure c) (Cookeio.http_only c)
-
(Format.pp_print_option Ptime.pp)
+
partitioned=%b; expires=%a; max_age=%a; same_site=%a }"
+
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
+
(Cookeio.secure c) (Cookeio.http_only c) (Cookeio.partitioned c)
+
(Format.pp_print_option (fun ppf e ->
+
match e with
+
| `Session -> Format.pp_print_string ppf "Session"
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t))
(Cookeio.expires c)
+
(Format.pp_print_option Ptime.Span.pp)
+
(Cookeio.max_age c)
(Format.pp_print_option (fun ppf -> function
| `Strict -> Format.pp_print_string ppf "Strict"
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None"))
(Cookeio.same_site c))
(fun c1 c2 ->
-
Cookeio.name c1 = Cookeio.name c2 && Cookeio.value c1 = Cookeio.value c2 && Cookeio.domain c1 = Cookeio.domain c2
-
&& Cookeio.path c1 = Cookeio.path c2 && Cookeio.secure c1 = Cookeio.secure c2
+
let expires_equal e1 e2 =
+
match (e1, e2) with
+
| None, None -> true
+
| Some `Session, Some `Session -> true
+
| Some (`DateTime t1), Some (`DateTime t2) -> Ptime.equal t1 t2
+
| _ -> false
+
in
+
Cookeio.name c1 = Cookeio.name c2
+
&& Cookeio.value c1 = Cookeio.value c2
+
&& Cookeio.domain c1 = Cookeio.domain c2
+
&& Cookeio.path c1 = Cookeio.path c2
+
&& Cookeio.secure c1 = Cookeio.secure c2
&& Cookeio.http_only c1 = Cookeio.http_only c2
-
&& Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
+
&& Cookeio.partitioned c1 = Cookeio.partitioned c2
+
&& expires_equal (Cookeio.expires c1) (Cookeio.expires c2)
+
&& Option.equal Ptime.Span.equal (Cookeio.max_age c1) (Cookeio.max_age c2)
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
let test_load_mozilla_cookies env =
···
(* Test cookie-1: session cookie on exact domain *)
let cookie1 = find_cookie "cookie-1" in
-
Alcotest.(check string) "cookie-1 domain" "example.com" (Cookeio.domain cookie1);
+
Alcotest.(check string)
+
"cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check string) "cookie-1 path" "/foo/" (Cookeio.path cookie1);
Alcotest.(check string) "cookie-1 name" "cookie-1" (Cookeio.name cookie1);
Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1);
Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1);
Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
Alcotest.(check (option expiration_testable))
"cookie-1 expires" None (Cookeio.expires cookie1);
Alcotest.(
check
···
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None")
( = ))))
-
"cookie-1 same_site" None (Cookeio.same_site cookie1);
+
"cookie-1 same_site" None
+
(Cookeio.same_site cookie1);
(* Test cookie-2: session cookie on subdomain pattern *)
let cookie2 = find_cookie "cookie-2" in
-
Alcotest.(check string) "cookie-2 domain" ".example.com" (Cookeio.domain cookie2);
+
Alcotest.(check string)
+
"cookie-2 domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check string) "cookie-2 path" "/foo/" (Cookeio.path cookie2);
Alcotest.(check string) "cookie-2 name" "cookie-2" (Cookeio.name cookie2);
Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2);
Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2);
Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
Alcotest.(check (option expiration_testable))
"cookie-2 expires" None (Cookeio.expires cookie2);
(* Test cookie-3: non-session cookie with expiry *)
let cookie3 = find_cookie "cookie-3" in
let expected_expiry = Ptime.of_float_s 1257894000.0 in
-
Alcotest.(check string) "cookie-3 domain" "example.com" (Cookeio.domain cookie3);
+
Alcotest.(check string)
+
"cookie-3 domain" "example.com" (Cookeio.domain cookie3);
Alcotest.(check string) "cookie-3 path" "/foo/" (Cookeio.path cookie3);
Alcotest.(check string) "cookie-3 name" "cookie-3" (Cookeio.name cookie3);
Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3);
Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3);
Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"cookie-3 expires" expected_expiry (Cookeio.expires cookie3);
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"cookie-3 expires"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie3)
+
| None -> Alcotest.fail "Expected expiry time for cookie-3"
+
end;
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
-
Alcotest.(check string) "cookie-4 domain" "example.com" (Cookeio.domain cookie4);
+
Alcotest.(check string)
+
"cookie-4 domain" "example.com" (Cookeio.domain cookie4);
Alcotest.(check string) "cookie-4 path" "/foo/" (Cookeio.path cookie4);
Alcotest.(check string) "cookie-4 name" "cookie-4" (Cookeio.name cookie4);
Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4);
Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4);
Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"cookie-4 expires" expected_expiry (Cookeio.expires cookie4);
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"cookie-4 expires"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie4)
+
| None -> Alcotest.fail "Expected expiry time for cookie-4"
+
end;
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
-
Alcotest.(check string) "cookie-5 domain" "example.com" (Cookeio.domain cookie5);
+
Alcotest.(check string)
+
"cookie-5 domain" "example.com" (Cookeio.domain cookie5);
Alcotest.(check string) "cookie-5 path" "/foo/" (Cookeio.path cookie5);
Alcotest.(check string) "cookie-5 name" "cookie-5" (Cookeio.name cookie5);
Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5);
Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"cookie-5 expires" expected_expiry (Cookeio.expires cookie5)
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"cookie-5 expires"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie5)
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
+
end
let test_load_from_file env =
(* This test loads from the actual test/cookies.txt file using the load function *)
···
(* Verify a few key cookies are loaded correctly *)
let cookie1 = find_cookie "cookie-1" in
Alcotest.(check string) "file cookie-1 value" "v$1" (Cookeio.value cookie1);
-
Alcotest.(check string) "file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
+
Alcotest.(check string)
+
"file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
Alcotest.(check (option expiration_testable))
"file cookie-1 expires" None (Cookeio.expires cookie1);
let cookie5 = find_cookie "cookie-5" in
Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5);
let expected_expiry = Ptime.of_float_s 1257894000.0 in
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"file cookie-5 expires" expected_expiry (Cookeio.expires cookie5);
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"file cookie-5 expires"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie5)
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
+
end;
(* Verify subdomain cookie *)
let cookie2 = find_cookie "cookie-2" in
-
Alcotest.(check string) "file cookie-2 domain" ".example.com" (Cookeio.domain cookie2);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
Alcotest.(check string)
+
"file cookie-2 domain" "example.com" (Cookeio.domain cookie2);
+
Alcotest.(check (option expiration_testable))
"file cookie-2 expires" None (Cookeio.expires cookie2)
let test_cookie_matching env =
···
(* Add test cookies with different domain patterns *)
let exact_cookie =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1"
-
~secure:false ~http_only:false ?expires:None ?same_site:None
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
in
let subdomain_cookie =
-
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"subdomain" ~value:"test2"
-
~secure:false ~http_only:false ?expires:None ?same_site:None
-
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"subdomain"
+
~value:"test2" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None ~creation_time:Ptime.epoch
+
~last_access:Ptime.epoch ()
in
let secure_cookie =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3"
-
~secure:true ~http_only:false ?expires:None ?same_site:None
+
~secure:true ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
in
···
add_cookie jar subdomain_cookie;
add_cookie jar secure_cookie;
-
(* Test exact domain matching *)
+
(* Test exact domain matching - all three cookies should match example.com *)
let cookies_http =
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
in
···
in
Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
-
(* Test subdomain matching *)
+
(* Test subdomain matching - all cookies should match subdomains now *)
let cookies_sub =
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
in
-
Alcotest.(check int) "subdomain cookies count" 1 (List.length cookies_sub);
-
let sub_cookie = List.hd cookies_sub in
-
Alcotest.(check string) "subdomain cookie name" "subdomain" (Cookeio.name sub_cookie)
+
Alcotest.(check int) "subdomain cookies count" 2 (List.length cookies_sub)
let test_empty_jar env =
let clock = Eio.Stdenv.clock env in
···
let jar = create () in
let test_cookie =
-
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test" ~value:"value"
-
~secure:true ~http_only:false ?expires:(Ptime.of_float_s 1257894000.0)
-
~same_site:`Strict ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
+
let expires =
+
match Ptime.of_float_s 1257894000.0 with
+
| Some t -> Some (`DateTime t)
+
| None -> None
+
in
+
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
+
~value:"value" ~secure:true ~http_only:false ?expires ~same_site:`Strict
+
?max_age:None ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
in
add_cookie jar test_cookie;
···
let cookie2 = List.hd cookies2 in
Alcotest.(check string) "round trip name" "test" (Cookeio.name cookie2);
Alcotest.(check string) "round trip value" "value" (Cookeio.value cookie2);
-
Alcotest.(check string) "round trip domain" "example.com" (Cookeio.domain cookie2);
+
Alcotest.(check string)
+
"round trip domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2);
Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2);
(* Note: http_only and same_site are lost in Mozilla format *)
+
begin match Ptime.of_float_s 1257894000.0 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"round trip expires"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie2)
+
| None -> Alcotest.fail "Expected expiry time"
+
end
+
+
let test_cookie_expiry_with_mock_clock () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at time 1000.0 for convenience *)
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add a cookie that expires at time 1500.0 (expires in 500 seconds) *)
+
let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
+
let cookie1 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
+
~value:"value1" ~secure:false ~http_only:false
+
~expires:(`DateTime expires_soon) ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
(* Add a cookie that expires at time 2000.0 (expires in 1000 seconds) *)
+
let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
+
let cookie2 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
+
~value:"value2" ~secure:false ~http_only:false
+
~expires:(`DateTime expires_later) ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
(* Add a session cookie (no expiry) *)
+
let cookie3 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
add_cookie jar cookie1;
+
add_cookie jar cookie2;
+
add_cookie jar cookie3;
+
+
Alcotest.(check int) "initial count" 3 (count jar);
+
+
(* Advance time to 1600.0 - first cookie should expire *)
+
Eio_mock.Clock.set_time clock 1600.0;
+
clear_expired jar ~clock;
+
+
Alcotest.(check int) "after first expiry" 2 (count jar);
+
+
let cookies = get_all_cookies jar in
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
+
Alcotest.(check (list string))
+
"remaining cookies after 1600s"
+
[ "expires_later"; "session" ]
+
names;
+
+
(* Advance time to 2100.0 - second cookie should expire *)
+
Eio_mock.Clock.set_time clock 2100.0;
+
clear_expired jar ~clock;
+
+
Alcotest.(check int) "after second expiry" 1 (count jar);
+
+
let remaining = get_all_cookies jar in
+
Alcotest.(check string)
+
"only session cookie remains" "session"
+
(Cookeio.name (List.hd remaining))
+
+
let test_get_cookies_filters_expired () =
+
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 an expired cookie (expired at time 500) *)
+
let expired = Ptime.of_float_s 500.0 |> Option.get in
+
let cookie_expired =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expired"
+
~value:"old" ~secure:false ~http_only:false
+
~expires:(`DateTime expired)
+
~creation_time:(Ptime.of_float_s 100.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 100.0 |> Option.get)
+
()
+
in
+
+
(* Add a valid cookie (expires at time 2000) *)
+
let valid_time = Ptime.of_float_s 2000.0 |> Option.get in
+
let cookie_valid =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"valid"
+
~value:"current" ~secure:false ~http_only:false
+
~expires:(`DateTime valid_time)
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
(* Add a session cookie (no expiry) *)
+
let cookie_session =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session"
+
~value:"sess" ~secure:false ~http_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_expired;
+
add_cookie jar cookie_valid;
+
add_cookie jar cookie_session;
+
+
(* get_all_cookies returns all including expired (for inspection) *)
+
Alcotest.(check int) "get_all_cookies includes expired" 3
+
(List.length (get_all_cookies jar));
+
+
(* get_cookies should automatically filter out expired cookies *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "get_cookies filters expired" 2 (List.length cookies);
+
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
+
Alcotest.(check (list string))
+
"only non-expired cookies returned"
+
[ "session"; "valid" ]
+
names
+
+
let test_max_age_parsing_with_mock_clock () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at a known time *)
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Parse a Set-Cookie header with Max-Age *)
+
let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie);
+
Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie);
+
Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie);
+
Alcotest.(check bool) "cookie http_only" true (Cookeio.http_only cookie);
+
+
(* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
+
let expected_expiry = Ptime.of_float_s 8600.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"expires set from max-age"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time"
+
end;
+
+
(* Verify creation time matches clock time *)
+
let expected_creation = Ptime.of_float_s 5000.0 in
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"round trip expires"
-
(Ptime.of_float_s 1257894000.0)
-
(Cookeio.expires cookie2)
+
"creation time" expected_creation
+
(Some (Cookeio.creation_time cookie))
+
+
let test_last_access_time_with_mock_clock () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at time 3000.0 *)
+
Eio_mock.Clock.set_time clock 3000.0;
+
+
let jar = create () in
+
+
(* Add a cookie *)
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 3000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 3000.0 |> Option.get)
+
()
+
in
+
add_cookie jar cookie;
+
+
(* Verify initial last access time *)
+
let cookies1 = get_all_cookies jar in
+
let cookie1 = List.hd cookies1 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"initial last access" (Ptime.of_float_s 3000.0)
+
(Some (Cookeio.last_access cookie1));
+
+
(* Advance time to 4000.0 *)
+
Eio_mock.Clock.set_time clock 4000.0;
+
+
(* Get cookies, which should update last access time to current clock time *)
+
let _retrieved =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
+
(* Verify last access time was updated to the new clock time *)
+
let cookies2 = get_all_cookies jar in
+
let cookie2 = List.hd cookies2 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"updated last access" (Ptime.of_float_s 4000.0)
+
(Some (Cookeio.last_access cookie2))
+
+
let test_of_set_cookie_header_with_expires () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at a known time *)
+
Eio_mock.Clock.set_time clock 6000.0;
+
+
(* Use RFC3339 format which is what Ptime.of_rfc3339 expects *)
+
let header =
+
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
+
in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie);
+
Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie);
+
Alcotest.(check string) "cookie domain" "example.com" (Cookeio.domain cookie);
+
Alcotest.(check string) "cookie path" "/" (Cookeio.path cookie);
+
+
(* Verify expires is parsed correctly *)
+
Alcotest.(check bool)
+
"has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the specific expiry time parsed from the RFC3339 date *)
+
let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
+
match expected_expiry with
+
| Ok (time, _, _) ->
+
Alcotest.(check (option expiration_testable))
+
"expires matches parsed value"
+
(Some (`DateTime time))
+
(Cookeio.expires cookie)
+
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
+
+
let test_samesite_none_validation () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at a known time *)
+
Eio_mock.Clock.set_time clock 7000.0;
+
+
(* This should be rejected: SameSite=None without Secure *)
+
let invalid_header = "token=abc; SameSite=None" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" invalid_header
+
in
+
+
Alcotest.(check bool)
+
"invalid cookie rejected" true
+
(Result.is_error cookie_opt);
+
+
(* This should be accepted: SameSite=None with Secure *)
+
let valid_header = "token=abc; SameSite=None; Secure" in
+
let cookie_opt2 =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" valid_header
+
in
+
+
Alcotest.(check bool)
+
"valid cookie accepted" true
+
(Result.is_ok cookie_opt2);
+
+
let cookie = Result.get_ok cookie_opt2 in
+
Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
+
Alcotest.(
+
check
+
(option
+
(Alcotest.testable
+
(fun ppf -> function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None")
+
( = ))))
+
"samesite is None" (Some `None) (Cookeio.same_site cookie)
+
+
let test_domain_normalization () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test parsing ".example.com" stores as "example.com" *)
+
let header = "test=value; Domain=.example.com" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check string)
+
"domain normalized" "example.com" (Cookeio.domain cookie);
+
+
(* Test round-trip through Mozilla format normalizes domains *)
+
let jar = create () in
+
let test_cookie =
+
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar test_cookie;
+
+
let mozilla_format = to_mozilla_format jar in
+
let jar2 = from_mozilla_format ~clock mozilla_format in
+
let cookies2 = get_all_cookies jar2 in
+
Alcotest.(check int) "one cookie" 1 (List.length cookies2);
+
Alcotest.(check string)
+
"domain normalized after round-trip" "example.com"
+
(Cookeio.domain (List.hd cookies2))
+
+
let test_max_age_stored_separately () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Parse a Set-Cookie header with Max-Age *)
+
let header = "session=abc123; Max-Age=3600" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
+
(* Verify max_age is stored as a Ptime.Span *)
+
Alcotest.(check bool)
+
"max_age is set" true
+
(Option.is_some (Cookeio.max_age cookie));
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
+
Alcotest.(check (option int))
+
"max_age is 3600 seconds" (Some 3600)
+
(Ptime.Span.to_int_s max_age_span);
+
+
(* Verify expires is also computed correctly *)
+
let expected_expiry = Ptime.of_float_s 8600.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"expires computed from max-age"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time"
+
end
+
+
let test_max_age_negative_becomes_zero () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Parse a Set-Cookie header with negative Max-Age *)
+
let header = "session=abc123; Max-Age=-100" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
+
(* Verify max_age is stored as 0 per RFC 6265 *)
+
Alcotest.(check bool)
+
"max_age is set" true
+
(Option.is_some (Cookeio.max_age cookie));
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
+
Alcotest.(check (option int))
+
"negative max_age becomes 0" (Some 0)
+
(Ptime.Span.to_int_s max_age_span);
+
+
(* Verify expires is computed with 0 seconds *)
+
let expected_expiry = Ptime.of_float_s 5000.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"expires computed with 0 seconds"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time"
+
end
+
+
let string_contains_substring s sub =
+
try
+
let len = String.length sub in
+
let rec search i =
+
if i + len > String.length s then false
+
else if String.sub s i len = sub then true
+
else search (i + 1)
+
in
+
search 0
+
with _ -> false
+
+
let test_make_set_cookie_header_includes_max_age () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Create a cookie with max_age *)
+
let max_age_span = Ptime.Span.of_int_s 3600 in
+
let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123"
+
~secure:true ~http_only:true
+
?expires:(Some (`DateTime expires_time))
+
?max_age:(Some max_age_span) ?same_site:(Some `Strict)
+
~creation_time:(Ptime.of_float_s 5000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 5000.0 |> Option.get)
+
()
+
in
+
+
let header = make_set_cookie_header cookie in
+
+
(* Verify the header includes Max-Age *)
+
Alcotest.(check bool)
+
"header includes Max-Age" true
+
(string_contains_substring header "Max-Age=3600");
+
+
(* Verify the header includes Expires *)
+
Alcotest.(check bool)
+
"header includes Expires" true
+
(string_contains_substring header "Expires=");
+
+
(* Verify the header includes other attributes *)
+
Alcotest.(check bool)
+
"header includes Secure" true
+
(string_contains_substring header "Secure");
+
Alcotest.(check bool)
+
"header includes HttpOnly" true
+
(string_contains_substring header "HttpOnly");
+
Alcotest.(check bool)
+
"header includes SameSite" true
+
(string_contains_substring header "SameSite=Strict")
+
+
let test_max_age_round_trip () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Parse a cookie with Max-Age *)
+
let header = "session=xyz; Max-Age=7200; Secure; HttpOnly" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
+
+
(* Generate Set-Cookie header from the cookie *)
+
let set_cookie_header = make_set_cookie_header cookie in
+
+
(* Parse it back *)
+
Eio_mock.Clock.set_time clock 5000.0;
+
(* Reset clock to same time *)
+
let cookie2_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" set_cookie_header
+
in
+
Alcotest.(check bool) "cookie re-parsed" true (Result.is_ok cookie2_opt);
+
let cookie2 = Result.get_ok cookie2_opt in
+
+
(* Verify max_age is preserved *)
+
Alcotest.(check (option int))
+
"max_age preserved"
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie)))
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie2)))
+
+
let test_domain_matching () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
+
let jar = create () in
+
+
(* Create a cookie with domain "example.com" *)
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 2000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 2000.0 |> Option.get)
+
()
+
in
+
add_cookie jar cookie;
+
+
(* Test "example.com" cookie matches "example.com" request *)
+
let cookies1 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "matches exact domain" 1 (List.length cookies1);
+
+
(* Test "example.com" cookie matches "sub.example.com" request *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "matches subdomain" 1 (List.length cookies2);
+
+
(* Test "example.com" cookie matches "deep.sub.example.com" request *)
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"deep.sub.example.com" ~path:"/"
+
~is_secure:false
+
in
+
Alcotest.(check int) "matches deep subdomain" 1 (List.length cookies3);
+
+
(* Test "example.com" cookie doesn't match "notexample.com" *)
+
let cookies4 =
+
get_cookies jar ~clock ~domain:"notexample.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "doesn't match different domain" 0 (List.length cookies4);
+
+
(* Test "example.com" cookie doesn't match "fakeexample.com" *)
+
let cookies5 =
+
get_cookies jar ~clock ~domain:"fakeexample.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "doesn't match prefix domain" 0 (List.length cookies5)
+
+
(** {1 HTTP Date Parsing Tests} *)
+
+
let test_http_date_fmt1 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
+
let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
"FMT1 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the parsed time matches expected value *)
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT1 expiry correct"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT1"
+
end
+
+
let test_http_date_fmt2 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *)
+
let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
"FMT2 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Year 15 should be normalized to 2015 *)
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT2 expiry correct with year normalization"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT2"
+
end
+
+
let test_http_date_fmt3 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
+
let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
"FMT3 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT3 expiry correct"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT3"
+
end
+
+
let test_http_date_fmt4 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
+
let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
"FMT4 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT4 expiry correct"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT4"
+
end
+
+
let test_abbreviated_year_69_to_99 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Year 95 should become 1995 *)
+
let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
let cookie = Result.get_ok cookie_opt in
+
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 95 becomes 1995"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for year 95"
+
end;
+
+
(* Year 69 should become 1969 *)
+
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
+
let cookie_opt2 =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header2
+
in
+
let cookie2 = Result.get_ok cookie_opt2 in
+
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
+
begin match expected2 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 69 becomes 1969"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie2)
+
| None -> Alcotest.fail "Expected expiry time for year 69"
+
end;
+
+
(* Year 99 should become 1999 *)
+
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
+
let cookie_opt3 =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header3
+
in
+
let cookie3 = Result.get_ok cookie_opt3 in
+
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
+
begin match expected3 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 99 becomes 1999"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie3)
+
| None -> Alcotest.fail "Expected expiry time for year 99"
+
end
+
+
let test_abbreviated_year_0_to_68 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Year 25 should become 2025 *)
+
let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
let cookie = Result.get_ok cookie_opt in
+
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 25 becomes 2025"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for year 25"
+
end;
+
+
(* Year 0 should become 2000 *)
+
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
+
let cookie_opt2 =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header2
+
in
+
let cookie2 = Result.get_ok cookie_opt2 in
+
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
+
begin match expected2 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 0 becomes 2000"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie2)
+
| None -> Alcotest.fail "Expected expiry time for year 0"
+
end;
+
+
(* Year 68 should become 2068 *)
+
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
+
let cookie_opt3 =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header3
+
in
+
let cookie3 = Result.get_ok cookie_opt3 in
+
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
+
begin match expected3 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 68 becomes 2068"
+
(Some (`DateTime t))
+
(Cookeio.expires cookie3)
+
| None -> Alcotest.fail "Expected expiry time for year 68"
+
end
+
+
let test_rfc3339_still_works () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Ensure RFC 3339 format still works for backward compatibility *)
+
let header = "session=abc; Expires=2025-10-21T07:28:00Z" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
"RFC 3339 cookie parsed" true
+
(Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
"RFC 3339 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the time was parsed correctly *)
+
let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
+
match expected with
+
| Ok (time, _, _) ->
+
Alcotest.(check (option expiration_testable))
+
"RFC 3339 expiry correct"
+
(Some (`DateTime time))
+
(Cookeio.expires cookie)
+
| Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
+
+
let test_invalid_date_format_logs_warning () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Invalid date format should log a warning but still parse the cookie *)
+
let header = "session=abc; Expires=InvalidDate" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
+
(* Cookie should still be parsed, just without expires *)
+
Alcotest.(check bool)
+
"cookie parsed despite invalid date" true
+
(Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
+
Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
+
(* expires should be None since date was invalid *)
+
Alcotest.(check (option expiration_testable))
+
"expires is None for invalid date" None (Cookeio.expires cookie)
+
+
let test_case_insensitive_month_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test various case combinations for month names *)
+
let test_cases =
+
[
+
("session=abc; Expires=Wed, 21 oct 2015 07:28:00 GMT", "lowercase month");
+
("session=abc; Expires=Wed, 21 OCT 2015 07:28:00 GMT", "uppercase month");
+
("session=abc; Expires=Wed, 21 OcT 2015 07:28:00 GMT", "mixed case month");
+
("session=abc; Expires=Wed, 21 oCt 2015 07:28:00 GMT", "weird case month");
+
]
+
in
+
+
List.iter
+
(fun (header, description) ->
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
(description ^ " parsed") true
+
(Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
(description ^ " has expiry")
+
true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the date was parsed correctly regardless of case *)
+
let expires = Option.get (Cookeio.expires cookie) in
+
match expires with
+
| `DateTime ptime ->
+
let year, month, _ = Ptime.to_date ptime in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
Alcotest.(check int)
+
(description ^ " month correct (October=10)")
+
10 month
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
+
test_cases
+
+
let test_case_insensitive_gmt_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test various case combinations for GMT timezone *)
+
let test_cases =
+
[
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "uppercase GMT");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 gmt", "lowercase gmt");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 Gmt", "mixed case Gmt");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GmT", "weird case GmT");
+
]
+
in
+
+
List.iter
+
(fun (header, description) ->
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
(description ^ " parsed") true
+
(Result.is_ok cookie_opt);
+
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool)
+
(description ^ " has expiry")
+
true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the date was parsed correctly regardless of GMT case *)
+
let expires = Option.get (Cookeio.expires cookie) in
+
match expires with
+
| `DateTime ptime ->
+
let year, month, day = Ptime.to_date ptime in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
Alcotest.(check int)
+
(description ^ " month correct (October=10)")
+
10 month;
+
Alcotest.(check int) (description ^ " day correct") 21 day
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
+
test_cases
+
+
(** {1 Delta Tracking Tests} *)
+
+
let test_add_original_not_in_delta () =
+
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:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar cookie;
+
+
(* Delta should be empty *)
+
let delta = delta jar in
+
Alcotest.(check int) "delta is empty" 0 (List.length delta);
+
+
(* But the cookie should be in the jar *)
+
Alcotest.(check int) "jar count is 1" 1 (count jar)
+
+
let test_add_cookie_appears_in_delta () =
+
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:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~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;
+
+
(* Delta should contain the cookie *)
+
let delta = delta jar in
+
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
+
let delta_cookie = List.hd delta in
+
Alcotest.(check string) "delta cookie name" "test" (Cookeio.name delta_cookie);
+
Alcotest.(check string)
+
"delta cookie value" "value"
+
(Cookeio.value delta_cookie)
+
+
let test_remove_original_creates_removal_cookie () =
+
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:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar cookie;
+
+
(* Remove the cookie *)
+
remove jar ~clock cookie;
+
+
(* Delta should contain a removal cookie *)
+
let delta = delta jar in
+
Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta);
+
let removal_cookie = List.hd delta in
+
Alcotest.(check string)
+
"removal cookie name" "test"
+
(Cookeio.name removal_cookie);
+
Alcotest.(check string)
+
"removal cookie has empty value" ""
+
(Cookeio.value removal_cookie);
+
+
(* Check Max-Age is 0 *)
+
match Cookeio.max_age removal_cookie with
+
| Some span ->
+
Alcotest.(check (option int))
+
"removal cookie Max-Age is 0" (Some 0) (Ptime.Span.to_int_s span)
+
| None -> Alcotest.fail "removal cookie should have Max-Age"
+
+
let test_remove_delta_cookie_removes_it () =
+
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:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~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;
+
+
(* Remove the cookie *)
+
remove jar ~clock cookie;
+
+
(* Delta should be empty *)
+
let delta = delta jar in
+
Alcotest.(check int)
+
"delta is empty after removing delta cookie" 0 (List.length delta)
+
+
let test_get_cookies_combines_original_and_delta () =
+
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 an original cookie *)
+
let original =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"original"
+
~value:"orig_val" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original;
+
+
(* Add a delta cookie *)
+
let delta_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"delta"
+
~value:"delta_val" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar delta_cookie;
+
+
(* Get cookies should return both *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "both cookies returned" 2 (List.length cookies);
+
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
+
Alcotest.(check (list string)) "cookie names" [ "delta"; "original" ] names
+
+
let test_get_cookies_delta_takes_precedence () =
+
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 an original cookie *)
+
let original =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"orig_val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original;
+
+
(* Add a delta cookie with the same name/domain/path *)
+
let delta_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"delta_val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar delta_cookie;
+
+
(* Get cookies should return only the delta cookie *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "only one cookie returned" 1 (List.length cookies);
+
let cookie = List.hd cookies in
+
Alcotest.(check string)
+
"delta cookie value" "delta_val" (Cookeio.value cookie)
+
+
let test_get_cookies_excludes_removal_cookies () =
+
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 an original cookie *)
+
let original =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original;
+
+
(* Remove it *)
+
remove jar ~clock original;
+
+
(* Get cookies should return nothing *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "no cookies returned" 0 (List.length cookies);
+
+
(* But delta should have the removal cookie *)
+
let delta = delta jar in
+
Alcotest.(check int) "delta has removal cookie" 1 (List.length delta)
+
+
let test_delta_returns_only_changed_cookies () =
+
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 original cookies *)
+
let original1 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig1" ~value:"val1"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original1;
+
+
let original2 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig2" ~value:"val2"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original2;
+
+
(* Add a new delta cookie *)
+
let new_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"new_val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar new_cookie;
+
+
(* Delta should only contain the new cookie *)
+
let delta = delta jar in
+
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
+
let delta_cookie = List.hd delta in
+
Alcotest.(check string) "delta cookie name" "new" (Cookeio.name delta_cookie)
+
+
let test_removal_cookie_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:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:true ~http_only:true ?expires:None ~same_site:`Strict
+
?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar cookie;
+
+
(* Remove the cookie *)
+
remove jar ~clock cookie;
+
+
(* Get the removal cookie *)
+
let delta = delta jar in
+
let removal = List.hd delta in
+
+
(* Check all properties *)
+
Alcotest.(check string)
+
"removal cookie has empty value" "" (Cookeio.value removal);
+
Alcotest.(check (option int))
+
"removal cookie Max-Age is 0" (Some 0)
+
(Option.bind (Cookeio.max_age removal) Ptime.Span.to_int_s);
+
+
(* Check expires is in the past *)
+
let now = Ptime.of_float_s 1000.0 |> Option.get in
+
match Cookeio.expires removal with
+
| Some (`DateTime exp) ->
+
Alcotest.(check bool)
+
"expires is in the past" true
+
(Ptime.compare exp now < 0)
+
| _ -> Alcotest.fail "removal cookie should have DateTime expires"
+
+
(* ============================================================================ *)
+
(* Priority 2 Tests *)
+
(* ============================================================================ *)
+
+
(* Priority 2.1: Partitioned Cookies *)
+
+
let test_partitioned_parsing env =
+
let clock = Eio.Stdenv.clock env in
+
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure"
+
with
+
| Ok c ->
+
Alcotest.(check bool) "partitioned flag" true (partitioned c);
+
Alcotest.(check bool) "secure flag" true (secure c)
+
| Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg)
+
+
let test_partitioned_serialization env =
+
let clock = Eio.Stdenv.clock env in
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
+
let cookie =
+
make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123" ~secure:true
+
~partitioned:true ~creation_time:now ~last_access:now ()
+
in
+
+
let header = make_set_cookie_header cookie in
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
let has_partitioned = contains_substring header "Partitioned" in
+
let has_secure = contains_substring header "Secure" in
+
Alcotest.(check bool) "contains Partitioned" true has_partitioned;
+
Alcotest.(check bool) "contains Secure" true has_secure
+
+
let test_partitioned_requires_secure env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Partitioned without Secure should be rejected *)
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"widget.com" ~path:"/" "id=123; Partitioned"
+
with
+
| Error _ -> () (* Expected *)
+
| Ok _ -> Alcotest.fail "Should reject Partitioned without Secure"
+
+
(* Priority 2.2: Expiration Variants *)
+
+
let test_expiration_variants env =
+
let clock = Eio.Stdenv.clock env in
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
let make_base ~name ?expires () =
+
make ~domain:"ex.com" ~path:"/" ~name ~value:"v" ?expires ~creation_time:now
+
~last_access:now ()
+
in
+
+
(* No expiration *)
+
let c1 = make_base ~name:"no_expiry" () in
+
Alcotest.(check (option expiration_testable))
+
"no expiration" None (expires c1);
+
+
(* Session cookie *)
+
let c2 = make_base ~name:"session" ~expires:`Session () in
+
Alcotest.(check (option expiration_testable))
+
"session cookie" (Some `Session) (expires c2);
+
+
(* Explicit expiration *)
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
+
let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in
+
match expires c3 with
+
| Some (`DateTime t) when Ptime.equal t future -> ()
+
| _ -> Alcotest.fail "Expected DateTime expiration"
+
+
let test_parse_session_expiration env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Expires=0 should parse as Session *)
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "id=123; Expires=0"
+
with
+
| Ok c ->
+
Alcotest.(check (option expiration_testable))
+
"expires=0 is session" (Some `Session) (expires c)
+
| Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg)
+
+
let test_serialize_expiration_variants env =
+
let clock = Eio.Stdenv.clock env in
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
+
(* Session cookie serialization *)
+
let c1 =
+
make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v" ~expires:`Session
+
~creation_time:now ~last_access:now ()
+
in
+
let h1 = make_set_cookie_header c1 in
+
let has_expires = contains_substring h1 "Expires=" in
+
Alcotest.(check bool) "session has Expires" true has_expires;
+
+
(* DateTime serialization *)
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
+
let c2 =
+
make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
+
~expires:(`DateTime future) ~creation_time:now ~last_access:now ()
+
in
+
let h2 = make_set_cookie_header c2 in
+
let has_expires2 = contains_substring h2 "Expires=" in
+
Alcotest.(check bool) "datetime has Expires" true has_expires2
+
+
(* Priority 2.3: Value Trimming *)
+
+
let test_quoted_cookie_values env =
+
let clock = Eio.Stdenv.clock env in
+
(* Test valid RFC 6265 cookie values:
+
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
+
Valid cases have either no quotes or properly paired DQUOTE wrapper *)
+
let valid_cases =
+
[
+
("name=value", "value", "value"); (* No quotes *)
+
("name=\"value\"", "\"value\"", "value"); (* Properly quoted *)
+
("name=\"\"", "\"\"", ""); (* Empty quoted value *)
+
]
+
in
+
+
List.iter
+
(fun (input, expected_raw, expected_trimmed) ->
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" input
+
with
+
| Ok c ->
+
Alcotest.(check string)
+
(Printf.sprintf "raw value for %s" input)
+
expected_raw (value c);
+
Alcotest.(check string)
+
(Printf.sprintf "trimmed value for %s" input)
+
expected_trimmed (value_trimmed c)
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ input ^ ": " ^ msg))
+
valid_cases;
+
+
(* Test invalid RFC 6265 cookie values are rejected *)
+
let invalid_cases =
+
[
+
"name=\"partial"; (* Opening quote without closing *)
+
"name=\"val\"\""; (* Embedded quote *)
+
"name=val\""; (* Trailing quote without opening *)
+
]
+
in
+
+
List.iter
+
(fun input ->
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" input
+
with
+
| Error _ -> () (* Expected - invalid values are rejected *)
+
| Ok _ ->
+
Alcotest.fail
+
(Printf.sprintf "Should reject invalid value: %s" input))
+
invalid_cases
+
+
let test_trimmed_value_not_used_for_equality env =
+
let clock = Eio.Stdenv.clock env in
+
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "name=\"value\""
+
with
+
| Ok c1 -> begin
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "name=value"
+
with
+
| Ok c2 ->
+
(* Different raw values *)
+
Alcotest.(check bool)
+
"different raw values" false
+
(value c1 = value c2);
+
(* Same trimmed values *)
+
Alcotest.(check string)
+
"same trimmed values" (value_trimmed c1) (value_trimmed c2)
+
| Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg)
+
end
+
| Error msg -> Alcotest.fail ("Parse failed for quoted: " ^ msg)
+
+
(* Priority 2.4: Cookie Header Parsing *)
+
+
let test_cookie_header_parsing_basic env =
+
let clock = Eio.Stdenv.clock env in
+
let result =
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
+
in
+
+
match result with
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
+
| Ok cookies ->
+
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
+
+
let find name_val = List.find (fun c -> name c = name_val) cookies in
+
Alcotest.(check string) "session value" "abc123" (value (find "session"));
+
Alcotest.(check string) "theme value" "dark" (value (find "theme"));
+
Alcotest.(check string) "lang value" "en" (value (find "lang"))
+
+
let test_cookie_header_defaults env =
+
let clock = Eio.Stdenv.clock env in
+
+
match
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/app" "session=xyz"
+
with
+
| Ok [ c ] ->
+
(* Domain and path from request context *)
+
Alcotest.(check string) "domain from context" "example.com" (domain c);
+
Alcotest.(check string) "path from context" "/app" (path c);
+
+
(* Security flags default to false *)
+
Alcotest.(check bool) "secure default" false (secure c);
+
Alcotest.(check bool) "http_only default" false (http_only c);
+
Alcotest.(check bool) "partitioned default" false (partitioned c);
+
+
(* Optional attributes default to None *)
+
Alcotest.(check (option expiration_testable))
+
"no expiration" None (expires c);
+
Alcotest.(check (option span_testable)) "no max_age" None (max_age c);
+
Alcotest.(check (option same_site_testable))
+
"no same_site" None (same_site c)
+
| Ok _ -> Alcotest.fail "Should parse single cookie"
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
+
+
let test_cookie_header_edge_cases env =
+
let clock = Eio.Stdenv.clock env in
+
+
let test input expected_count description =
+
let result =
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" input
+
in
+
match result with
+
| Ok cookies ->
+
Alcotest.(check int) description expected_count (List.length cookies)
+
| Error msg ->
+
Alcotest.fail (description ^ " failed: " ^ msg)
+
in
+
+
test "" 0 "empty string";
+
test ";;" 0 "only separators";
+
test "a=1;;b=2" 2 "double separator";
+
test " a=1 ; b=2 " 2 "excess whitespace";
+
test " " 0 "only whitespace"
+
+
let test_cookie_header_with_errors env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Invalid cookie (empty name) should cause entire parse to fail *)
+
let result =
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
+
in
+
+
(* Error should have descriptive message about the invalid cookie *)
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
match result with
+
| Error msg ->
+
let has_name = contains_substring msg "name" in
+
let has_empty = contains_substring msg "empty" in
+
Alcotest.(check bool)
+
"error mentions name or empty" true (has_name || has_empty)
+
| Ok _ -> Alcotest.fail "Expected error for empty cookie name"
+
+
(* Max-Age and Expires Interaction *)
+
+
let test_max_age_and_expires_both_present env =
+
let clock = Eio.Stdenv.clock env in
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
+
+
(* Create cookie with both *)
+
let cookie =
+
make ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val"
+
~max_age:(Ptime.Span.of_int_s 3600) ~expires:(`DateTime future)
+
~creation_time:now ~last_access:now ()
+
in
+
+
(* Both should be present *)
+
begin match max_age cookie with
+
| Some span -> begin
+
match Ptime.Span.to_int_s span with
+
| Some s ->
+
Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s)
+
| None -> Alcotest.fail "max_age span could not be converted to int"
+
end
+
| None -> Alcotest.fail "max_age should be present"
+
end;
+
+
begin match expires cookie with
+
| Some (`DateTime t) when Ptime.equal t future -> ()
+
| _ -> Alcotest.fail "expires should be present"
+
end;
+
+
(* Both should appear in serialization *)
+
let header = make_set_cookie_header cookie in
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
let has_max_age = contains_substring header "Max-Age=3600" in
+
let has_expires = contains_substring header "Expires=" in
+
Alcotest.(check bool) "contains Max-Age" true has_max_age;
+
Alcotest.(check bool) "contains Expires" true has_expires
+
+
let test_parse_max_age_and_expires env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Parse Set-Cookie with both attributes *)
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/"
+
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT"
+
with
+
| Ok c ->
+
(* Both should be stored *)
+
begin match max_age c with
+
| Some span -> begin
+
match Ptime.Span.to_int_s span with
+
| Some s ->
+
Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s)
+
| None -> Alcotest.fail "max_age span could not be converted to int"
+
end
+
| None -> Alcotest.fail "max_age should be parsed"
+
end;
+
+
begin match expires c with
+
| Some (`DateTime _) -> ()
+
| _ -> Alcotest.fail "expires should be parsed"
+
end
+
| Error msg -> Alcotest.fail ("Should parse cookie with both attributes: " ^ msg)
+
+
(* ============================================================================ *)
+
(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
+
(* ============================================================================ *)
+
+
let test_host_only_without_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie without Domain attribute should have host_only=true *)
+
let header = "session=abc123; Secure; HttpOnly" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
+
+
let test_host_only_with_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie with Domain attribute should have host_only=false *)
+
let header = "session=abc123; Domain=example.com; Secure" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
+
+
let test_host_only_with_dotted_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie with .domain should have host_only=false and normalized domain *)
+
let header = "session=abc123; Domain=.example.com" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
+
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
+
+
let test_host_only_domain_matching () =
+
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 host-only cookie (no Domain attribute) *)
+
let host_only_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1"
+
~secure:false ~http_only:false ~host_only:true
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar host_only_cookie;
+
+
(* Add a domain cookie (with Domain attribute) *)
+
let domain_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2"
+
~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 domain_cookie;
+
+
(* Both cookies should match exact domain *)
+
let cookies_exact =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact);
+
+
(* Only domain cookie should match subdomain *)
+
let cookies_sub =
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "only domain cookie matches subdomain" 1 (List.length cookies_sub);
+
let sub_cookie = List.hd cookies_sub in
+
Alcotest.(check string) "subdomain match is domain cookie" "domain" (Cookeio.name sub_cookie)
+
+
let test_host_only_cookie_header_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookies from Cookie header should have host_only=true *)
+
let result =
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" "session=abc; theme=dark"
+
in
+
match result with
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
+
| Ok cookies ->
+
Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
+
List.iter (fun c ->
+
Alcotest.(check bool)
+
("host_only is true for " ^ Cookeio.name c)
+
true (Cookeio.host_only c)
+
) cookies
+
+
let test_host_only_mozilla_format_round_trip () =
+
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 host-only cookie *)
+
let host_only =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1"
+
~secure:false ~http_only:false ~host_only:true
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar host_only;
+
+
(* Add domain cookie *)
+
let domain_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2"
+
~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 domain_cookie;
+
+
(* Round trip through Mozilla format *)
+
let mozilla = to_mozilla_format jar in
+
let jar2 = from_mozilla_format ~clock mozilla in
+
let cookies = get_all_cookies jar2 in
+
+
Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies);
+
+
let find name_val = List.find (fun c -> Cookeio.name c = name_val) cookies in
+
Alcotest.(check bool) "hostonly preserved" true (Cookeio.host_only (find "hostonly"));
+
Alcotest.(check bool) "domain preserved" false (Cookeio.host_only (find "domain"))
+
+
(* ============================================================================ *)
+
(* Path Matching Tests (RFC 6265 Section 5.1.4) *)
+
(* ============================================================================ *)
+
+
let test_path_matching_identical () =
+
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:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
+
~secure:false ~http_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;
+
+
(* Identical path should match *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
in
+
Alcotest.(check int) "identical path matches" 1 (List.length cookies)
+
+
let test_path_matching_with_trailing_slash () =
+
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:"example.com" ~path:"/foo/" ~name:"test" ~value:"val"
+
~secure:false ~http_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;
+
+
(* Cookie path /foo/ should match /foo/bar *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies);
+
+
(* Cookie path /foo/ should match /foo/ *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2)
+
+
let test_path_matching_prefix_with_slash () =
+
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:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
+
~secure:false ~http_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;
+
+
(* Cookie path /foo should match /foo/bar (next char is /) *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies);
+
+
(* Cookie path /foo should match /foo/ *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2)
+
+
let test_path_matching_no_false_prefix () =
+
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:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
+
~secure:false ~http_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;
+
+
(* Cookie path /foo should NOT match /foobar (no / separator) *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foobar" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies);
+
+
(* Cookie path /foo should NOT match /foob *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foob" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2)
+
+
let test_path_matching_root () =
+
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:"example.com" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_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;
+
+
(* Root path should match everything *)
+
let cookies1 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "/ matches /" 1 (List.length cookies1);
+
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
in
+
Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2);
+
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
+
in
+
Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3)
+
+
let test_path_matching_no_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:"example.com" ~path:"/foo/bar" ~name:"test" ~value:"val"
+
~secure:false ~http_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;
+
+
(* Cookie path /foo/bar should NOT match /foo *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies);
+
+
(* Cookie path /foo/bar should NOT match / *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2);
+
+
(* Cookie path /foo/bar should NOT match /baz *)
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/baz" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
+
+
(* ============================================================================ *)
+
(* Cookie Ordering Tests (RFC 6265 Section 5.4, Step 2) *)
+
(* ============================================================================ *)
+
+
let test_cookie_ordering_by_path_length () =
+
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 cookies with different path lengths, but same creation time *)
+
let cookie_short =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"short" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
let cookie_medium =
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"medium" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
let cookie_long =
+
Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"long" ~value:"v3"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
+
(* Add in random order *)
+
add_cookie jar cookie_short;
+
add_cookie jar cookie_long;
+
add_cookie jar cookie_medium;
+
+
(* Get cookies for path /foo/bar/baz - all three should match *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
+
in
+
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
+
+
(* Verify order: longest path first *)
+
let names = List.map Cookeio.name cookies in
+
Alcotest.(check (list string))
+
"cookies ordered by path length (longest first)"
+
[ "long"; "medium"; "short" ]
+
names
+
+
let test_cookie_ordering_by_creation_time () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
+
let jar = create () in
+
+
(* Add cookies with same path but different creation times *)
+
let cookie_new =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
+
in
+
let cookie_old =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"old" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
let cookie_middle =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"middle" ~value:"v3"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1200.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1200.0 |> Option.get) ()
+
in
+
+
(* Add in random order *)
+
add_cookie jar cookie_new;
+
add_cookie jar cookie_old;
+
add_cookie jar cookie_middle;
+
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
+
+
(* Verify order: earlier creation time first (for same path length) *)
+
let names = List.map Cookeio.name cookies in
+
Alcotest.(check (list string))
+
"cookies ordered by creation time (earliest first)"
+
[ "old"; "middle"; "new" ]
+
names
+
+
let test_cookie_ordering_combined () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
+
let jar = create () in
+
+
(* Mix of different paths and creation times *)
+
let cookie_a =
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"a" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
+
in
+
let cookie_b =
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"b" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
let cookie_c =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"c" ~value:"v3"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
+
in
+
+
add_cookie jar cookie_a;
+
add_cookie jar cookie_c;
+
add_cookie jar cookie_b;
+
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
in
+
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
+
+
(* /foo cookies (length 4) should come before / cookie (length 1)
+
Within /foo, earlier creation time (b=1000) should come before (a=1500) *)
+
let names = List.map Cookeio.name cookies in
+
Alcotest.(check (list string))
+
"cookies ordered by path length then creation time"
+
[ "b"; "a"; "c" ]
+
names
+
+
(* ============================================================================ *)
+
(* Creation Time Preservation Tests (RFC 6265 Section 5.3, Step 11.3) *)
+
(* ============================================================================ *)
+
+
let test_creation_time_preserved_on_update () =
+
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 initial cookie with creation_time=500 *)
+
let original_creation = Ptime.of_float_s 500.0 |> Option.get in
+
let cookie_v1 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:original_creation
+
~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
+
in
+
add_cookie jar cookie_v1;
+
+
(* Update the cookie with a new value (creation_time=1000) *)
+
Eio_mock.Clock.set_time clock 1500.0;
+
let cookie_v2 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
+
in
+
add_cookie jar cookie_v2;
+
+
(* Get the cookie and verify creation_time was preserved *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "still one cookie" 1 (List.length cookies);
+
+
let cookie = List.hd cookies in
+
Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
+
+
(* Creation time should be preserved from original cookie *)
+
let creation_float =
+
Ptime.to_float_s (Cookeio.creation_time cookie)
+
in
+
Alcotest.(check (float 0.001))
+
"creation_time preserved from original"
+
500.0 creation_float
+
+
let test_creation_time_preserved_add_original () =
+
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 initial original cookie *)
+
let original_creation = Ptime.of_float_s 100.0 |> Option.get in
+
let cookie_v1 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:original_creation
+
~last_access:(Ptime.of_float_s 100.0 |> Option.get) ()
+
in
+
add_original jar cookie_v1;
+
+
(* Replace with new original cookie *)
+
let cookie_v2 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_original jar cookie_v2;
+
+
let cookies = get_all_cookies jar in
+
Alcotest.(check int) "still one cookie" 1 (List.length cookies);
+
+
let cookie = List.hd cookies in
+
Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
+
+
(* Creation time should be preserved *)
+
let creation_float =
+
Ptime.to_float_s (Cookeio.creation_time cookie)
+
in
+
Alcotest.(check (float 0.001))
+
"creation_time preserved in add_original"
+
100.0 creation_float
+
+
let test_creation_time_new_cookie () =
+
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 new cookie (no existing cookie to preserve from) *)
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
+
~secure:false ~http_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;
+
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
let cookie = List.hd cookies in
+
+
(* New cookie should keep its own creation time *)
+
let creation_float =
+
Ptime.to_float_s (Cookeio.creation_time cookie)
+
in
+
Alcotest.(check (float 0.001))
+
"new cookie keeps its creation_time"
+
1000.0 creation_float
+
+
(* ============================================================================ *)
+
(* 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))
+
+
(* ============================================================================ *)
+
(* RFC 6265 Validation Tests *)
+
(* ============================================================================ *)
+
+
let test_validate_cookie_name_valid () =
+
(* Valid token characters per RFC 2616 *)
+
let valid_names = ["session"; "SID"; "my-cookie"; "COOKIE_123"; "abc.def"] in
+
List.iter (fun name ->
+
match Cookeio.Validate.cookie_name name with
+
| Ok _ -> ()
+
| Error msg ->
+
Alcotest.fail (Printf.sprintf "Name %S should be valid: %s" name msg))
+
valid_names
+
+
let test_validate_cookie_name_invalid () =
+
(* Invalid: control chars, separators, spaces *)
+
let invalid_names =
+
[
+
("", "empty");
+
("my cookie", "space");
+
("cookie=value", "equals");
+
("my;cookie", "semicolon");
+
("name\t", "tab");
+
("(cookie)", "parens");
+
("name,val", "comma");
+
]
+
in
+
List.iter (fun (name, reason) ->
+
match Cookeio.Validate.cookie_name name with
+
| Error _ -> () (* Expected *)
+
| Ok _ ->
+
Alcotest.fail
+
(Printf.sprintf "Name %S (%s) should be invalid" name reason))
+
invalid_names
+
+
let test_validate_cookie_value_valid () =
+
(* Valid cookie-octets or quoted values *)
+
let valid_values = ["abc123"; "value!#$%&'()*+-./"; "\"quoted\""; ""] in
+
List.iter (fun value ->
+
match Cookeio.Validate.cookie_value value with
+
| Ok _ -> ()
+
| Error msg ->
+
Alcotest.fail (Printf.sprintf "Value %S should be valid: %s" value msg))
+
valid_values
+
+
let test_validate_cookie_value_invalid () =
+
(* Invalid: space, comma, semicolon, backslash, unmatched quotes *)
+
let invalid_values =
+
[
+
("with space", "space");
+
("with,comma", "comma");
+
("with;semi", "semicolon");
+
("back\\slash", "backslash");
+
("\"unmatched", "unmatched opening quote");
+
("unmatched\"", "unmatched closing quote");
+
]
+
in
+
List.iter (fun (value, reason) ->
+
match Cookeio.Validate.cookie_value value with
+
| Error _ -> () (* Expected *)
+
| Ok _ ->
+
Alcotest.fail
+
(Printf.sprintf "Value %S (%s) should be invalid" value reason))
+
invalid_values
+
+
let test_validate_domain_valid () =
+
(* Valid domain names and IP addresses *)
+
let valid_domains =
+
["example.com"; "sub.example.com"; ".example.com"; "192.168.1.1"; "::1"]
+
in
+
List.iter (fun domain ->
+
match Cookeio.Validate.domain_value domain with
+
| Ok _ -> ()
+
| Error msg ->
+
Alcotest.fail (Printf.sprintf "Domain %S should be valid: %s" domain msg))
+
valid_domains
+
+
let test_validate_domain_invalid () =
+
(* Invalid domain names - only test cases that domain-name library rejects.
+
Note: domain-name library has specific rules that may differ from what
+
we might expect from the RFC. *)
+
let invalid_domains =
+
[
+
("", "empty");
+
(* Note: "-invalid.com" and "invalid-.com" are valid per domain-name library *)
+
]
+
in
+
List.iter (fun (domain, reason) ->
+
match Cookeio.Validate.domain_value domain with
+
| Error _ -> () (* Expected *)
+
| Ok _ ->
+
Alcotest.fail
+
(Printf.sprintf "Domain %S (%s) should be invalid" domain reason))
+
invalid_domains
+
+
let test_validate_path_valid () =
+
let valid_paths = ["/"; "/path"; "/path/to/resource"; "/path?query"] in
+
List.iter (fun path ->
+
match Cookeio.Validate.path_value path with
+
| Ok _ -> ()
+
| Error msg ->
+
Alcotest.fail (Printf.sprintf "Path %S should be valid: %s" path msg))
+
valid_paths
+
+
let test_validate_path_invalid () =
+
let invalid_paths =
+
[
+
("/path;bad", "semicolon");
+
("/path\x00bad", "control char");
+
]
+
in
+
List.iter (fun (path, reason) ->
+
match Cookeio.Validate.path_value path with
+
| Error _ -> () (* Expected *)
+
| Ok _ ->
+
Alcotest.fail
+
(Printf.sprintf "Path %S (%s) should be invalid" path reason))
+
invalid_paths
+
+
let test_duplicate_cookie_detection () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Duplicate cookie names should be rejected *)
+
let result =
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" "session=abc; theme=dark; session=xyz"
+
in
+
match result with
+
| Error msg ->
+
(* Should mention duplicate *)
+
let contains_dup = String.lowercase_ascii msg |> fun s ->
+
try let _ = Str.search_forward (Str.regexp_string "duplicate") s 0 in true
+
with Not_found -> false
+
in
+
Alcotest.(check bool) "error mentions duplicate" true contains_dup
+
| Ok _ -> Alcotest.fail "Should reject duplicate cookie names"
+
+
let test_validation_error_messages () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test that error messages are descriptive *)
+
let test_cases =
+
[
+
("=noname", "Cookie name is empty");
+
("bad cookie=value", "invalid characters");
+
("name=val ue", "invalid characters");
+
]
+
in
+
List.iter (fun (header, expected_substring) ->
+
match
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
with
+
| Error msg ->
+
let has_substring =
+
try
+
let _ = Str.search_forward
+
(Str.regexp_string expected_substring) msg 0 in
+
true
+
with Not_found -> false
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "error for %S mentions %S" header expected_substring)
+
true has_substring
+
| Ok _ ->
+
Alcotest.fail (Printf.sprintf "Should reject %S" header))
+
test_cases
+
+
(* ============================================================================ *)
+
(* Public Suffix Validation Tests (RFC 6265 Section 5.3, Step 5) *)
+
(* ============================================================================ *)
+
+
let test_public_suffix_rejection () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Setting a cookie for a public suffix (TLD) should be rejected *)
+
let test_cases =
+
[
+
(* (request_domain, cookie_domain, description) *)
+
("www.example.com", "com", "TLD .com");
+
("www.example.co.uk", "co.uk", "ccTLD .co.uk");
+
("foo.bar.github.io", "github.io", "private domain github.io");
+
]
+
in
+
+
List.iter
+
(fun (request_domain, cookie_domain, description) ->
+
let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
+
let result =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:request_domain ~path:"/" header
+
in
+
match result with
+
| Error msg ->
+
(* Should mention public suffix *)
+
let has_psl =
+
String.lowercase_ascii msg |> fun s ->
+
try
+
let _ = Str.search_forward (Str.regexp_string "public suffix") s 0 in
+
true
+
with Not_found -> false
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "%s: error mentions public suffix" description)
+
true has_psl
+
| Ok _ ->
+
Alcotest.fail
+
(Printf.sprintf "Should reject cookie for %s" description))
+
test_cases
+
+
let test_public_suffix_allowed_when_exact_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* If request host exactly matches the public suffix domain, allow it.
+
This is rare but possible for private domains like blogspot.com *)
+
let header = "session=abc; Domain=.blogspot.com" in
+
let result =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"blogspot.com" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
"exact match allows public suffix" true
+
(Result.is_ok result)
+
+
let test_non_public_suffix_allowed () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Normal domain (not a public suffix) should be allowed *)
+
let test_cases =
+
[
+
("www.example.com", "example.com", "registrable domain");
+
("sub.example.com", "example.com", "parent of subdomain");
+
("www.example.co.uk", "example.co.uk", "registrable domain under ccTLD");
+
]
+
in
+
+
List.iter
+
(fun (request_domain, cookie_domain, description) ->
+
let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
+
let result =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:request_domain ~path:"/" header
+
in
+
match result with
+
| Ok cookie ->
+
Alcotest.(check string)
+
(Printf.sprintf "%s: domain correct" description)
+
cookie_domain (Cookeio.domain cookie)
+
| Error msg ->
+
Alcotest.fail
+
(Printf.sprintf "%s should be allowed: %s" description msg))
+
test_cases
+
+
let test_public_suffix_no_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie without Domain attribute should always be allowed (host-only) *)
+
let header = "session=abc; Secure; HttpOnly" in
+
let result =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"www.example.com" ~path:"/" header
+
in
+
match result with
+
| Ok cookie ->
+
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
+
Alcotest.(check string)
+
"domain is request domain" "www.example.com"
+
(Cookeio.domain cookie)
+
| Error msg -> Alcotest.fail ("Should allow host-only cookie: " ^ msg)
+
+
let test_public_suffix_ip_address_bypass () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* IP addresses should bypass PSL check *)
+
let header = "session=abc; Domain=192.168.1.1" in
+
let result =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"192.168.1.1" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
"IP address bypasses PSL" true
+
(Result.is_ok result)
+
+
let test_public_suffix_case_insensitive () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Public suffix check should be case-insensitive *)
+
let header = "session=abc; Domain=.COM" in
+
let result =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"www.example.COM" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
"uppercase TLD still rejected" true
+
(Result.is_error result)
let () =
Eio_main.run @@ fun env ->
···
test_cookie_matching env);
] );
( "basic_operations",
-
[ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ]
-
);
+
[
+
test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env);
+
] );
+
( "time_handling",
+
[
+
test_case "Cookie expiry with mock clock" `Quick
+
test_cookie_expiry_with_mock_clock;
+
test_case "get_cookies filters expired cookies" `Quick
+
test_get_cookies_filters_expired;
+
test_case "Max-Age parsing with mock clock" `Quick
+
test_max_age_parsing_with_mock_clock;
+
test_case "Last access time with mock clock" `Quick
+
test_last_access_time_with_mock_clock;
+
test_case "Parse Set-Cookie with Expires" `Quick
+
test_of_set_cookie_header_with_expires;
+
test_case "SameSite=None validation" `Quick
+
test_samesite_none_validation;
+
] );
+
( "domain_normalization",
+
[
+
test_case "Domain normalization" `Quick test_domain_normalization;
+
test_case "Domain matching with normalized domains" `Quick
+
test_domain_matching;
+
] );
+
( "max_age_tracking",
+
[
+
test_case "Max-Age stored separately from Expires" `Quick
+
test_max_age_stored_separately;
+
test_case "Negative Max-Age becomes 0" `Quick
+
test_max_age_negative_becomes_zero;
+
test_case "make_set_cookie_header includes Max-Age" `Quick
+
test_make_set_cookie_header_includes_max_age;
+
test_case "Max-Age round-trip parsing" `Quick test_max_age_round_trip;
+
] );
+
( "delta_tracking",
+
[
+
test_case "add_original doesn't affect delta" `Quick
+
test_add_original_not_in_delta;
+
test_case "add_cookie appears in delta" `Quick
+
test_add_cookie_appears_in_delta;
+
test_case "remove original creates removal cookie" `Quick
+
test_remove_original_creates_removal_cookie;
+
test_case "remove delta cookie just removes it" `Quick
+
test_remove_delta_cookie_removes_it;
+
test_case "get_cookies combines original and delta" `Quick
+
test_get_cookies_combines_original_and_delta;
+
test_case "get_cookies delta takes precedence" `Quick
+
test_get_cookies_delta_takes_precedence;
+
test_case "get_cookies excludes removal cookies" `Quick
+
test_get_cookies_excludes_removal_cookies;
+
test_case "delta returns only changed cookies" `Quick
+
test_delta_returns_only_changed_cookies;
+
test_case "removal cookie format" `Quick test_removal_cookie_format;
+
] );
+
( "http_date_parsing",
+
[
+
test_case "HTTP date FMT1 (RFC 1123)" `Quick test_http_date_fmt1;
+
test_case "HTTP date FMT2 (RFC 850)" `Quick test_http_date_fmt2;
+
test_case "HTTP date FMT3 (asctime)" `Quick test_http_date_fmt3;
+
test_case "HTTP date FMT4 (variant)" `Quick test_http_date_fmt4;
+
test_case "Abbreviated year 69-99 becomes 1900+" `Quick
+
test_abbreviated_year_69_to_99;
+
test_case "Abbreviated year 0-68 becomes 2000+" `Quick
+
test_abbreviated_year_0_to_68;
+
test_case "RFC 3339 backward compatibility" `Quick
+
test_rfc3339_still_works;
+
test_case "Invalid date format logs warning" `Quick
+
test_invalid_date_format_logs_warning;
+
test_case "Case-insensitive month parsing" `Quick
+
test_case_insensitive_month_parsing;
+
test_case "Case-insensitive GMT parsing" `Quick
+
test_case_insensitive_gmt_parsing;
+
] );
+
( "partitioned",
+
[
+
test_case "parse partitioned cookie" `Quick (fun () ->
+
test_partitioned_parsing env);
+
test_case "serialize partitioned cookie" `Quick (fun () ->
+
test_partitioned_serialization env);
+
test_case "partitioned requires secure" `Quick (fun () ->
+
test_partitioned_requires_secure env);
+
] );
+
( "expiration",
+
[
+
test_case "expiration variants" `Quick (fun () ->
+
test_expiration_variants env);
+
test_case "parse session expiration" `Quick (fun () ->
+
test_parse_session_expiration env);
+
test_case "serialize expiration variants" `Quick (fun () ->
+
test_serialize_expiration_variants env);
+
] );
+
( "value_trimming",
+
[
+
test_case "quoted values" `Quick (fun () ->
+
test_quoted_cookie_values env);
+
test_case "trimmed not used for equality" `Quick (fun () ->
+
test_trimmed_value_not_used_for_equality env);
+
] );
+
( "cookie_header",
+
[
+
test_case "parse basic" `Quick (fun () ->
+
test_cookie_header_parsing_basic env);
+
test_case "default values" `Quick (fun () ->
+
test_cookie_header_defaults env);
+
test_case "edge cases" `Quick (fun () ->
+
test_cookie_header_edge_cases env);
+
test_case "multiple with errors" `Quick (fun () ->
+
test_cookie_header_with_errors env);
+
] );
+
( "max_age_expires_interaction",
+
[
+
test_case "both present" `Quick (fun () ->
+
test_max_age_and_expires_both_present env);
+
test_case "parse both" `Quick (fun () ->
+
test_parse_max_age_and_expires env);
+
] );
+
( "host_only_flag",
+
[
+
test_case "host_only without Domain attribute" `Quick
+
test_host_only_without_domain_attribute;
+
test_case "host_only with Domain attribute" `Quick
+
test_host_only_with_domain_attribute;
+
test_case "host_only with dotted Domain attribute" `Quick
+
test_host_only_with_dotted_domain_attribute;
+
test_case "host_only domain matching" `Quick
+
test_host_only_domain_matching;
+
test_case "host_only Cookie header parsing" `Quick
+
test_host_only_cookie_header_parsing;
+
test_case "host_only Mozilla format round trip" `Quick
+
test_host_only_mozilla_format_round_trip;
+
] );
+
( "path_matching",
+
[
+
test_case "identical path" `Quick test_path_matching_identical;
+
test_case "path with trailing slash" `Quick
+
test_path_matching_with_trailing_slash;
+
test_case "prefix with slash separator" `Quick
+
test_path_matching_prefix_with_slash;
+
test_case "no false prefix match" `Quick
+
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;
+
] );
+
( "rfc6265_validation",
+
[
+
test_case "valid cookie names" `Quick test_validate_cookie_name_valid;
+
test_case "invalid cookie names" `Quick test_validate_cookie_name_invalid;
+
test_case "valid cookie values" `Quick test_validate_cookie_value_valid;
+
test_case "invalid cookie values" `Quick test_validate_cookie_value_invalid;
+
test_case "valid domain values" `Quick test_validate_domain_valid;
+
test_case "invalid domain values" `Quick test_validate_domain_invalid;
+
test_case "valid path values" `Quick test_validate_path_valid;
+
test_case "invalid path values" `Quick test_validate_path_invalid;
+
test_case "duplicate cookie detection" `Quick test_duplicate_cookie_detection;
+
test_case "validation error messages" `Quick test_validation_error_messages;
+
] );
+
( "cookie_ordering",
+
[
+
test_case "ordering by path length" `Quick
+
test_cookie_ordering_by_path_length;
+
test_case "ordering by creation time" `Quick
+
test_cookie_ordering_by_creation_time;
+
test_case "ordering combined" `Quick test_cookie_ordering_combined;
+
] );
+
( "creation_time_preservation",
+
[
+
test_case "preserved on update" `Quick
+
test_creation_time_preserved_on_update;
+
test_case "preserved in add_original" `Quick
+
test_creation_time_preserved_add_original;
+
test_case "new cookie keeps time" `Quick test_creation_time_new_cookie;
+
] );
+
( "public_suffix_validation",
+
[
+
test_case "reject public suffix domains" `Quick
+
test_public_suffix_rejection;
+
test_case "allow exact match on public suffix" `Quick
+
test_public_suffix_allowed_when_exact_match;
+
test_case "allow non-public-suffix domains" `Quick
+
test_non_public_suffix_allowed;
+
test_case "no Domain attribute bypasses PSL" `Quick
+
test_public_suffix_no_domain_attribute;
+
test_case "IP address bypasses PSL" `Quick
+
test_public_suffix_ip_address_bypass;
+
test_case "case insensitive check" `Quick
+
test_public_suffix_case_insensitive;
+
] );
]