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/
+5 -1
.tangled/workflows/build.yml
···
- bzip2
- gcc
- ocaml
+
- pkg-config
steps:
- name: opam
command: |
-
opam init --disable-sandboxing -any
+
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
+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
+5
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
+5 -2
cookeio.opam
···
authors: ["Anil Madhavapeddy"]
license: "ISC"
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.20"}
"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)"]
+7 -5
dune-project
···
-
(lang dune 3.20)
+
(lang dune 3.18)
(name cookeio)
(generate_opam_files true)
-
(source (github avsm/cookeio))
-
(license ISC)
(authors "Anil Madhavapeddy")
(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
···
(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
(logs (>= 0.9.0))
(ptime (>= 1.1.0))
+
(ipaddr (>= 5.6.0))
+
(domain-name (>= 0.4.0))
+
publicsuffix
eio_main
-
(alcotest :with-test)))
+
(alcotest :with-test)
+
(odoc :with-doc)))
+578 -120
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 ]
···
| `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 ]
···
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;
···
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 last_access cookie = cookie.last_access
let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
-
?expires ?max_age ?same_site ?(partitioned = false) ~creation_time
-
~last_access () =
+
?expires ?max_age ?same_site ?(partitioned = false) ?(host_only = false)
+
~creation_time ~last_access () =
{
domain;
path;
···
secure;
http_only;
partitioned;
+
host_only;
expires;
max_age;
same_site;
···
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 =
-
(* Strip leading dot per RFC 6265 *)
match String.starts_with ~prefix:"." domain with
| true when String.length domain > 1 ->
String.sub domain 1 (String.length domain - 1)
| _ -> domain
-
(** {1 HTTP Date Parsing} *)
+
(** {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) *)
+
(** Month name to number mapping (case-insensitive).
+
+
Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1},
+
month tokens are matched case-insensitively.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *)
let month_of_string s =
match String.lowercase_ascii s with
| "jan" -> Some 1
···
| "dec" -> Some 12
| _ -> None
-
(** Normalize abbreviated years:
-
- Years 69-99 get 1900 added (e.g., 95 โ†’ 1995)
-
- Years 0-68 get 2000 added (e.g., 25 โ†’ 2025)
-
- Years >= 100 are returned as-is *)
+
(** 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
···
(** Parse HTTP date by trying all supported formats in sequence *)
let parse_http_date s =
-
match parse_fmt1 s with
-
| Some t -> Some t
-
| None -> (
-
match parse_fmt2 s with
-
| Some t -> Some t
-
| None -> (
-
match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
+
let ( <|> ) a b = match a with Some _ -> a | None -> b () in
+
parse_fmt1 s <|> fun () ->
+
parse_fmt2 s <|> fun () ->
+
parse_fmt3 s <|> fun () ->
+
parse_fmt4 s
end
(** {1 Cookie Parsing} *)
···
same_site = None;
}
-
(** Parse a single attribute and update the accumulator in-place *)
+
(** Parse a single cookie attribute and update the accumulator in-place.
+
+
Attribute parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2}
+
which defines the grammar and semantics for each cookie attribute.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
let parse_attribute now attrs attr_name attr_value =
let attr_lower = String.lowercase_ascii attr_name in
match attr_lower with
···
| _ ->
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
-
(** Validate cookie attributes and log warnings for invalid combinations *)
+
(** Validate cookie attributes and log warnings for invalid combinations.
+
+
Validates:
+
- SameSite=None requires the Secure flag (per RFC 6265bis)
+
- Partitioned requires the Secure flag (per CHIPS specification)
+
+
@see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - SameSite
+
@see <https://developer.chrome.com/docs/privacy-sandbox/chips/> CHIPS - Cookies Having Independent Partitioned State *)
let validate_attributes attrs =
-
(* SameSite=None requires Secure flag *)
-
let samesite_valid =
-
match attrs.same_site with
-
| Some `None when not attrs.secure ->
-
Log.warn (fun m ->
-
m
-
"Cookie has SameSite=None but Secure flag is not set; this \
-
violates RFC requirements");
-
false
-
| _ -> true
-
in
-
(* Partitioned requires Secure flag *)
-
let partitioned_valid =
-
if attrs.partitioned && not attrs.secure then (
+
match (attrs.same_site, attrs.secure, attrs.partitioned) with
+
| Some `None, false, _ ->
+
Log.warn (fun m ->
+
m
+
"Cookie has SameSite=None but Secure flag is not set; this \
+
violates RFC requirements");
+
false
+
| _, false, true ->
Log.warn (fun m ->
m
"Cookie has Partitioned attribute but Secure flag is not set; this \
violates CHIPS requirements");
-
false)
-
else true
-
in
-
samesite_valid && partitioned_valid
+
false
+
| _ -> true
-
(** Build final cookie from name/value and accumulated attributes *)
+
(** 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 domain =
-
normalize_domain (Option.value attrs.domain ~default:request_domain)
+
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 ~creation_time:now
-
~last_access:now ()
+
?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;@ expires=%a;@ max_age=%a;@ same_site=%a \
-
}@]"
+
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)
+
(http_only cookie) (partitioned cookie) (host_only cookie)
(Format.pp_print_option Expiration.pp)
(expires cookie)
(Format.pp_print_option Ptime.Span.pp)
···
(** {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);
···
let parts = String.split_on_char ';' header_value |> List.map String.trim in
match parts with
-
| [] -> None
+
| [] -> Error "Empty Set-Cookie header"
| name_value :: attrs -> (
(* Parse name=value *)
match String.index_opt name_value '=' with
-
| None -> None
-
| Some eq_pos ->
+
| 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.trim
in
-
let current_time = now () 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.
-
(* 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 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
-
parse_attribute now accumulated_attrs attr_name attr_value)
-
attrs;
+
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.
-
(* 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:current_time
-
in
-
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
-
Some cookie)
+
@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);
···
(* Filter out empty parts *)
let parts = List.filter (fun s -> String.length s > 0) parts in
-
(* Parse each name=value pair *)
-
List.map
-
(fun name_value ->
-
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
-
if String.length cookie_name = 0 then Error "Cookie has empty name"
-
else
-
let cookie_value =
-
String.sub name_value (eq_pos + 1)
-
(String.length name_value - eq_pos - 1)
-
|> String.trim
-
in
-
let current_time = now () in
-
(* Create cookie with defaults from Cookie header context *)
-
let cookie =
-
make ~domain ~path ~name:cookie_name ~value:cookie_value
-
~secure:false ~http_only:false ~partitioned:false
-
~creation_time:current_time ~last_access:current_time ()
-
in
-
Ok cookie)
-
parts
+
(* 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 *)
-
(match max_age cookie with
-
| Some span -> (
-
match Ptime.Span.to_int_s span with
-
| Some seconds ->
-
Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
-
| None -> ())
-
| None -> ());
+
Option.iter
+
(fun span ->
+
Option.iter
+
(fun seconds ->
+
Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds))
+
(Ptime.Span.to_int_s span))
+
(max_age cookie);
(* Add Expires if present *)
-
(match expires cookie with
-
| Some `Session ->
-
(* Session cookies can be indicated with Expires=0 or a past date *)
-
Buffer.add_string buffer "; Expires=0"
-
| Some (`DateTime exp_time) ->
-
(* Format as HTTP date *)
-
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
-
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
-
| None -> ());
+
Option.iter
+
(function
+
| `Session -> Buffer.add_string buffer "; Expires=0"
+
| `DateTime exp_time ->
+
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
+
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str))
+
(expires cookie);
(* Add Domain *)
Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
···
if partitioned cookie then Buffer.add_string buffer "; Partitioned";
(* Add SameSite *)
-
(match same_site cookie with
-
| Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
-
| Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
-
| Some `None -> Buffer.add_string buffer "; SameSite=None"
-
| None -> ());
+
Option.iter
+
(function
+
| `Strict -> Buffer.add_string buffer "; SameSite=Strict"
+
| `Lax -> Buffer.add_string buffer "; SameSite=Lax"
+
| `None -> Buffer.add_string buffer "; SameSite=None")
+
(same_site cookie);
Buffer.contents buffer
+371 -72
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 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.
+
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 jar implementation following
-
established web standards while integrating Eio for efficient asynchronous
-
operations.
+
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 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
+
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
-
- [samesite]: Cross-site request behavior control
+
({{: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:
-
- 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 *)
+
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) *)
+
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 *)
+
(** Equality function for same-site values. *)
val pp : Format.formatter -> t -> unit
-
(** Pretty printer for same-site values *)
+
(** Pretty printer for same-site values. *)
end
module Expiration : sig
type t = [ `Session | `DateTime of Ptime.t ]
(** Cookie expiration strategy.
-
- [`Session]: Session cookie that expires when browser session ends
-
- [`DateTime time]: Persistent cookie that expires at specific time *)
+
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 *)
+
(** Equality function for expiration values. *)
val pp : Format.formatter -> t -> unit
-
(** Pretty printer for expiration values *)
+
(** Pretty printer for expiration values. *)
end
type t
(** HTTP Cookie representation with all standard attributes.
A cookie represents a name-value pair with associated metadata that controls
-
its scope, security, and lifetime. Cookies with the same [name], [domain],
-
and [path] will overwrite each other when added to a cookie jar. *)
+
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 *)
+
(** 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 *)
+
(** 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 *)
+
(** Get the name of a cookie. *)
val value : t -> string
-
(** Get the value of a cookie *)
+
(** Get the value of a cookie. *)
val value_trimmed : t -> string
(** Get cookie value with surrounding double-quotes removed if they form a
···
Only removes quotes when both opening and closing quotes are present. The
raw value is always preserved in {!value}. This is useful for handling
-
quoted cookie values per RFC 6265.
+
quoted cookie values.
Examples:
- ["value"] โ†’ ["value"]
···
- ["\"val\"\""] โ†’ ["val\""] (removes outer pair only) *)
val secure : t -> bool
-
(** Check if cookie is secure only *)
+
(** 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 is HTTP only *)
+
(** 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. *)
+
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.
-
- [None]: No expiration specified (browser decides lifetime)
-
- [Some `Session]: Session cookie (expires when browser session ends)
+
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. *)
+
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.
-
Both [max_age] and [expires] can be present simultaneously. When both are
-
present in a Set-Cookie header, browsers prioritize [max_age] per RFC 6265.
-
This library stores both independently and serializes both when present. *)
+
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 *)
+
(** 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 *)
+
(** 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 *)
+
(** 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 ->
···
?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. *)
+
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 option
+
now:(unit -> Ptime.t) ->
+
domain:string ->
+
path:string ->
+
string ->
+
(t, string) result
(** Parse Set-Cookie response header value into a cookie.
-
Set-Cookie headers are sent from server to client and contain the cookie
-
name, value, and all attributes.
-
-
Parses a Set-Cookie header value following RFC specifications:
+
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 [None] if parsing fails or cookie validation fails
+
- 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
-
Cookie validation rules:
-
- [SameSite=None] requires the [Secure] flag to be set
-
- [Partitioned] requires the [Secure] flag to be set
+
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"] *)
+
{[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, string) result list
+
(t list, string) result
(** Parse Cookie request header containing semicolon-separated name=value pairs.
-
Cookie headers are sent from client to server and contain only name=value
-
pairs without attributes: ["name1=value1; name2=value2; name3=value3"]
+
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 a list of parse results, one per cookie. Parse errors for individual
-
cookies are returned as [Error msg] without failing the entire parse. Empty
-
values and excess whitespace are ignored.
+
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"] *)
+
{[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.
+
(** Create Cookie header value from cookies.
Formats a list of cookies into a Cookie header value suitable for HTTP
-
requests.
+
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
-
- More specific path mappings should be ordered first in the input list
Example: [make_cookie_header cookies] might return
-
["session=abc123; theme=dark"] *)
+
["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.
+
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, and SameSite. *)
+
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 *)
+
(** Pretty print a cookie. *)
+1 -1
lib/core/dune
···
(library
(name cookeio)
(public_name cookeio)
-
(libraries logs ptime))
+
(libraries logs ptime ipaddr domain-name publicsuffix))
+231 -66
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)
···
(** {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 =
-
(* Strip leading dot per RFC 6265 *)
match String.starts_with ~prefix:"." domain with
| true when String.length domain > 1 ->
String.sub domain 1 (String.length domain - 1)
| _ -> domain
-
let domain_matches cookie_domain request_domain =
-
(* Cookie domains are stored without leading dots per RFC 6265.
-
A cookie with domain "example.com" should match both "example.com" (exact)
-
and "sub.example.com" (subdomain). *)
+
(** 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
-
|| String.ends_with ~suffix:("." ^ cookie_domain) request_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 =
-
(* Cookie path /foo matches /foo, /foo/, /foo/bar *)
-
String.starts_with ~prefix: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.
-
(** {1 HTTP Date Parsing} *)
+
@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 *)
···
(** {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
···
(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
···
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
···
~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 =
···
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 ->
···
(* Combine original and delta cookies, with delta taking precedence *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
-
(* Filter out duplicates, keeping the last occurrence (from delta) *)
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
(* Keep this cookie only if no later cookie has the same identity *)
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique_cookies = dedup [] all_cookies in
+
let unique_cookies = dedup_by_identity all_cookies in
-
(* Filter for applicable cookies, excluding removal cookies (empty value) *)
+
(* Filter for applicable cookies, excluding removal cookies and expired cookies *)
let applicable =
List.filter
(fun cookie ->
Cookeio.value cookie <> ""
(* Exclude removal cookies *)
-
&& domain_matches (Cookeio.domain cookie) request_domain
+
&& (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
···
?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
···
Eio.Mutex.unlock jar.mutex;
-
Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
-
applicable
+
Log.debug (fun m -> m "Found %d applicable cookies" (List.length sorted));
+
sorted
let clear jar =
Log.info (fun m -> m "Clearing all cookies");
···
let count jar =
Eio.Mutex.lock jar.mutex;
-
(* Combine and deduplicate cookies for count *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
+
let unique = dedup_by_identity all_cookies in
let n = List.length unique in
Eio.Mutex.unlock jar.mutex;
n
let get_all_cookies jar =
Eio.Mutex.lock jar.mutex;
-
(* Combine and deduplicate, with delta taking precedence *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
+
let unique = dedup_by_identity all_cookies in
Eio.Mutex.unlock jar.mutex;
unique
···
(* Combine and deduplicate cookies *)
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
+
let unique = dedup_by_identity all_cookies in
List.iter
(fun cookie ->
-
let include_subdomains =
-
if String.starts_with ~prefix:"." (Cookeio.domain cookie) then "TRUE"
-
else "FALSE"
-
in
+
(* 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
···
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 ] ->
+
| [ 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
-
match Ptime.of_float_s (float_of_int exp_int) with
-
| Some t -> Some (`DateTime t)
-
| None -> None
+
match int_of_string_opt expires with
+
| Some exp_int when exp_int <> 0 ->
+
Option.map (fun t -> `DateTime t)
+
(Ptime.of_float_s (float_of_int exp_int))
+
| _ -> None
in
+
(* Mozilla format: include_subdomains=TRUE means host_only=false *)
+
let host_only = include_subdomains <> "TRUE" in
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
+
?max_age:None ?same_site:None ~partitioned:false ~host_only
~creation_time:now ~last_access:now ()
in
add_original jar cookie;
+131 -28
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
-
established web standards while integrating Eio for efficient asynchronous
-
operations.
+
{{: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
+
- 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 *)
+
- 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, 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 *)
+
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 *)
+
(** Create an empty cookie jar. *)
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t
(** Load cookies from Mozilla format file.
···
exist or cannot be loaded. *)
val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit
-
(** Save cookies to Mozilla format file *)
+
(** Save cookies to Mozilla format file. *)
(** {1 Cookie Jar Management} *)
···
The cookie is added to the delta, meaning it will appear in Set-Cookie
headers when calling {!delta}. If a cookie with the same name/domain/path
-
exists in the delta, it will be replaced. *)
+
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. *)
+
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}. *)
+
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. *)
+
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 ->
···
Cookeio.t list
(** Get cookies applicable for a URL.
-
Returns all cookies that match the given domain and path, and satisfy the
-
secure flag requirement. Combines original and delta cookies, with delta
-
taking precedence. Excludes removal cookies (empty value). Also updates the
-
last access time of matching cookies using the provided clock. *)
+
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 *)
+
(** Clear all cookies. *)
val clear_expired : t -> clock:_ Eio.Time.clock -> unit
-
(** Clear expired cookies *)
+
(** 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 (those without expiry) *)
+
(** 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 cookies in the jar *)
+
(** Get the number of unique cookies in the jar. *)
val get_all_cookies : t -> Cookeio.t list
-
(** Get all cookies in the jar *)
+
(** 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 *)
+
(** Check if the jar is empty. *)
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
-
(** Pretty print a cookie jar *)
+
(** Pretty print a cookie jar. *)
(** {1 Mozilla Format} *)
val to_mozilla_format : t -> string
-
(** Write cookies in Mozilla format *)
+
(** 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. *)
+
provided clock to set creation and last access times. The [include_subdomains]
+
field is mapped to the host-only-flag per
+
{{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
+1 -1
lib/jar/dune
···
(library
(name cookeio_jar)
(public_name cookeio.jar)
-
(libraries cookeio eio logs ptime unix))
+
(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]
+
+1324 -87
test/test_cookeio.ml
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
open Cookeio
open Cookeio_jar
···
"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
···
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
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);
···
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
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 bool)
"invalid cookie rejected" true
-
(Option.is_none cookie_opt);
+
(Result.is_error cookie_opt);
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
···
Alcotest.(check bool)
"valid cookie accepted" true
-
(Option.is_some cookie_opt2);
+
(Result.is_ok cookie_opt2);
-
let cookie = Option.get cookie_opt2 in
+
let cookie = Result.get_ok cookie_opt2 in
Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
Alcotest.(
check
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
-
let cookie = Option.get cookie_opt 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);
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
(* Verify max_age is stored as a Ptime.Span *)
Alcotest.(check bool)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
(* Verify max_age is stored as 0 per RFC 6265 *)
Alcotest.(check bool)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
-
let cookie = Option.get cookie_opt 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
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" set_cookie_header
in
-
Alcotest.(check bool) "cookie re-parsed" true (Option.is_some cookie2_opt);
-
let cookie2 = Option.get cookie2_opt 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))
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "FMT1 cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
"FMT1 has expiry" true
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "FMT2 cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
"FMT2 has expiry" true
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "FMT3 cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
"FMT3 has expiry" true
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
Alcotest.(check bool) "FMT4 cookie parsed" true (Option.is_some cookie_opt);
+
Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
"FMT4 has expiry" true
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
let cookie = Option.get cookie_opt 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 ->
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header2
in
-
let cookie2 = Option.get cookie_opt2 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 ->
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header3
in
-
let cookie3 = Option.get cookie_opt3 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 ->
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
in
-
let cookie = Option.get cookie_opt 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 ->
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header2
in
-
let cookie2 = Option.get cookie_opt2 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 ->
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header3
in
-
let cookie3 = Option.get cookie_opt3 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 ->
···
in
Alcotest.(check bool)
"RFC 3339 cookie parsed" true
-
(Option.is_some cookie_opt);
+
(Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
"RFC 3339 has expiry" true
(Option.is_some (Cookeio.expires cookie));
···
(* Cookie should still be parsed, just without expires *)
Alcotest.(check bool)
"cookie parsed despite invalid date" true
-
(Option.is_some cookie_opt);
-
let cookie = Option.get cookie_opt in
+
(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 *)
···
in
Alcotest.(check bool)
(description ^ " parsed") true
-
(Option.is_some cookie_opt);
+
(Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
(description ^ " has expiry")
true
···
in
Alcotest.(check bool)
(description ^ " parsed") true
-
(Option.is_some cookie_opt);
+
(Result.is_ok cookie_opt);
-
let cookie = Option.get cookie_opt in
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool)
(description ^ " has expiry")
true
···
|> Option.value ~default:Ptime.epoch)
~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure"
with
-
| Some c ->
+
| Ok c ->
Alcotest.(check bool) "partitioned flag" true (partitioned c);
Alcotest.(check bool) "secure flag" true (secure c)
-
| None -> Alcotest.fail "Should parse valid Partitioned cookie"
+
| Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg)
let test_partitioned_serialization env =
let clock = Eio.Stdenv.clock env in
···
|> Option.value ~default:Ptime.epoch)
~domain:"widget.com" ~path:"/" "id=123; Partitioned"
with
-
| None -> () (* Expected *)
-
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
+
| Error _ -> () (* Expected *)
+
| Ok _ -> Alcotest.fail "Should reject Partitioned without Secure"
(* Priority 2.2: Expiration Variants *)
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" "id=123; Expires=0"
with
-
| Some c ->
+
| Ok c ->
Alcotest.(check (option expiration_testable))
"expires=0 is session" (Some `Session) (expires c)
-
| None -> Alcotest.fail "Should parse Expires=0"
+
| Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg)
let test_serialize_expiration_variants env =
let clock = Eio.Stdenv.clock env in
···
let test_quoted_cookie_values env =
let clock = Eio.Stdenv.clock env in
-
let test_cases =
+
(* 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");
-
("name=\"value\"", "\"value\"", "value");
-
("name=\"partial", "\"partial", "\"partial");
-
("name=\"val\"\"", "\"val\"\"", "val\"");
-
("name=val\"", "val\"", "val\"");
-
("name=\"\"", "\"\"", "");
+
("name=value", "value", "value"); (* No quotes *)
+
("name=\"value\"", "\"value\"", "value"); (* Properly quoted *)
+
("name=\"\"", "\"\"", ""); (* Empty quoted value *)
in
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" input
with
-
| Some c ->
+
| 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)
-
| None -> Alcotest.fail ("Parse failed: " ^ input))
-
test_cases
+
| 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
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" "name=\"value\""
with
-
| Some c1 -> begin
+
| Ok c1 -> begin
match
of_set_cookie_header
~now:(fun () ->
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" "name=value"
with
-
| Some c2 ->
+
| Ok c2 ->
(* Different raw values *)
Alcotest.(check bool)
"different raw values" false
···
(* Same trimmed values *)
Alcotest.(check string)
"same trimmed values" (value_trimmed c1) (value_trimmed c2)
-
| None -> Alcotest.fail "Parse failed for unquoted"
+
| Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg)
end
-
| None -> Alcotest.fail "Parse failed for quoted"
+
| 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 results =
+
let result =
of_cookie_header
~now:(fun () ->
Ptime.of_float_s (Eio.Time.now clock)
···
~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
in
-
let cookies = List.filter_map Result.to_option results in
-
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
+
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 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
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/app" "session=xyz"
with
-
| [ Ok c ] ->
+
| 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);
···
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)
-
| _ -> Alcotest.fail "Should parse single cookie"
+
| 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 results =
+
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
-
let cookies = List.filter_map Result.to_option results in
-
Alcotest.(check int) description expected_count (List.length cookies)
+
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";
···
let test_cookie_header_with_errors env =
let clock = Eio.Stdenv.clock env in
-
(* Mix of valid and invalid cookies *)
-
let results =
+
(* 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)
···
~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
in
-
Alcotest.(check int) "total results" 3 (List.length results);
-
-
let successes = List.filter Result.is_ok results in
-
let errors = List.filter Result.is_error results in
-
-
Alcotest.(check int) "successful parses" 2 (List.length successes);
-
Alcotest.(check int) "failed parses" 1 (List.length errors);
-
-
(* Error should have descriptive message *)
+
(* 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
-
begin match List.hd errors with
+
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"
-
end
+
| Ok _ -> Alcotest.fail "Expected error for empty cookie name"
(* Max-Age and Expires Interaction *)
···
~domain:"ex.com" ~path:"/"
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT"
with
-
| Some c ->
+
| Ok c ->
(* Both should be stored *)
begin match max_age c with
| Some span -> begin
···
| Some (`DateTime _) -> ()
| _ -> Alcotest.fail "expires should be parsed"
end
-
| None -> Alcotest.fail "Should parse cookie with both attributes"
+
| 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_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_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;
] );