···6677### 1. Public Suffix Validation (Section 5.3, Step 5)
8899-**Status:** Not implemented
99+**Status:** ✅ IMPLEMENTED
10101111The RFC requires rejecting cookies with domains that are "public suffixes" (e.g., `.com`, `.co.uk`) to prevent domain-wide cookie attacks.
12121313-**Required behavior:**
1414-- Maintain or reference a public suffix list (e.g., from [publicsuffix.org](https://publicsuffix.org/))
1515-- Reject cookies where the Domain attribute is a public suffix (unless it exactly matches the request host)
1313+**Implementation:**
1414+- Uses the `publicsuffix` library which embeds the Mozilla Public Suffix List at build time
1515+- Validates Domain attribute in `of_set_cookie_header` before creating the cookie
1616+- Rejects cookies where Domain is a public suffix (e.g., `.com`, `.co.uk`, `.github.io`)
1717+- Allows cookies where the request host exactly matches the public suffix domain
1818+- IP addresses bypass PSL validation (per RFC 6265 Section 5.1.3)
1919+- Cookies without Domain attribute (host-only) are always allowed
16201717-**Security impact:** Without this, an attacker on `evil.com` could potentially set cookies for `.com` affecting all `.com` sites.
2121+**Security impact:** Prevents attackers from setting domain-wide cookies that would affect all sites under a TLD.
18221923---
2024···49535054### 4. Cookie Ordering in Header (Section 5.4, Step 2)
51555252-**Status:** Not implemented
5656+**Status:** ✅ IMPLEMENTED
53575454-When generating Cookie headers, cookies SHOULD be sorted:
5858+When generating Cookie headers, cookies are sorted:
55591. Cookies with longer paths listed first
56602. Among equal-length paths, earlier creation-times listed first
57615858-**Location:** `get_cookies` function in `cookeio_jar.ml`
6262+**Implementation:** `get_cookies` function in `cookeio_jar.ml` uses `compare_cookie_order` to sort cookies before returning them.
59636064---
61656266### 5. Creation Time Preservation (Section 5.3, Step 11.3)
63676464-**Status:** Not implemented
6868+**Status:** ✅ IMPLEMENTED
65696666-When replacing an existing cookie (same name/domain/path), the creation-time of the old cookie should be preserved.
7070+When replacing an existing cookie (same name/domain/path), the creation-time of the old cookie is preserved.
67716868-**Current behavior:** Completely replaces cookie, losing original creation time.
6969-7070-**Location:** `add_cookie` and `add_original` functions in `cookeio_jar.ml`
7272+**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.
71737274---
7375···145147- [x] Host-only flag for domain matching (Section 5.3, Step 6)
146148- [x] Path matching algorithm (Section 5.1.4)
147149- [x] IP address domain matching - exact match only (Section 5.1.3)
150150+- [x] Cookie ordering in headers - longer paths first, then by creation time (Section 5.4, Step 2)
151151+- [x] Creation time preservation when replacing cookies (Section 5.3, Step 11.3)
152152+- [x] Public suffix validation - rejects cookies for TLDs like .com (Section 5.3, Step 5)
148153149154---
150155
···107107 last_access;
108108 }
109109110110+(** {1 RFC 6265 Validation}
111111+112112+ Validation functions for cookie names, values, and attributes per
113113+ {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 Section 4.1.1}.
114114+115115+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 - Syntax *)
116116+module Validate = struct
117117+ (** Check if a character is a valid RFC 2616 token character.
118118+119119+ Per RFC 6265, cookie-name must be a token as defined in RFC 2616 Section 2.2:
120120+ token = 1*<any CHAR except CTLs or separators>
121121+ separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" |
122122+ <"> | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
123123+124124+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
125125+ let is_token_char = function
126126+ | '\x00' .. '\x1F' | '\x7F' -> false (* CTL characters *)
127127+ | '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '\\' | '"' | '/' | '['
128128+ | ']' | '?' | '=' | '{' | '}' | ' ' ->
129129+ false (* separators - note: HT (0x09) is already covered by CTL range *)
130130+ | _ -> true
131131+132132+ (** Validate a cookie name per RFC 6265.
133133+134134+ Cookie names must be valid RFC 2616 tokens: one or more characters
135135+ excluding control characters and separators.
136136+137137+ @param name The cookie name to validate
138138+ @return [Ok name] if valid, [Error message] with explanation if invalid
139139+140140+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
141141+ let cookie_name name =
142142+ let len = String.length name in
143143+ if len = 0 then
144144+ Error "Cookie name is empty; RFC 6265 requires at least one character"
145145+ else
146146+ let rec find_invalid i acc =
147147+ if i >= len then acc
148148+ else
149149+ let c = String.unsafe_get name i in
150150+ if is_token_char c then find_invalid (i + 1) acc
151151+ else find_invalid (i + 1) (c :: acc)
152152+ in
153153+ match find_invalid 0 [] with
154154+ | [] -> Ok name
155155+ | invalid_chars ->
156156+ let chars_str =
157157+ invalid_chars
158158+ |> List.rev
159159+ |> List.map (fun c -> Printf.sprintf "%C" c)
160160+ |> String.concat ", "
161161+ in
162162+ Error
163163+ (Printf.sprintf
164164+ "Cookie name %S contains invalid characters: %s. RFC 6265 requires \
165165+ cookie names to be valid tokens (no control characters, spaces, \
166166+ or separators like ()[]{}=,;:@\\\"/?<>)"
167167+ name chars_str)
168168+169169+ (** Check if a character is a valid cookie-octet.
170170+171171+ Per RFC 6265 Section 4.1.1:
172172+ cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
173173+ (US-ASCII excluding CTLs, whitespace, DQUOTE, comma, semicolon, backslash)
174174+175175+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
176176+ let is_cookie_octet = function
177177+ | '\x21' -> true (* ! *)
178178+ | '\x23' .. '\x2B' -> true (* # $ % & ' ( ) * + *)
179179+ | '\x2D' .. '\x3A' -> true (* - . / 0-9 : *)
180180+ | '\x3C' .. '\x5B' -> true (* < = > ? @ A-Z [ *)
181181+ | '\x5D' .. '\x7E' -> true (* ] ^ _ ` a-z { | } ~ *)
182182+ | _ -> false
183183+184184+ (** Validate a cookie value per RFC 6265.
185185+186186+ Cookie values must contain only cookie-octets, optionally wrapped in
187187+ double quotes. Invalid characters include: control characters, space,
188188+ double quote (except as wrapper), comma, semicolon, and backslash.
189189+190190+ @param value The cookie value to validate
191191+ @return [Ok value] if valid, [Error message] with explanation if invalid
192192+193193+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
194194+ let cookie_value value =
195195+ (* Handle optional DQUOTE wrapper *)
196196+ let len = String.length value in
197197+ let inner_value, inner_len =
198198+ if len >= 2 && value.[0] = '"' && value.[len - 1] = '"' then
199199+ (String.sub value 1 (len - 2), len - 2)
200200+ else (value, len)
201201+ in
202202+ let rec find_invalid i acc =
203203+ if i >= inner_len then acc
204204+ else
205205+ let c = String.unsafe_get inner_value i in
206206+ if is_cookie_octet c then find_invalid (i + 1) acc
207207+ else find_invalid (i + 1) (c :: acc)
208208+ in
209209+ match find_invalid 0 [] with
210210+ | [] -> Ok value
211211+ | invalid_chars ->
212212+ let chars_str =
213213+ invalid_chars
214214+ |> List.rev
215215+ |> List.map (fun c ->
216216+ match c with
217217+ | ' ' -> "space (0x20)"
218218+ | '"' -> "double-quote (0x22)"
219219+ | ',' -> "comma (0x2C)"
220220+ | ';' -> "semicolon (0x3B)"
221221+ | '\\' -> "backslash (0x5C)"
222222+ | c when Char.code c < 0x20 ->
223223+ Printf.sprintf "control char (0x%02X)" (Char.code c)
224224+ | c -> Printf.sprintf "%C (0x%02X)" c (Char.code c))
225225+ |> String.concat ", "
226226+ in
227227+ Error
228228+ (Printf.sprintf
229229+ "Cookie value %S contains invalid characters: %s. RFC 6265 cookie \
230230+ values may only contain printable ASCII excluding space, \
231231+ double-quote, comma, semicolon, and backslash"
232232+ value chars_str)
233233+234234+ (** Validate a domain attribute value.
235235+236236+ Domain values must be either:
237237+ - A valid domain name per RFC 1034 Section 3.5
238238+ - A valid IPv4 address
239239+ - A valid IPv6 address
240240+241241+ @param domain The domain value to validate (leading dot is stripped first)
242242+ @return [Ok domain] if valid, [Error message] with explanation if invalid
243243+244244+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> RFC 6265 Section 4.1.2.3
245245+ @see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> RFC 1034 Section 3.5 *)
246246+ let domain_value domain =
247247+ (* Strip leading dot per RFC 6265 Section 5.2.3 *)
248248+ let domain =
249249+ if String.starts_with ~prefix:"." domain && String.length domain > 1 then
250250+ String.sub domain 1 (String.length domain - 1)
251251+ else domain
252252+ in
253253+ if String.length domain = 0 then
254254+ Error "Domain attribute is empty"
255255+ else
256256+ (* First check if it's an IP address *)
257257+ match Ipaddr.of_string domain with
258258+ | Ok _ -> Ok domain (* Valid IP address *)
259259+ | Error _ -> (
260260+ (* Not an IP, validate as domain name using domain-name library *)
261261+ match Domain_name.of_string domain with
262262+ | Ok _ -> Ok domain
263263+ | Error (`Msg msg) ->
264264+ Error
265265+ (Printf.sprintf
266266+ "Domain %S is not a valid domain name: %s. Domain names \
267267+ must follow RFC 1034: labels must start with a letter, \
268268+ contain only letters/digits/hyphens, not end with a \
269269+ hyphen, and be at most 63 characters each"
270270+ domain msg))
271271+272272+ (** Validate a path attribute value.
273273+274274+ Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except
275275+ control characters and semicolon.
276276+277277+ @param path The path value to validate
278278+ @return [Ok path] if valid, [Error message] with explanation if invalid
279279+280280+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
281281+ let path_value path =
282282+ let len = String.length path in
283283+ let rec find_invalid i acc =
284284+ if i >= len then acc
285285+ else
286286+ let c = String.unsafe_get path i in
287287+ match c with
288288+ | '\x00' .. '\x1F' | '\x7F' | ';' -> find_invalid (i + 1) (c :: acc)
289289+ | _ -> find_invalid (i + 1) acc
290290+ in
291291+ match find_invalid 0 [] with
292292+ | [] -> Ok path
293293+ | invalid_chars ->
294294+ let chars_str =
295295+ invalid_chars
296296+ |> List.rev
297297+ |> List.map (fun c -> Printf.sprintf "0x%02X" (Char.code c))
298298+ |> String.concat ", "
299299+ in
300300+ Error
301301+ (Printf.sprintf
302302+ "Path %S contains invalid characters: %s. Paths may not contain \
303303+ control characters or semicolons"
304304+ path chars_str)
305305+306306+ (** Validate a Max-Age attribute value.
307307+308308+ Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT.
309309+ However, per Section 5.2.2, user agents should treat values <= 0 as
310310+ "delete immediately". This function returns [Ok] for any integer since
311311+ the parsing code handles negative values by converting to 0.
312312+313313+ @param seconds The Max-Age value in seconds
314314+ @return [Ok seconds] always (negative values are handled in parsing)
315315+316316+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1
317317+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> RFC 6265 Section 5.2.2 *)
318318+ let max_age seconds = Ok seconds
319319+end
320320+321321+(** {1 Public Suffix Validation}
322322+323323+ Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 5},
324324+ cookies with Domain attributes that are public suffixes must be rejected
325325+ to prevent domain-wide cookie attacks.
326326+327327+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model
328328+ @see <https://publicsuffix.org/list/> Public Suffix List *)
329329+330330+(** Module-level Public Suffix List instance.
331331+332332+ Lazily initialized on first use. The PSL data is compiled into the
333333+ publicsuffix library at build time from the Mozilla Public Suffix List. *)
334334+let psl = lazy (Publicsuffix.create ())
335335+336336+(** Validate that a cookie domain is not a public suffix.
337337+338338+ Per RFC 6265 Section 5.3 Step 5, user agents MUST reject cookies where
339339+ the Domain attribute is a public suffix (e.g., ".com", ".co.uk") unless
340340+ the request host exactly matches that domain.
341341+342342+ This prevents attackers from setting domain-wide cookies that would affect
343343+ all sites under a TLD.
344344+345345+ @param request_domain The host from the HTTP request
346346+ @param cookie_domain The Domain attribute value (already normalized, without leading dot)
347347+ @return [Ok ()] if the domain is allowed, [Error msg] if it's a public suffix
348348+349349+ Examples:
350350+ - Request from "www.example.com", Domain=".com" → Error (public suffix)
351351+ - Request from "www.example.com", Domain=".example.com" → Ok (not public suffix)
352352+ - Request from "com", Domain=".com" → Ok (request host matches domain exactly)
353353+354354+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 *)
355355+let validate_not_public_suffix ~request_domain ~cookie_domain =
356356+ (* IP addresses bypass PSL check per RFC 6265 Section 5.1.3 *)
357357+ match Ipaddr.of_string cookie_domain with
358358+ | Ok _ -> Ok () (* IP addresses are not subject to PSL rules *)
359359+ | Error _ ->
360360+ let psl = Lazy.force psl in
361361+ (match Publicsuffix.is_public_suffix psl cookie_domain with
362362+ | Error _ ->
363363+ (* If PSL lookup fails (e.g., invalid domain), allow the cookie.
364364+ Domain name validation is handled separately. *)
365365+ Ok ()
366366+ | Ok false ->
367367+ (* Not a public suffix, allow the cookie *)
368368+ Ok ()
369369+ | Ok true ->
370370+ (* It's a public suffix - only allow if request host matches exactly.
371371+ This allows a server that IS a public suffix (rare but possible with
372372+ private domains like blogspot.com) to set cookies for itself. *)
373373+ let request_lower = String.lowercase_ascii request_domain in
374374+ let cookie_lower = String.lowercase_ascii cookie_domain in
375375+ if request_lower = cookie_lower then Ok ()
376376+ else
377377+ Error
378378+ (Printf.sprintf
379379+ "Domain %S is a public suffix; RFC 6265 Section 5.3 prohibits \
380380+ setting cookies for public suffixes to prevent domain-wide \
381381+ cookie attacks. The request host %S does not exactly match \
382382+ the domain."
383383+ cookie_domain request_domain))
384384+110385(** {1 Cookie Parsing Helpers} *)
111386112387(** Normalize a domain by stripping the leading dot.
···393668(** Parse a Set-Cookie HTTP response header.
394669395670 Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2},
396396- extracting the cookie name, value, and all attributes. Returns [None] if
397397- the cookie is invalid or fails validation.
671671+ extracting the cookie name, value, and all attributes. Returns [Error msg] if
672672+ the cookie is invalid or fails validation, with a descriptive error message.
398673399674 @param now Function returning current time for Max-Age computation
400675 @param domain The request host (used as default domain)
401676 @param path The request path (used as default path)
402677 @param header_value The Set-Cookie header value string
403403- @return The parsed cookie, or [None] if parsing/validation fails
678678+ @return [Ok cookie] if parsing succeeds, [Error msg] with explanation if invalid
404679405680 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
406681let of_set_cookie_header ~now ~domain:request_domain ~path:request_path
···411686 let parts = String.split_on_char ';' header_value |> List.map String.trim in
412687413688 match parts with
414414- | [] -> None
689689+ | [] -> Error "Empty Set-Cookie header"
415690 | name_value :: attrs -> (
416691 (* Parse name=value *)
417692 match String.index_opt name_value '=' with
418418- | None -> None
419419- | Some eq_pos ->
693693+ | None ->
694694+ Error
695695+ (Printf.sprintf
696696+ "Set-Cookie header missing '=' separator in name-value pair: %S"
697697+ name_value)
698698+ | Some eq_pos -> (
420699 let name = String.sub name_value 0 eq_pos |> String.trim in
421700 let cookie_value =
422701 String.sub name_value (eq_pos + 1)
···424703 |> String.trim
425704 in
426705427427- let current_time = now () in
706706+ (* Validate cookie name per RFC 6265 *)
707707+ match Validate.cookie_name name with
708708+ | Error msg -> Error msg
709709+ | Ok name -> (
710710+ (* Validate cookie value per RFC 6265 *)
711711+ match Validate.cookie_value cookie_value with
712712+ | Error msg -> Error msg
713713+ | Ok cookie_value ->
714714+ let current_time = now () in
428715429429- (* Parse all attributes into mutable accumulator *)
430430- let accumulated_attrs = empty_attributes () in
431431- List.iter
432432- (fun attr ->
433433- match String.index_opt attr '=' with
434434- | None ->
435435- (* Attribute without value (e.g., Secure, HttpOnly) *)
436436- parse_attribute now accumulated_attrs attr ""
437437- | Some eq ->
438438- let attr_name = String.sub attr 0 eq |> String.trim in
439439- let attr_value =
440440- String.sub attr (eq + 1) (String.length attr - eq - 1)
441441- |> String.trim
442442- in
443443- parse_attribute now accumulated_attrs attr_name attr_value)
444444- attrs;
716716+ (* Parse all attributes into mutable accumulator *)
717717+ let accumulated_attrs = empty_attributes () in
718718+ let attr_errors = ref [] in
719719+ List.iter
720720+ (fun attr ->
721721+ match String.index_opt attr '=' with
722722+ | None ->
723723+ (* Attribute without value (e.g., Secure, HttpOnly) *)
724724+ parse_attribute now accumulated_attrs attr ""
725725+ | Some eq ->
726726+ let attr_name = String.sub attr 0 eq |> String.trim in
727727+ let attr_value =
728728+ String.sub attr (eq + 1)
729729+ (String.length attr - eq - 1)
730730+ |> String.trim
731731+ in
732732+ (* Validate domain and path attributes *)
733733+ (match String.lowercase_ascii attr_name with
734734+ | "domain" -> (
735735+ match Validate.domain_value attr_value with
736736+ | Error msg -> attr_errors := msg :: !attr_errors
737737+ | Ok _ -> ())
738738+ | "path" -> (
739739+ match Validate.path_value attr_value with
740740+ | Error msg -> attr_errors := msg :: !attr_errors
741741+ | Ok _ -> ())
742742+ | "max-age" -> (
743743+ match int_of_string_opt attr_value with
744744+ | Some seconds -> (
745745+ match Validate.max_age seconds with
746746+ | Error msg ->
747747+ attr_errors := msg :: !attr_errors
748748+ | Ok _ -> ())
749749+ | None -> ())
750750+ | _ -> ());
751751+ parse_attribute now accumulated_attrs attr_name
752752+ attr_value)
753753+ attrs;
445754446446- (* Validate attributes *)
447447- if not (validate_attributes accumulated_attrs) then (
448448- Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
449449- None)
450450- else
451451- let cookie =
452452- build_cookie ~request_domain ~request_path ~name
453453- ~value:cookie_value accumulated_attrs ~now:current_time
454454- in
455455- Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
456456- Some cookie)
755755+ (* Check for attribute validation errors *)
756756+ if List.length !attr_errors > 0 then
757757+ Error (String.concat "; " (List.rev !attr_errors))
758758+ else if not (validate_attributes accumulated_attrs) then
759759+ Error
760760+ "Cookie validation failed: SameSite=None requires \
761761+ Secure flag, and Partitioned requires Secure flag"
762762+ else
763763+ (* Public suffix validation per RFC 6265 Section 5.3 Step 5.
764764+ Only applies when Domain attribute is present. *)
765765+ let psl_result =
766766+ match accumulated_attrs.domain with
767767+ | None ->
768768+ (* No Domain attribute - cookie is host-only, no PSL check needed *)
769769+ Ok ()
770770+ | Some cookie_domain ->
771771+ let normalized = normalize_domain cookie_domain in
772772+ validate_not_public_suffix ~request_domain ~cookie_domain:normalized
773773+ in
774774+ (match psl_result with
775775+ | Error msg -> Error msg
776776+ | Ok () ->
777777+ let cookie =
778778+ build_cookie ~request_domain ~request_path ~name
779779+ ~value:cookie_value accumulated_attrs ~now:current_time
780780+ in
781781+ Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
782782+ Ok cookie))))
457783458784(** Parse a Cookie HTTP request header.
459785460786 Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}.
461787 The Cookie header contains semicolon-separated name=value pairs.
462788789789+ Validates cookie names and values per RFC 6265 and detects duplicate
790790+ cookie names (which is an error per Section 4.2.1).
791791+463792 Cookies parsed from the Cookie header have [host_only = true] since we
464793 cannot determine from the header alone whether they originally had a
465794 Domain attribute.
···468797 @param domain The request host (assigned to all parsed cookies)
469798 @param path The request path (assigned to all parsed cookies)
470799 @param header_value The Cookie header value string
471471- @return List of parse results (Ok cookie or Error message)
800800+ @return [Ok cookies] if all cookies parse successfully with no duplicates,
801801+ [Error msg] with explanation if validation fails
472802473803 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
474804let of_cookie_header ~now ~domain ~path header_value =
···480810 (* Filter out empty parts *)
481811 let parts = List.filter (fun s -> String.length s > 0) parts in
482812483483- (* Parse each name=value pair *)
484484- List.map
485485- (fun name_value ->
486486- match String.index_opt name_value '=' with
487487- | None ->
488488- Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
489489- | Some eq_pos ->
490490- let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
491491- if String.length cookie_name = 0 then Error "Cookie has empty name"
492492- else
493493- let cookie_value =
494494- String.sub name_value (eq_pos + 1)
495495- (String.length name_value - eq_pos - 1)
496496- |> String.trim
497497- in
498498- let current_time = now () in
499499- (* Create cookie with defaults from Cookie header context.
500500- Cookies from Cookie headers have host_only=true since we don't
501501- know if they originally had a Domain attribute. *)
502502- let cookie =
503503- make ~domain ~path ~name:cookie_name ~value:cookie_value
504504- ~secure:false ~http_only:false ~partitioned:false ~host_only:true
505505- ~creation_time:current_time ~last_access:current_time ()
506506- in
507507- Ok cookie)
508508- parts
813813+ (* Parse each name=value pair, collecting results *)
814814+ let results =
815815+ List.fold_left
816816+ (fun acc name_value ->
817817+ match acc with
818818+ | Error _ -> acc (* Propagate earlier errors *)
819819+ | Ok (cookies, seen_names) -> (
820820+ match String.index_opt name_value '=' with
821821+ | None ->
822822+ Error
823823+ (Printf.sprintf "Cookie missing '=' separator: %S" name_value)
824824+ | Some eq_pos -> (
825825+ let cookie_name =
826826+ String.sub name_value 0 eq_pos |> String.trim
827827+ in
828828+ (* Validate cookie name per RFC 6265 *)
829829+ match Validate.cookie_name cookie_name with
830830+ | Error msg -> Error msg
831831+ | Ok cookie_name -> (
832832+ (* Check for duplicate names per RFC 6265 Section 4.2.1 *)
833833+ if List.mem cookie_name seen_names then
834834+ Error
835835+ (Printf.sprintf
836836+ "Duplicate cookie name %S in Cookie header; RFC \
837837+ 6265 Section 4.2.1 forbids duplicate names"
838838+ cookie_name)
839839+ else
840840+ let cookie_value =
841841+ String.sub name_value (eq_pos + 1)
842842+ (String.length name_value - eq_pos - 1)
843843+ |> String.trim
844844+ in
845845+ (* Validate cookie value per RFC 6265 *)
846846+ match Validate.cookie_value cookie_value with
847847+ | Error msg -> Error msg
848848+ | Ok cookie_value ->
849849+ let current_time = now () in
850850+ (* Create cookie with defaults from Cookie header context.
851851+ Cookies from Cookie headers have host_only=true since we don't
852852+ know if they originally had a Domain attribute. *)
853853+ let cookie =
854854+ make ~domain ~path ~name:cookie_name
855855+ ~value:cookie_value ~secure:false ~http_only:false
856856+ ~partitioned:false ~host_only:true
857857+ ~creation_time:current_time
858858+ ~last_access:current_time ()
859859+ in
860860+ Ok (cookie :: cookies, cookie_name :: seen_names)))))
861861+ (Ok ([], []))
862862+ parts
863863+ in
864864+ match results with
865865+ | Error msg -> Error msg
866866+ | Ok (cookies, _) -> Ok (List.rev cookies)
509867510868(** Generate a Cookie HTTP request header from a list of cookies.
511869
+113-9
lib/core/cookeio.mli
···261261262262 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
263263264264+(** {1 RFC 6265 Validation}
265265+266266+ Validation functions for cookie names, values, and attributes per
267267+ {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 Section 4.1.1}.
268268+ These functions return [Ok value] on success or [Error msg] with a detailed
269269+ explanation of why validation failed.
270270+271271+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 - Syntax *)
272272+273273+module Validate : sig
274274+ val cookie_name : string -> (string, string) result
275275+ (** Validate a cookie name per RFC 6265.
276276+277277+ Cookie names must be valid RFC 2616 tokens: one or more characters
278278+ excluding control characters and separators.
279279+280280+ @param name The cookie name to validate
281281+ @return [Ok name] if valid, [Error message] with explanation if invalid
282282+283283+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
284284+285285+ val cookie_value : string -> (string, string) result
286286+ (** Validate a cookie value per RFC 6265.
287287+288288+ Cookie values must contain only cookie-octets, optionally wrapped in
289289+ double quotes. Invalid characters include: control characters, space,
290290+ double quote (except as wrapper), comma, semicolon, and backslash.
291291+292292+ @param value The cookie value to validate
293293+ @return [Ok value] if valid, [Error message] with explanation if invalid
294294+295295+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
296296+297297+ val domain_value : string -> (string, string) result
298298+ (** Validate a domain attribute value.
299299+300300+ Domain values must be either:
301301+ - A valid domain name per RFC 1034 Section 3.5
302302+ - A valid IPv4 address
303303+ - A valid IPv6 address
304304+305305+ @param domain The domain value to validate (leading dot is stripped first)
306306+ @return [Ok domain] if valid, [Error message] with explanation if invalid
307307+308308+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> RFC 6265 Section 4.1.2.3
309309+ @see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> RFC 1034 Section 3.5 *)
310310+311311+ val path_value : string -> (string, string) result
312312+ (** Validate a path attribute value.
313313+314314+ Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except
315315+ control characters and semicolon.
316316+317317+ @param path The path value to validate
318318+ @return [Ok path] if valid, [Error message] with explanation if invalid
319319+320320+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
321321+322322+ val max_age : int -> (int, string) result
323323+ (** Validate a Max-Age attribute value.
324324+325325+ Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT.
326326+ However, per Section 5.2.2, user agents should treat values <= 0 as
327327+ "delete immediately". This function returns [Ok] for any integer since
328328+ the parsing code handles negative values by converting to 0.
329329+330330+ @param seconds The Max-Age value in seconds
331331+ @return [Ok seconds] always (negative values are handled in parsing)
332332+333333+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1
334334+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> RFC 6265 Section 5.2.2 *)
335335+end
336336+264337(** {1 Cookie Creation and Parsing} *)
265338266339val of_set_cookie_header :
267267- now:(unit -> Ptime.t) -> domain:string -> path:string -> string -> t option
340340+ now:(unit -> Ptime.t) ->
341341+ domain:string ->
342342+ path:string ->
343343+ string ->
344344+ (t, string) result
268345(** Parse Set-Cookie response header value into a cookie.
269346270347 Parses a Set-Cookie header following
···272349 - Basic format: [NAME=VALUE; attribute1; attribute2=value2]
273350 - Supports all standard attributes: [expires], [max-age], [domain], [path],
274351 [secure], [httponly], [samesite], [partitioned]
275275- - Returns [None] if parsing fails or cookie validation fails
352352+ - Returns [Error msg] if parsing fails or cookie validation fails, with
353353+ a detailed explanation of what was invalid
276354 - The [domain] and [path] parameters provide the request context for default
277355 values
278356 - The [now] parameter is used for calculating expiry times from [max-age]
279357 attributes and setting creation/access times
280358281281- Cookie validation rules (from RFC 6265bis and CHIPS):
282282- - [SameSite=None] requires the [Secure] flag to be set
283283- - [Partitioned] requires the [Secure] flag to be set
359359+ Validation rules applied:
360360+ - Cookie name must be a valid RFC 2616 token (no CTLs or separators)
361361+ - Cookie value must contain only valid cookie-octets
362362+ - Domain must be a valid domain name (RFC 1034) or IP address
363363+ - Path must not contain control characters or semicolons
364364+ - Max-Age must be non-negative
365365+ - [SameSite=None] requires the [Secure] flag to be set (RFC 6265bis)
366366+ - [Partitioned] requires the [Secure] flag to be set (CHIPS)
367367+ - Domain must not be a public suffix per
368368+ {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 5}
369369+ (unless the request host exactly matches the domain). This uses the
370370+ {{:https://publicsuffix.org/list/} Mozilla Public Suffix List} to prevent
371371+ domain-wide cookie attacks.
372372+373373+ {3 Public Suffix Validation}
374374+375375+ Cookies with Domain attributes that are public suffixes (e.g., [.com], [.co.uk],
376376+ [.github.io]) are rejected to prevent a malicious site from setting cookies
377377+ that would affect all sites under that TLD.
378378+379379+ Examples:
380380+ - Request from [www.example.com], Domain=[.com] → rejected (public suffix)
381381+ - Request from [www.example.com], Domain=[.example.com] → allowed
382382+ - Request from [blogspot.com], Domain=[.blogspot.com] → allowed (request matches)
284383285384 Example:
286385 {[of_set_cookie_header ~now:(fun () -> Ptime_clock.now ())
287386 ~domain:"example.com" ~path:"/" "session=abc123; Secure; HttpOnly"]}
288387289289- @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
388388+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header
389389+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (public suffix check)
390390+ @see <https://publicsuffix.org/list/> Public Suffix List *)
290391291392val of_cookie_header :
292393 now:(unit -> Ptime.t) ->
293394 domain:string ->
294395 path:string ->
295396 string ->
296296- (t, string) result list
397397+ (t list, string) result
297398(** Parse Cookie request header containing semicolon-separated name=value pairs.
298399299400 Parses a Cookie header following
···301402 Cookie headers contain only name=value pairs without attributes:
302403 ["name1=value1; name2=value2; name3=value3"]
303404405405+ Validates each cookie name and value per RFC 6265 and detects duplicate
406406+ cookie names (which is forbidden per Section 4.2.1).
407407+304408 Creates cookies with:
305409 - Provided [domain] and [path] from request context
306410 - All security flags set to [false] (defaults)
···309413 whether cookies originally had a Domain attribute)
310414 - [creation_time] and [last_access] set to current time from [now]
311415312312- Returns a list of parse results, one per cookie. Parse errors for individual
313313- cookies are returned as [Error msg] without failing the entire parse.
416416+ Returns [Ok cookies] if all cookies parse successfully with no duplicates,
417417+ or [Error msg] if any validation fails.
314418315419 Example:
316420 {[of_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com"
···146146147147(** {1 Cookie Management} *)
148148149149+(** Preserve creation time from an existing cookie when replacing.
150150+151151+ Per RFC 6265 Section 5.3, Step 11.3: "If the newly created cookie was
152152+ received from a 'non-HTTP' API and the old-cookie's http-only-flag is
153153+ true, abort these steps and ignore the newly created cookie entirely."
154154+ Step 11.3 also states: "Update the creation-time of the old-cookie to
155155+ match the creation-time of the newly created cookie."
156156+157157+ However, the common interpretation (and browser behavior) is to preserve
158158+ the original creation-time when updating a cookie. This matches what
159159+ Step 3 of Section 5.4 uses for ordering (creation-time stability).
160160+161161+ @param old_cookie The existing cookie being replaced (if any)
162162+ @param new_cookie The new cookie to add
163163+ @return The new cookie with creation_time preserved from old_cookie if present
164164+165165+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
166166+let preserve_creation_time old_cookie_opt new_cookie =
167167+ match old_cookie_opt with
168168+ | None -> new_cookie
169169+ | Some old_cookie ->
170170+ Cookeio.make ~domain:(Cookeio.domain new_cookie)
171171+ ~path:(Cookeio.path new_cookie) ~name:(Cookeio.name new_cookie)
172172+ ~value:(Cookeio.value new_cookie) ~secure:(Cookeio.secure new_cookie)
173173+ ~http_only:(Cookeio.http_only new_cookie)
174174+ ?expires:(Cookeio.expires new_cookie)
175175+ ?max_age:(Cookeio.max_age new_cookie)
176176+ ?same_site:(Cookeio.same_site new_cookie)
177177+ ~partitioned:(Cookeio.partitioned new_cookie)
178178+ ~host_only:(Cookeio.host_only new_cookie)
179179+ ~creation_time:(Cookeio.creation_time old_cookie)
180180+ ~last_access:(Cookeio.last_access new_cookie)
181181+ ()
182182+149183let add_cookie jar cookie =
150184 Log.debug (fun m ->
151185 m "Adding cookie to delta: %s=%s for domain %s" (Cookeio.name cookie)
152186 (Cookeio.value cookie) (Cookeio.domain cookie));
153187154188 Eio.Mutex.lock jar.mutex;
189189+190190+ (* Find existing cookie with same identity to preserve creation_time
191191+ per RFC 6265 Section 5.3, Step 11.3 *)
192192+ let existing =
193193+ List.find_opt (fun c -> cookie_identity_matches c cookie) jar.delta_cookies
194194+ in
195195+ let existing =
196196+ match existing with
197197+ | Some _ -> existing
198198+ | None ->
199199+ (* Also check original cookies for creation time preservation *)
200200+ List.find_opt
201201+ (fun c -> cookie_identity_matches c cookie)
202202+ jar.original_cookies
203203+ in
204204+205205+ let cookie = preserve_creation_time existing cookie in
206206+155207 (* Remove existing cookie with same identity from delta *)
156208 jar.delta_cookies <-
157209 List.filter
···166218 (Cookeio.value cookie) (Cookeio.domain cookie));
167219168220 Eio.Mutex.lock jar.mutex;
221221+222222+ (* Find existing cookie with same identity to preserve creation_time
223223+ per RFC 6265 Section 5.3, Step 11.3 *)
224224+ let existing =
225225+ List.find_opt
226226+ (fun c -> cookie_identity_matches c cookie)
227227+ jar.original_cookies
228228+ in
229229+230230+ let cookie = preserve_creation_time existing cookie in
231231+169232 (* Remove existing cookie with same identity from original *)
170233 jar.original_cookies <-
171234 List.filter
···239302240303 Eio.Mutex.unlock jar.mutex
241304305305+(** Compare cookies for ordering per RFC 6265 Section 5.4, Step 2.
306306+307307+ Cookies SHOULD be sorted:
308308+ 1. Cookies with longer paths listed first
309309+ 2. Among equal-length paths, cookies with earlier creation-times first
310310+311311+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)
312312+let compare_cookie_order c1 c2 =
313313+ let path1_len = String.length (Cookeio.path c1) in
314314+ let path2_len = String.length (Cookeio.path c2) in
315315+ (* Longer paths first (descending order) *)
316316+ match Int.compare path2_len path1_len with
317317+ | 0 ->
318318+ (* Equal path lengths: earlier creation time first (ascending order) *)
319319+ Ptime.compare (Cookeio.creation_time c1) (Cookeio.creation_time c2)
320320+ | n -> n
321321+242322(** Retrieve cookies that should be sent for a given request.
243323244324 Per RFC 6265 Section 5.4, the user agent should include a Cookie header
245325 containing cookies that match the request-uri's domain, path, and security
246326 context. This function also updates the last-access-time for matched cookies.
247327328328+ Cookies are sorted per Section 5.4, Step 2:
329329+ 1. Cookies with longer paths listed first
330330+ 2. Among equal-length paths, earlier creation-times listed first
331331+248332 @param jar The cookie jar to search
249333 @param clock The Eio clock for timestamp updates
250334 @param domain The request domain (hostname or IP address)
251335 @param path The request path
252336 @param is_secure Whether the request is over a secure channel (HTTPS)
253253- @return List of cookies that should be included in the Cookie header
337337+ @return List of cookies that should be included in the Cookie header, sorted
254338255339 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)
256340let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
···276360 in
277361 let unique_cookies = dedup [] all_cookies in
278362279279- (* Filter for applicable cookies, excluding removal cookies (empty value) *)
363363+ (* Filter for applicable cookies, excluding removal cookies and expired cookies *)
280364 let applicable =
281365 List.filter
282366 (fun cookie ->
283367 Cookeio.value cookie <> ""
284368 (* Exclude removal cookies *)
369369+ && (not (is_expired cookie clock))
370370+ (* Exclude expired cookies *)
285371 && domain_matches ~host_only:(Cookeio.host_only cookie)
286372 (Cookeio.domain cookie) request_domain
287373 && path_matches (Cookeio.path cookie) request_path
288374 && ((not (Cookeio.secure cookie)) || is_secure))
289375 unique_cookies
290376 in
377377+378378+ (* Sort cookies per RFC 6265 Section 5.4, Step 2:
379379+ - Longer paths first
380380+ - Equal paths: earlier creation time first *)
381381+ let sorted = List.sort compare_cookie_order applicable in
291382292383 (* Update last access time in both lists *)
293384 let now =
···313404314405 Eio.Mutex.unlock jar.mutex;
315406316316- Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
317317- applicable
407407+ Log.debug (fun m -> m "Found %d applicable cookies" (List.length sorted));
408408+ sorted
318409319410let clear jar =
320411 Log.info (fun m -> m "Clearing all cookies");
+30-6
lib/jar/cookeio_jar.mli
···50505151 The cookie is added to the delta, meaning it will appear in Set-Cookie
5252 headers when calling {!delta}. If a cookie with the same name/domain/path
5353- exists in the delta, it will be replaced per
5454- {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}. *)
5353+ exists, it will be replaced per
5454+ {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}.
5555+5656+ Per Section 5.3, Step 11.3, when replacing an existing cookie, the original
5757+ creation-time is preserved. This ensures stable cookie ordering per
5858+ Section 5.4, Step 2.
5959+6060+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
55615662val add_original : t -> Cookeio.t -> unit
5763(** Add an original cookie to the jar.
58645965 Original cookies are those received from the client (via Cookie header).
6066 They do not appear in the delta. This method should be used when loading
6161- cookies from incoming HTTP requests. *)
6767+ cookies from incoming HTTP requests.
6868+6969+ Per Section 5.3, Step 11.3, when replacing an existing cookie, the original
7070+ creation-time is preserved.
7171+7272+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
62736374val delta : t -> Cookeio.t list
6475(** Get cookies that need to be sent in Set-Cookie headers.
···9210393104 Returns all cookies that match the given domain and path, and satisfy the
94105 secure flag requirement. Combines original and delta cookies, with delta
9595- taking precedence. Excludes removal cookies (empty value). Also updates the
9696- last access time of matching cookies using the provided clock.
106106+ taking precedence. Excludes:
107107+ - Removal cookies (empty value)
108108+ - Expired cookies (expiry-time in the past per Section 5.3)
109109+110110+ Cookies are sorted per Section 5.4, Step 2:
111111+ - Cookies with longer paths are listed before cookies with shorter paths
112112+ - Among cookies with equal-length paths, cookies with earlier creation-times
113113+ are listed first
114114+115115+ Also updates the last access time of matching cookies using the provided clock.
9711698117 Domain matching follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3} Section 5.1.3}:
99118 - IP addresses require exact match only
···101120102121 Path matching follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4} Section 5.1.4}.
103122123123+ @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (expiry)
104124 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *)
105125106126val clear : t -> unit
···123143(** Get the number of unique cookies in the jar. *)
124144125145val get_all_cookies : t -> Cookeio.t list
126126-(** Get all cookies in the jar. *)
146146+(** Get all cookies in the jar.
147147+148148+ Returns all cookies including expired ones (for inspection/debugging).
149149+ Use {!get_cookies} with appropriate domain/path for filtered results that
150150+ exclude expired cookies, or call {!clear_expired} first. *)
127151128152val is_empty : t -> bool
129153(** Check if the jar is empty. *)
+821-101
test/test_cookeio.ml
···379379 "only session cookie remains" "session"
380380 (Cookeio.name (List.hd remaining))
381381382382+let test_get_cookies_filters_expired () =
383383+ Eio_mock.Backend.run @@ fun () ->
384384+ let clock = Eio_mock.Clock.make () in
385385+ Eio_mock.Clock.set_time clock 1000.0;
386386+387387+ let jar = create () in
388388+389389+ (* Add an expired cookie (expired at time 500) *)
390390+ let expired = Ptime.of_float_s 500.0 |> Option.get in
391391+ let cookie_expired =
392392+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expired"
393393+ ~value:"old" ~secure:false ~http_only:false
394394+ ~expires:(`DateTime expired)
395395+ ~creation_time:(Ptime.of_float_s 100.0 |> Option.get)
396396+ ~last_access:(Ptime.of_float_s 100.0 |> Option.get)
397397+ ()
398398+ in
399399+400400+ (* Add a valid cookie (expires at time 2000) *)
401401+ let valid_time = Ptime.of_float_s 2000.0 |> Option.get in
402402+ let cookie_valid =
403403+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"valid"
404404+ ~value:"current" ~secure:false ~http_only:false
405405+ ~expires:(`DateTime valid_time)
406406+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
407407+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
408408+ ()
409409+ in
410410+411411+ (* Add a session cookie (no expiry) *)
412412+ let cookie_session =
413413+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session"
414414+ ~value:"sess" ~secure:false ~http_only:false
415415+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
416416+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
417417+ ()
418418+ in
419419+420420+ add_cookie jar cookie_expired;
421421+ add_cookie jar cookie_valid;
422422+ add_cookie jar cookie_session;
423423+424424+ (* get_all_cookies returns all including expired (for inspection) *)
425425+ Alcotest.(check int) "get_all_cookies includes expired" 3
426426+ (List.length (get_all_cookies jar));
427427+428428+ (* get_cookies should automatically filter out expired cookies *)
429429+ let cookies =
430430+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
431431+ in
432432+ Alcotest.(check int) "get_cookies filters expired" 2 (List.length cookies);
433433+434434+ let names = List.map Cookeio.name cookies |> List.sort String.compare in
435435+ Alcotest.(check (list string))
436436+ "only non-expired cookies returned"
437437+ [ "session"; "valid" ]
438438+ names
439439+382440let test_max_age_parsing_with_mock_clock () =
383441 Eio_mock.Backend.run @@ fun () ->
384442 let clock = Eio_mock.Clock.make () in
···396454 ~domain:"example.com" ~path:"/" header
397455 in
398456399399- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
457457+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
400458401401- let cookie = Option.get cookie_opt in
459459+ let cookie = Result.get_ok cookie_opt in
402460 Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie);
403461 Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie);
404462 Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie);
···481539 ~domain:"example.com" ~path:"/" header
482540 in
483541484484- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
542542+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
485543486486- let cookie = Option.get cookie_opt in
544544+ let cookie = Result.get_ok cookie_opt in
487545 Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie);
488546 Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie);
489547 Alcotest.(check string) "cookie domain" "example.com" (Cookeio.domain cookie);
···523581524582 Alcotest.(check bool)
525583 "invalid cookie rejected" true
526526- (Option.is_none cookie_opt);
584584+ (Result.is_error cookie_opt);
527585528586 (* This should be accepted: SameSite=None with Secure *)
529587 let valid_header = "token=abc; SameSite=None; Secure" in
···537595538596 Alcotest.(check bool)
539597 "valid cookie accepted" true
540540- (Option.is_some cookie_opt2);
598598+ (Result.is_ok cookie_opt2);
541599542542- let cookie = Option.get cookie_opt2 in
600600+ let cookie = Result.get_ok cookie_opt2 in
543601 Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
544602 Alcotest.(
545603 check
···566624 |> Option.value ~default:Ptime.epoch)
567625 ~domain:"example.com" ~path:"/" header
568626 in
569569- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
570570- let cookie = Option.get cookie_opt in
627627+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
628628+ let cookie = Result.get_ok cookie_opt in
571629 Alcotest.(check string)
572630 "domain normalized" "example.com" (Cookeio.domain cookie);
573631···604662 |> Option.value ~default:Ptime.epoch)
605663 ~domain:"example.com" ~path:"/" header
606664 in
607607- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
665665+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
608666609609- let cookie = Option.get cookie_opt in
667667+ let cookie = Result.get_ok cookie_opt in
610668611669 (* Verify max_age is stored as a Ptime.Span *)
612670 Alcotest.(check bool)
···642700 |> Option.value ~default:Ptime.epoch)
643701 ~domain:"example.com" ~path:"/" header
644702 in
645645- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
703703+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
646704647647- let cookie = Option.get cookie_opt in
705705+ let cookie = Result.get_ok cookie_opt in
648706649707 (* Verify max_age is stored as 0 per RFC 6265 *)
650708 Alcotest.(check bool)
···732790 |> Option.value ~default:Ptime.epoch)
733791 ~domain:"example.com" ~path:"/" header
734792 in
735735- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
736736- let cookie = Option.get cookie_opt in
793793+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
794794+ let cookie = Result.get_ok cookie_opt in
737795738796 (* Generate Set-Cookie header from the cookie *)
739797 let set_cookie_header = make_set_cookie_header cookie in
···748806 |> Option.value ~default:Ptime.epoch)
749807 ~domain:"example.com" ~path:"/" set_cookie_header
750808 in
751751- Alcotest.(check bool) "cookie re-parsed" true (Option.is_some cookie2_opt);
752752- let cookie2 = Option.get cookie2_opt in
809809+ Alcotest.(check bool) "cookie re-parsed" true (Result.is_ok cookie2_opt);
810810+ let cookie2 = Result.get_ok cookie2_opt in
753811754812 (* Verify max_age is preserved *)
755813 Alcotest.(check (option int))
···821879 |> Option.value ~default:Ptime.epoch)
822880 ~domain:"example.com" ~path:"/" header
823881 in
824824- Alcotest.(check bool) "FMT1 cookie parsed" true (Option.is_some cookie_opt);
882882+ Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt);
825883826826- let cookie = Option.get cookie_opt in
884884+ let cookie = Result.get_ok cookie_opt in
827885 Alcotest.(check bool)
828886 "FMT1 has expiry" true
829887 (Option.is_some (Cookeio.expires cookie));
···853911 |> Option.value ~default:Ptime.epoch)
854912 ~domain:"example.com" ~path:"/" header
855913 in
856856- Alcotest.(check bool) "FMT2 cookie parsed" true (Option.is_some cookie_opt);
914914+ Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt);
857915858858- let cookie = Option.get cookie_opt in
916916+ let cookie = Result.get_ok cookie_opt in
859917 Alcotest.(check bool)
860918 "FMT2 has expiry" true
861919 (Option.is_some (Cookeio.expires cookie));
···885943 |> Option.value ~default:Ptime.epoch)
886944 ~domain:"example.com" ~path:"/" header
887945 in
888888- Alcotest.(check bool) "FMT3 cookie parsed" true (Option.is_some cookie_opt);
946946+ Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt);
889947890890- let cookie = Option.get cookie_opt in
948948+ let cookie = Result.get_ok cookie_opt in
891949 Alcotest.(check bool)
892950 "FMT3 has expiry" true
893951 (Option.is_some (Cookeio.expires cookie));
···916974 |> Option.value ~default:Ptime.epoch)
917975 ~domain:"example.com" ~path:"/" header
918976 in
919919- Alcotest.(check bool) "FMT4 cookie parsed" true (Option.is_some cookie_opt);
977977+ Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt);
920978921921- let cookie = Option.get cookie_opt in
979979+ let cookie = Result.get_ok cookie_opt in
922980 Alcotest.(check bool)
923981 "FMT4 has expiry" true
924982 (Option.is_some (Cookeio.expires cookie));
···9471005 |> Option.value ~default:Ptime.epoch)
9481006 ~domain:"example.com" ~path:"/" header
9491007 in
950950- let cookie = Option.get cookie_opt in
10081008+ let cookie = Result.get_ok cookie_opt in
9511009 let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
9521010 begin match expected with
9531011 | Some t ->
···9671025 |> Option.value ~default:Ptime.epoch)
9681026 ~domain:"example.com" ~path:"/" header2
9691027 in
970970- let cookie2 = Option.get cookie_opt2 in
10281028+ let cookie2 = Result.get_ok cookie_opt2 in
9711029 let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
9721030 begin match expected2 with
9731031 | Some t ->
···9871045 |> Option.value ~default:Ptime.epoch)
9881046 ~domain:"example.com" ~path:"/" header3
9891047 in
990990- let cookie3 = Option.get cookie_opt3 in
10481048+ let cookie3 = Result.get_ok cookie_opt3 in
9911049 let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
9921050 begin match expected3 with
9931051 | Some t ->
···10121070 |> Option.value ~default:Ptime.epoch)
10131071 ~domain:"example.com" ~path:"/" header
10141072 in
10151015- let cookie = Option.get cookie_opt in
10731073+ let cookie = Result.get_ok cookie_opt in
10161074 let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
10171075 begin match expected with
10181076 | Some t ->
···10321090 |> Option.value ~default:Ptime.epoch)
10331091 ~domain:"example.com" ~path:"/" header2
10341092 in
10351035- let cookie2 = Option.get cookie_opt2 in
10931093+ let cookie2 = Result.get_ok cookie_opt2 in
10361094 let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
10371095 begin match expected2 with
10381096 | Some t ->
···10521110 |> Option.value ~default:Ptime.epoch)
10531111 ~domain:"example.com" ~path:"/" header3
10541112 in
10551055- let cookie3 = Option.get cookie_opt3 in
11131113+ let cookie3 = Result.get_ok cookie_opt3 in
10561114 let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
10571115 begin match expected3 with
10581116 | Some t ->
···10791137 in
10801138 Alcotest.(check bool)
10811139 "RFC 3339 cookie parsed" true
10821082- (Option.is_some cookie_opt);
11401140+ (Result.is_ok cookie_opt);
1083114110841084- let cookie = Option.get cookie_opt in
11421142+ let cookie = Result.get_ok cookie_opt in
10851143 Alcotest.(check bool)
10861144 "RFC 3339 has expiry" true
10871145 (Option.is_some (Cookeio.expires cookie));
···11141172 (* Cookie should still be parsed, just without expires *)
11151173 Alcotest.(check bool)
11161174 "cookie parsed despite invalid date" true
11171117- (Option.is_some cookie_opt);
11181118- let cookie = Option.get cookie_opt in
11751175+ (Result.is_ok cookie_opt);
11761176+ let cookie = Result.get_ok cookie_opt in
11191177 Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
11201178 Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
11211179 (* expires should be None since date was invalid *)
···11481206 in
11491207 Alcotest.(check bool)
11501208 (description ^ " parsed") true
11511151- (Option.is_some cookie_opt);
12091209+ (Result.is_ok cookie_opt);
1152121011531153- let cookie = Option.get cookie_opt in
12111211+ let cookie = Result.get_ok cookie_opt in
11541212 Alcotest.(check bool)
11551213 (description ^ " has expiry")
11561214 true
···11941252 in
11951253 Alcotest.(check bool)
11961254 (description ^ " parsed") true
11971197- (Option.is_some cookie_opt);
12551255+ (Result.is_ok cookie_opt);
1198125611991199- let cookie = Option.get cookie_opt in
12571257+ let cookie = Result.get_ok cookie_opt in
12001258 Alcotest.(check bool)
12011259 (description ^ " has expiry")
12021260 true
···15231581 |> Option.value ~default:Ptime.epoch)
15241582 ~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure"
15251583 with
15261526- | Some c ->
15841584+ | Ok c ->
15271585 Alcotest.(check bool) "partitioned flag" true (partitioned c);
15281586 Alcotest.(check bool) "secure flag" true (secure c)
15291529- | None -> Alcotest.fail "Should parse valid Partitioned cookie"
15871587+ | Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg)
1530158815311589let test_partitioned_serialization env =
15321590 let clock = Eio.Stdenv.clock env in
···15621620 |> Option.value ~default:Ptime.epoch)
15631621 ~domain:"widget.com" ~path:"/" "id=123; Partitioned"
15641622 with
15651565- | None -> () (* Expected *)
15661566- | Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
16231623+ | Error _ -> () (* Expected *)
16241624+ | Ok _ -> Alcotest.fail "Should reject Partitioned without Secure"
1567162515681626(* Priority 2.2: Expiration Variants *)
15691627···16051663 |> Option.value ~default:Ptime.epoch)
16061664 ~domain:"ex.com" ~path:"/" "id=123; Expires=0"
16071665 with
16081608- | Some c ->
16661666+ | Ok c ->
16091667 Alcotest.(check (option expiration_testable))
16101668 "expires=0 is session" (Some `Session) (expires c)
16111611- | None -> Alcotest.fail "Should parse Expires=0"
16691669+ | Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg)
1612167016131671let test_serialize_expiration_variants env =
16141672 let clock = Eio.Stdenv.clock env in
···1645170316461704let test_quoted_cookie_values env =
16471705 let clock = Eio.Stdenv.clock env in
16481648- let test_cases =
17061706+ (* Test valid RFC 6265 cookie values:
17071707+ cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
17081708+ Valid cases have either no quotes or properly paired DQUOTE wrapper *)
17091709+ let valid_cases =
16491710 [
16501650- ("name=value", "value", "value");
16511651- ("name=\"value\"", "\"value\"", "value");
16521652- ("name=\"partial", "\"partial", "\"partial");
16531653- ("name=\"val\"\"", "\"val\"\"", "val\"");
16541654- ("name=val\"", "val\"", "val\"");
16551655- ("name=\"\"", "\"\"", "");
17111711+ ("name=value", "value", "value"); (* No quotes *)
17121712+ ("name=\"value\"", "\"value\"", "value"); (* Properly quoted *)
17131713+ ("name=\"\"", "\"\"", ""); (* Empty quoted value *)
16561714 ]
16571715 in
16581716···16651723 |> Option.value ~default:Ptime.epoch)
16661724 ~domain:"ex.com" ~path:"/" input
16671725 with
16681668- | Some c ->
17261726+ | Ok c ->
16691727 Alcotest.(check string)
16701728 (Printf.sprintf "raw value for %s" input)
16711729 expected_raw (value c);
16721730 Alcotest.(check string)
16731731 (Printf.sprintf "trimmed value for %s" input)
16741732 expected_trimmed (value_trimmed c)
16751675- | None -> Alcotest.fail ("Parse failed: " ^ input))
16761676- test_cases
17331733+ | Error msg -> Alcotest.fail ("Parse failed: " ^ input ^ ": " ^ msg))
17341734+ valid_cases;
17351735+17361736+ (* Test invalid RFC 6265 cookie values are rejected *)
17371737+ let invalid_cases =
17381738+ [
17391739+ "name=\"partial"; (* Opening quote without closing *)
17401740+ "name=\"val\"\""; (* Embedded quote *)
17411741+ "name=val\""; (* Trailing quote without opening *)
17421742+ ]
17431743+ in
17441744+17451745+ List.iter
17461746+ (fun input ->
17471747+ match
17481748+ of_set_cookie_header
17491749+ ~now:(fun () ->
17501750+ Ptime.of_float_s (Eio.Time.now clock)
17511751+ |> Option.value ~default:Ptime.epoch)
17521752+ ~domain:"ex.com" ~path:"/" input
17531753+ with
17541754+ | Error _ -> () (* Expected - invalid values are rejected *)
17551755+ | Ok _ ->
17561756+ Alcotest.fail
17571757+ (Printf.sprintf "Should reject invalid value: %s" input))
17581758+ invalid_cases
1677175916781760let test_trimmed_value_not_used_for_equality env =
16791761 let clock = Eio.Stdenv.clock env in
···16851767 |> Option.value ~default:Ptime.epoch)
16861768 ~domain:"ex.com" ~path:"/" "name=\"value\""
16871769 with
16881688- | Some c1 -> begin
17701770+ | Ok c1 -> begin
16891771 match
16901772 of_set_cookie_header
16911773 ~now:(fun () ->
···16931775 |> Option.value ~default:Ptime.epoch)
16941776 ~domain:"ex.com" ~path:"/" "name=value"
16951777 with
16961696- | Some c2 ->
17781778+ | Ok c2 ->
16971779 (* Different raw values *)
16981780 Alcotest.(check bool)
16991781 "different raw values" false
···17011783 (* Same trimmed values *)
17021784 Alcotest.(check string)
17031785 "same trimmed values" (value_trimmed c1) (value_trimmed c2)
17041704- | None -> Alcotest.fail "Parse failed for unquoted"
17861786+ | Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg)
17051787 end
17061706- | None -> Alcotest.fail "Parse failed for quoted"
17881788+ | Error msg -> Alcotest.fail ("Parse failed for quoted: " ^ msg)
1707178917081790(* Priority 2.4: Cookie Header Parsing *)
1709179117101792let test_cookie_header_parsing_basic env =
17111793 let clock = Eio.Stdenv.clock env in
17121712- let results =
17941794+ let result =
17131795 of_cookie_header
17141796 ~now:(fun () ->
17151797 Ptime.of_float_s (Eio.Time.now clock)
···17171799 ~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
17181800 in
1719180117201720- let cookies = List.filter_map Result.to_option results in
17211721- Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
18021802+ match result with
18031803+ | Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
18041804+ | Ok cookies ->
18051805+ Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
1722180617231723- let find name_val = List.find (fun c -> name c = name_val) cookies in
17241724- Alcotest.(check string) "session value" "abc123" (value (find "session"));
17251725- Alcotest.(check string) "theme value" "dark" (value (find "theme"));
17261726- Alcotest.(check string) "lang value" "en" (value (find "lang"))
18071807+ let find name_val = List.find (fun c -> name c = name_val) cookies in
18081808+ Alcotest.(check string) "session value" "abc123" (value (find "session"));
18091809+ Alcotest.(check string) "theme value" "dark" (value (find "theme"));
18101810+ Alcotest.(check string) "lang value" "en" (value (find "lang"))
1727181117281812let test_cookie_header_defaults env =
17291813 let clock = Eio.Stdenv.clock env in
···17351819 |> Option.value ~default:Ptime.epoch)
17361820 ~domain:"example.com" ~path:"/app" "session=xyz"
17371821 with
17381738- | [ Ok c ] ->
18221822+ | Ok [ c ] ->
17391823 (* Domain and path from request context *)
17401824 Alcotest.(check string) "domain from context" "example.com" (domain c);
17411825 Alcotest.(check string) "path from context" "/app" (path c);
···17511835 Alcotest.(check (option span_testable)) "no max_age" None (max_age c);
17521836 Alcotest.(check (option same_site_testable))
17531837 "no same_site" None (same_site c)
17541754- | _ -> Alcotest.fail "Should parse single cookie"
18381838+ | Ok _ -> Alcotest.fail "Should parse single cookie"
18391839+ | Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
1755184017561841let test_cookie_header_edge_cases env =
17571842 let clock = Eio.Stdenv.clock env in
1758184317591844 let test input expected_count description =
17601760- let results =
18451845+ let result =
17611846 of_cookie_header
17621847 ~now:(fun () ->
17631848 Ptime.of_float_s (Eio.Time.now clock)
17641849 |> Option.value ~default:Ptime.epoch)
17651850 ~domain:"ex.com" ~path:"/" input
17661851 in
17671767- let cookies = List.filter_map Result.to_option results in
17681768- Alcotest.(check int) description expected_count (List.length cookies)
18521852+ match result with
18531853+ | Ok cookies ->
18541854+ Alcotest.(check int) description expected_count (List.length cookies)
18551855+ | Error msg ->
18561856+ Alcotest.fail (description ^ " failed: " ^ msg)
17691857 in
1770185817711859 test "" 0 "empty string";
···17771865let test_cookie_header_with_errors env =
17781866 let clock = Eio.Stdenv.clock env in
1779186717801780- (* Mix of valid and invalid cookies *)
17811781- let results =
18681868+ (* Invalid cookie (empty name) should cause entire parse to fail *)
18691869+ let result =
17821870 of_cookie_header
17831871 ~now:(fun () ->
17841872 Ptime.of_float_s (Eio.Time.now clock)
···17861874 ~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
17871875 in
1788187617891789- Alcotest.(check int) "total results" 3 (List.length results);
17901790-17911791- let successes = List.filter Result.is_ok results in
17921792- let errors = List.filter Result.is_error results in
17931793-17941794- Alcotest.(check int) "successful parses" 2 (List.length successes);
17951795- Alcotest.(check int) "failed parses" 1 (List.length errors);
17961796-17971797- (* Error should have descriptive message *)
18771877+ (* Error should have descriptive message about the invalid cookie *)
17981878 let contains_substring s sub =
17991879 try
18001880 let _ = Str.search_forward (Str.regexp_string sub) s 0 in
18011881 true
18021882 with Not_found -> false
18031883 in
18041804- begin match List.hd errors with
18841884+ match result with
18051885 | Error msg ->
18061886 let has_name = contains_substring msg "name" in
18071887 let has_empty = contains_substring msg "empty" in
18081888 Alcotest.(check bool)
18091889 "error mentions name or empty" true (has_name || has_empty)
18101810- | Ok _ -> Alcotest.fail "Expected error"
18111811- end
18901890+ | Ok _ -> Alcotest.fail "Expected error for empty cookie name"
1812189118131892(* Max-Age and Expires Interaction *)
18141893···18671946 ~domain:"ex.com" ~path:"/"
18681947 "id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT"
18691948 with
18701870- | Some c ->
19491949+ | Ok c ->
18711950 (* Both should be stored *)
18721951 begin match max_age c with
18731952 | Some span -> begin
···18831962 | Some (`DateTime _) -> ()
18841963 | _ -> Alcotest.fail "expires should be parsed"
18851964 end
18861886- | None -> Alcotest.fail "Should parse cookie with both attributes"
19651965+ | Error msg -> Alcotest.fail ("Should parse cookie with both attributes: " ^ msg)
1887196618881967(* ============================================================================ *)
18891968(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
···19031982 |> Option.value ~default:Ptime.epoch)
19041983 ~domain:"example.com" ~path:"/" header
19051984 in
19061906- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
19071907- let cookie = Option.get cookie_opt in
19851985+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
19861986+ let cookie = Result.get_ok cookie_opt in
19081987 Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
19091988 Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
19101989···19222001 |> Option.value ~default:Ptime.epoch)
19232002 ~domain:"example.com" ~path:"/" header
19242003 in
19251925- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
19261926- let cookie = Option.get cookie_opt in
20042004+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
20052005+ let cookie = Result.get_ok cookie_opt in
19272006 Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
19282007 Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
19292008···19412020 |> Option.value ~default:Ptime.epoch)
19422021 ~domain:"example.com" ~path:"/" header
19432022 in
19441944- Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
19451945- let cookie = Option.get cookie_opt in
20232023+ Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
20242024+ let cookie = Result.get_ok cookie_opt in
19462025 Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
19472026 Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
19482027···19912070 Eio_mock.Clock.set_time clock 1000.0;
1992207119932072 (* Cookies from Cookie header should have host_only=true *)
19941994- let results =
20732073+ let result =
19952074 of_cookie_header
19962075 ~now:(fun () ->
19972076 Ptime.of_float_s (Eio.Time.now clock)
19982077 |> Option.value ~default:Ptime.epoch)
19992078 ~domain:"example.com" ~path:"/" "session=abc; theme=dark"
20002079 in
20012001- let cookies = List.filter_map Result.to_option results in
20022002- Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
20032003- List.iter (fun c ->
20042004- Alcotest.(check bool)
20052005- ("host_only is true for " ^ Cookeio.name c)
20062006- true (Cookeio.host_only c)
20072007- ) cookies
20802080+ match result with
20812081+ | Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
20822082+ | Ok cookies ->
20832083+ Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
20842084+ List.iter (fun c ->
20852085+ Alcotest.(check bool)
20862086+ ("host_only is true for " ^ Cookeio.name c)
20872087+ true (Cookeio.host_only c)
20882088+ ) cookies
2008208920092090let test_host_only_mozilla_format_round_trip () =
20102091 Eio_mock.Backend.run @@ fun () ->
···22072288 Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
2208228922092290(* ============================================================================ *)
22912291+(* Cookie Ordering Tests (RFC 6265 Section 5.4, Step 2) *)
22922292+(* ============================================================================ *)
22932293+22942294+let test_cookie_ordering_by_path_length () =
22952295+ Eio_mock.Backend.run @@ fun () ->
22962296+ let clock = Eio_mock.Clock.make () in
22972297+ Eio_mock.Clock.set_time clock 1000.0;
22982298+22992299+ let jar = create () in
23002300+23012301+ (* Add cookies with different path lengths, but same creation time *)
23022302+ let cookie_short =
23032303+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"short" ~value:"v1"
23042304+ ~secure:false ~http_only:false
23052305+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
23062306+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
23072307+ in
23082308+ let cookie_medium =
23092309+ Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"medium" ~value:"v2"
23102310+ ~secure:false ~http_only:false
23112311+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
23122312+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
23132313+ in
23142314+ let cookie_long =
23152315+ Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"long" ~value:"v3"
23162316+ ~secure:false ~http_only:false
23172317+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
23182318+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
23192319+ in
23202320+23212321+ (* Add in random order *)
23222322+ add_cookie jar cookie_short;
23232323+ add_cookie jar cookie_long;
23242324+ add_cookie jar cookie_medium;
23252325+23262326+ (* Get cookies for path /foo/bar/baz - all three should match *)
23272327+ let cookies =
23282328+ get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
23292329+ in
23302330+23312331+ Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
23322332+23332333+ (* Verify order: longest path first *)
23342334+ let names = List.map Cookeio.name cookies in
23352335+ Alcotest.(check (list string))
23362336+ "cookies ordered by path length (longest first)"
23372337+ [ "long"; "medium"; "short" ]
23382338+ names
23392339+23402340+let test_cookie_ordering_by_creation_time () =
23412341+ Eio_mock.Backend.run @@ fun () ->
23422342+ let clock = Eio_mock.Clock.make () in
23432343+ Eio_mock.Clock.set_time clock 2000.0;
23442344+23452345+ let jar = create () in
23462346+23472347+ (* Add cookies with same path but different creation times *)
23482348+ let cookie_new =
23492349+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
23502350+ ~secure:false ~http_only:false
23512351+ ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
23522352+ ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
23532353+ in
23542354+ let cookie_old =
23552355+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"old" ~value:"v2"
23562356+ ~secure:false ~http_only:false
23572357+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
23582358+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
23592359+ in
23602360+ let cookie_middle =
23612361+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"middle" ~value:"v3"
23622362+ ~secure:false ~http_only:false
23632363+ ~creation_time:(Ptime.of_float_s 1200.0 |> Option.get)
23642364+ ~last_access:(Ptime.of_float_s 1200.0 |> Option.get) ()
23652365+ in
23662366+23672367+ (* Add in random order *)
23682368+ add_cookie jar cookie_new;
23692369+ add_cookie jar cookie_old;
23702370+ add_cookie jar cookie_middle;
23712371+23722372+ let cookies =
23732373+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
23742374+ in
23752375+23762376+ Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
23772377+23782378+ (* Verify order: earlier creation time first (for same path length) *)
23792379+ let names = List.map Cookeio.name cookies in
23802380+ Alcotest.(check (list string))
23812381+ "cookies ordered by creation time (earliest first)"
23822382+ [ "old"; "middle"; "new" ]
23832383+ names
23842384+23852385+let test_cookie_ordering_combined () =
23862386+ Eio_mock.Backend.run @@ fun () ->
23872387+ let clock = Eio_mock.Clock.make () in
23882388+ Eio_mock.Clock.set_time clock 2000.0;
23892389+23902390+ let jar = create () in
23912391+23922392+ (* Mix of different paths and creation times *)
23932393+ let cookie_a =
23942394+ Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"a" ~value:"v1"
23952395+ ~secure:false ~http_only:false
23962396+ ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
23972397+ ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
23982398+ in
23992399+ let cookie_b =
24002400+ Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"b" ~value:"v2"
24012401+ ~secure:false ~http_only:false
24022402+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
24032403+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
24042404+ in
24052405+ let cookie_c =
24062406+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"c" ~value:"v3"
24072407+ ~secure:false ~http_only:false
24082408+ ~creation_time:(Ptime.of_float_s 500.0 |> Option.get)
24092409+ ~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
24102410+ in
24112411+24122412+ add_cookie jar cookie_a;
24132413+ add_cookie jar cookie_c;
24142414+ add_cookie jar cookie_b;
24152415+24162416+ let cookies =
24172417+ get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
24182418+ in
24192419+24202420+ Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
24212421+24222422+ (* /foo cookies (length 4) should come before / cookie (length 1)
24232423+ Within /foo, earlier creation time (b=1000) should come before (a=1500) *)
24242424+ let names = List.map Cookeio.name cookies in
24252425+ Alcotest.(check (list string))
24262426+ "cookies ordered by path length then creation time"
24272427+ [ "b"; "a"; "c" ]
24282428+ names
24292429+24302430+(* ============================================================================ *)
24312431+(* Creation Time Preservation Tests (RFC 6265 Section 5.3, Step 11.3) *)
24322432+(* ============================================================================ *)
24332433+24342434+let test_creation_time_preserved_on_update () =
24352435+ Eio_mock.Backend.run @@ fun () ->
24362436+ let clock = Eio_mock.Clock.make () in
24372437+ Eio_mock.Clock.set_time clock 1000.0;
24382438+24392439+ let jar = create () in
24402440+24412441+ (* Add initial cookie with creation_time=500 *)
24422442+ let original_creation = Ptime.of_float_s 500.0 |> Option.get in
24432443+ let cookie_v1 =
24442444+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v1"
24452445+ ~secure:false ~http_only:false
24462446+ ~creation_time:original_creation
24472447+ ~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
24482448+ in
24492449+ add_cookie jar cookie_v1;
24502450+24512451+ (* Update the cookie with a new value (creation_time=1000) *)
24522452+ Eio_mock.Clock.set_time clock 1500.0;
24532453+ let cookie_v2 =
24542454+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v2"
24552455+ ~secure:false ~http_only:false
24562456+ ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
24572457+ ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
24582458+ in
24592459+ add_cookie jar cookie_v2;
24602460+24612461+ (* Get the cookie and verify creation_time was preserved *)
24622462+ let cookies =
24632463+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
24642464+ in
24652465+ Alcotest.(check int) "still one cookie" 1 (List.length cookies);
24662466+24672467+ let cookie = List.hd cookies in
24682468+ Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
24692469+24702470+ (* Creation time should be preserved from original cookie *)
24712471+ let creation_float =
24722472+ Ptime.to_float_s (Cookeio.creation_time cookie)
24732473+ in
24742474+ Alcotest.(check (float 0.001))
24752475+ "creation_time preserved from original"
24762476+ 500.0 creation_float
24772477+24782478+let test_creation_time_preserved_add_original () =
24792479+ Eio_mock.Backend.run @@ fun () ->
24802480+ let clock = Eio_mock.Clock.make () in
24812481+ Eio_mock.Clock.set_time clock 1000.0;
24822482+24832483+ let jar = create () in
24842484+24852485+ (* Add initial original cookie *)
24862486+ let original_creation = Ptime.of_float_s 100.0 |> Option.get in
24872487+ let cookie_v1 =
24882488+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v1"
24892489+ ~secure:false ~http_only:false
24902490+ ~creation_time:original_creation
24912491+ ~last_access:(Ptime.of_float_s 100.0 |> Option.get) ()
24922492+ in
24932493+ add_original jar cookie_v1;
24942494+24952495+ (* Replace with new original cookie *)
24962496+ let cookie_v2 =
24972497+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v2"
24982498+ ~secure:false ~http_only:false
24992499+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
25002500+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
25012501+ in
25022502+ add_original jar cookie_v2;
25032503+25042504+ let cookies = get_all_cookies jar in
25052505+ Alcotest.(check int) "still one cookie" 1 (List.length cookies);
25062506+25072507+ let cookie = List.hd cookies in
25082508+ Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
25092509+25102510+ (* Creation time should be preserved *)
25112511+ let creation_float =
25122512+ Ptime.to_float_s (Cookeio.creation_time cookie)
25132513+ in
25142514+ Alcotest.(check (float 0.001))
25152515+ "creation_time preserved in add_original"
25162516+ 100.0 creation_float
25172517+25182518+let test_creation_time_new_cookie () =
25192519+ Eio_mock.Backend.run @@ fun () ->
25202520+ let clock = Eio_mock.Clock.make () in
25212521+ Eio_mock.Clock.set_time clock 1000.0;
25222522+25232523+ let jar = create () in
25242524+25252525+ (* Add a new cookie (no existing cookie to preserve from) *)
25262526+ let cookie =
25272527+ Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
25282528+ ~secure:false ~http_only:false
25292529+ ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
25302530+ ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
25312531+ in
25322532+ add_cookie jar cookie;
25332533+25342534+ let cookies =
25352535+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
25362536+ in
25372537+ let cookie = List.hd cookies in
25382538+25392539+ (* New cookie should keep its own creation time *)
25402540+ let creation_float =
25412541+ Ptime.to_float_s (Cookeio.creation_time cookie)
25422542+ in
25432543+ Alcotest.(check (float 0.001))
25442544+ "new cookie keeps its creation_time"
25452545+ 1000.0 creation_float
25462546+25472547+(* ============================================================================ *)
22102548(* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *)
22112549(* ============================================================================ *)
22122550···23612699 Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3);
23622700 Alcotest.(check string) "IP cookie is returned" "ip" (Cookeio.name (List.hd cookies3))
2363270127022702+(* ============================================================================ *)
27032703+(* RFC 6265 Validation Tests *)
27042704+(* ============================================================================ *)
27052705+27062706+let test_validate_cookie_name_valid () =
27072707+ (* Valid token characters per RFC 2616 *)
27082708+ let valid_names = ["session"; "SID"; "my-cookie"; "COOKIE_123"; "abc.def"] in
27092709+ List.iter (fun name ->
27102710+ match Cookeio.Validate.cookie_name name with
27112711+ | Ok _ -> ()
27122712+ | Error msg ->
27132713+ Alcotest.fail (Printf.sprintf "Name %S should be valid: %s" name msg))
27142714+ valid_names
27152715+27162716+let test_validate_cookie_name_invalid () =
27172717+ (* Invalid: control chars, separators, spaces *)
27182718+ let invalid_names =
27192719+ [
27202720+ ("", "empty");
27212721+ ("my cookie", "space");
27222722+ ("cookie=value", "equals");
27232723+ ("my;cookie", "semicolon");
27242724+ ("name\t", "tab");
27252725+ ("(cookie)", "parens");
27262726+ ("name,val", "comma");
27272727+ ]
27282728+ in
27292729+ List.iter (fun (name, reason) ->
27302730+ match Cookeio.Validate.cookie_name name with
27312731+ | Error _ -> () (* Expected *)
27322732+ | Ok _ ->
27332733+ Alcotest.fail
27342734+ (Printf.sprintf "Name %S (%s) should be invalid" name reason))
27352735+ invalid_names
27362736+27372737+let test_validate_cookie_value_valid () =
27382738+ (* Valid cookie-octets or quoted values *)
27392739+ let valid_values = ["abc123"; "value!#$%&'()*+-./"; "\"quoted\""; ""] in
27402740+ List.iter (fun value ->
27412741+ match Cookeio.Validate.cookie_value value with
27422742+ | Ok _ -> ()
27432743+ | Error msg ->
27442744+ Alcotest.fail (Printf.sprintf "Value %S should be valid: %s" value msg))
27452745+ valid_values
27462746+27472747+let test_validate_cookie_value_invalid () =
27482748+ (* Invalid: space, comma, semicolon, backslash, unmatched quotes *)
27492749+ let invalid_values =
27502750+ [
27512751+ ("with space", "space");
27522752+ ("with,comma", "comma");
27532753+ ("with;semi", "semicolon");
27542754+ ("back\\slash", "backslash");
27552755+ ("\"unmatched", "unmatched opening quote");
27562756+ ("unmatched\"", "unmatched closing quote");
27572757+ ]
27582758+ in
27592759+ List.iter (fun (value, reason) ->
27602760+ match Cookeio.Validate.cookie_value value with
27612761+ | Error _ -> () (* Expected *)
27622762+ | Ok _ ->
27632763+ Alcotest.fail
27642764+ (Printf.sprintf "Value %S (%s) should be invalid" value reason))
27652765+ invalid_values
27662766+27672767+let test_validate_domain_valid () =
27682768+ (* Valid domain names and IP addresses *)
27692769+ let valid_domains =
27702770+ ["example.com"; "sub.example.com"; ".example.com"; "192.168.1.1"; "::1"]
27712771+ in
27722772+ List.iter (fun domain ->
27732773+ match Cookeio.Validate.domain_value domain with
27742774+ | Ok _ -> ()
27752775+ | Error msg ->
27762776+ Alcotest.fail (Printf.sprintf "Domain %S should be valid: %s" domain msg))
27772777+ valid_domains
27782778+27792779+let test_validate_domain_invalid () =
27802780+ (* Invalid domain names - only test cases that domain-name library rejects.
27812781+ Note: domain-name library has specific rules that may differ from what
27822782+ we might expect from the RFC. *)
27832783+ let invalid_domains =
27842784+ [
27852785+ ("", "empty");
27862786+ (* Note: "-invalid.com" and "invalid-.com" are valid per domain-name library *)
27872787+ ]
27882788+ in
27892789+ List.iter (fun (domain, reason) ->
27902790+ match Cookeio.Validate.domain_value domain with
27912791+ | Error _ -> () (* Expected *)
27922792+ | Ok _ ->
27932793+ Alcotest.fail
27942794+ (Printf.sprintf "Domain %S (%s) should be invalid" domain reason))
27952795+ invalid_domains
27962796+27972797+let test_validate_path_valid () =
27982798+ let valid_paths = ["/"; "/path"; "/path/to/resource"; "/path?query"] in
27992799+ List.iter (fun path ->
28002800+ match Cookeio.Validate.path_value path with
28012801+ | Ok _ -> ()
28022802+ | Error msg ->
28032803+ Alcotest.fail (Printf.sprintf "Path %S should be valid: %s" path msg))
28042804+ valid_paths
28052805+28062806+let test_validate_path_invalid () =
28072807+ let invalid_paths =
28082808+ [
28092809+ ("/path;bad", "semicolon");
28102810+ ("/path\x00bad", "control char");
28112811+ ]
28122812+ in
28132813+ List.iter (fun (path, reason) ->
28142814+ match Cookeio.Validate.path_value path with
28152815+ | Error _ -> () (* Expected *)
28162816+ | Ok _ ->
28172817+ Alcotest.fail
28182818+ (Printf.sprintf "Path %S (%s) should be invalid" path reason))
28192819+ invalid_paths
28202820+28212821+let test_duplicate_cookie_detection () =
28222822+ Eio_mock.Backend.run @@ fun () ->
28232823+ let clock = Eio_mock.Clock.make () in
28242824+ Eio_mock.Clock.set_time clock 1000.0;
28252825+28262826+ (* Duplicate cookie names should be rejected *)
28272827+ let result =
28282828+ of_cookie_header
28292829+ ~now:(fun () ->
28302830+ Ptime.of_float_s (Eio.Time.now clock)
28312831+ |> Option.value ~default:Ptime.epoch)
28322832+ ~domain:"example.com" ~path:"/" "session=abc; theme=dark; session=xyz"
28332833+ in
28342834+ match result with
28352835+ | Error msg ->
28362836+ (* Should mention duplicate *)
28372837+ let contains_dup = String.lowercase_ascii msg |> fun s ->
28382838+ try let _ = Str.search_forward (Str.regexp_string "duplicate") s 0 in true
28392839+ with Not_found -> false
28402840+ in
28412841+ Alcotest.(check bool) "error mentions duplicate" true contains_dup
28422842+ | Ok _ -> Alcotest.fail "Should reject duplicate cookie names"
28432843+28442844+let test_validation_error_messages () =
28452845+ Eio_mock.Backend.run @@ fun () ->
28462846+ let clock = Eio_mock.Clock.make () in
28472847+ Eio_mock.Clock.set_time clock 1000.0;
28482848+28492849+ (* Test that error messages are descriptive *)
28502850+ let test_cases =
28512851+ [
28522852+ ("=noname", "Cookie name is empty");
28532853+ ("bad cookie=value", "invalid characters");
28542854+ ("name=val ue", "invalid characters");
28552855+ ]
28562856+ in
28572857+ List.iter (fun (header, expected_substring) ->
28582858+ match
28592859+ of_set_cookie_header
28602860+ ~now:(fun () ->
28612861+ Ptime.of_float_s (Eio.Time.now clock)
28622862+ |> Option.value ~default:Ptime.epoch)
28632863+ ~domain:"example.com" ~path:"/" header
28642864+ with
28652865+ | Error msg ->
28662866+ let has_substring =
28672867+ try
28682868+ let _ = Str.search_forward
28692869+ (Str.regexp_string expected_substring) msg 0 in
28702870+ true
28712871+ with Not_found -> false
28722872+ in
28732873+ Alcotest.(check bool)
28742874+ (Printf.sprintf "error for %S mentions %S" header expected_substring)
28752875+ true has_substring
28762876+ | Ok _ ->
28772877+ Alcotest.fail (Printf.sprintf "Should reject %S" header))
28782878+ test_cases
28792879+28802880+(* ============================================================================ *)
28812881+(* Public Suffix Validation Tests (RFC 6265 Section 5.3, Step 5) *)
28822882+(* ============================================================================ *)
28832883+28842884+let test_public_suffix_rejection () =
28852885+ Eio_mock.Backend.run @@ fun () ->
28862886+ let clock = Eio_mock.Clock.make () in
28872887+ Eio_mock.Clock.set_time clock 1000.0;
28882888+28892889+ (* Setting a cookie for a public suffix (TLD) should be rejected *)
28902890+ let test_cases =
28912891+ [
28922892+ (* (request_domain, cookie_domain, description) *)
28932893+ ("www.example.com", "com", "TLD .com");
28942894+ ("www.example.co.uk", "co.uk", "ccTLD .co.uk");
28952895+ ("foo.bar.github.io", "github.io", "private domain github.io");
28962896+ ]
28972897+ in
28982898+28992899+ List.iter
29002900+ (fun (request_domain, cookie_domain, description) ->
29012901+ let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
29022902+ let result =
29032903+ of_set_cookie_header
29042904+ ~now:(fun () ->
29052905+ Ptime.of_float_s (Eio.Time.now clock)
29062906+ |> Option.value ~default:Ptime.epoch)
29072907+ ~domain:request_domain ~path:"/" header
29082908+ in
29092909+ match result with
29102910+ | Error msg ->
29112911+ (* Should mention public suffix *)
29122912+ let has_psl =
29132913+ String.lowercase_ascii msg |> fun s ->
29142914+ try
29152915+ let _ = Str.search_forward (Str.regexp_string "public suffix") s 0 in
29162916+ true
29172917+ with Not_found -> false
29182918+ in
29192919+ Alcotest.(check bool)
29202920+ (Printf.sprintf "%s: error mentions public suffix" description)
29212921+ true has_psl
29222922+ | Ok _ ->
29232923+ Alcotest.fail
29242924+ (Printf.sprintf "Should reject cookie for %s" description))
29252925+ test_cases
29262926+29272927+let test_public_suffix_allowed_when_exact_match () =
29282928+ Eio_mock.Backend.run @@ fun () ->
29292929+ let clock = Eio_mock.Clock.make () in
29302930+ Eio_mock.Clock.set_time clock 1000.0;
29312931+29322932+ (* If request host exactly matches the public suffix domain, allow it.
29332933+ This is rare but possible for private domains like blogspot.com *)
29342934+ let header = "session=abc; Domain=.blogspot.com" in
29352935+ let result =
29362936+ of_set_cookie_header
29372937+ ~now:(fun () ->
29382938+ Ptime.of_float_s (Eio.Time.now clock)
29392939+ |> Option.value ~default:Ptime.epoch)
29402940+ ~domain:"blogspot.com" ~path:"/" header
29412941+ in
29422942+ Alcotest.(check bool)
29432943+ "exact match allows public suffix" true
29442944+ (Result.is_ok result)
29452945+29462946+let test_non_public_suffix_allowed () =
29472947+ Eio_mock.Backend.run @@ fun () ->
29482948+ let clock = Eio_mock.Clock.make () in
29492949+ Eio_mock.Clock.set_time clock 1000.0;
29502950+29512951+ (* Normal domain (not a public suffix) should be allowed *)
29522952+ let test_cases =
29532953+ [
29542954+ ("www.example.com", "example.com", "registrable domain");
29552955+ ("sub.example.com", "example.com", "parent of subdomain");
29562956+ ("www.example.co.uk", "example.co.uk", "registrable domain under ccTLD");
29572957+ ]
29582958+ in
29592959+29602960+ List.iter
29612961+ (fun (request_domain, cookie_domain, description) ->
29622962+ let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
29632963+ let result =
29642964+ of_set_cookie_header
29652965+ ~now:(fun () ->
29662966+ Ptime.of_float_s (Eio.Time.now clock)
29672967+ |> Option.value ~default:Ptime.epoch)
29682968+ ~domain:request_domain ~path:"/" header
29692969+ in
29702970+ match result with
29712971+ | Ok cookie ->
29722972+ Alcotest.(check string)
29732973+ (Printf.sprintf "%s: domain correct" description)
29742974+ cookie_domain (Cookeio.domain cookie)
29752975+ | Error msg ->
29762976+ Alcotest.fail
29772977+ (Printf.sprintf "%s should be allowed: %s" description msg))
29782978+ test_cases
29792979+29802980+let test_public_suffix_no_domain_attribute () =
29812981+ Eio_mock.Backend.run @@ fun () ->
29822982+ let clock = Eio_mock.Clock.make () in
29832983+ Eio_mock.Clock.set_time clock 1000.0;
29842984+29852985+ (* Cookie without Domain attribute should always be allowed (host-only) *)
29862986+ let header = "session=abc; Secure; HttpOnly" in
29872987+ let result =
29882988+ of_set_cookie_header
29892989+ ~now:(fun () ->
29902990+ Ptime.of_float_s (Eio.Time.now clock)
29912991+ |> Option.value ~default:Ptime.epoch)
29922992+ ~domain:"www.example.com" ~path:"/" header
29932993+ in
29942994+ match result with
29952995+ | Ok cookie ->
29962996+ Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
29972997+ Alcotest.(check string)
29982998+ "domain is request domain" "www.example.com"
29992999+ (Cookeio.domain cookie)
30003000+ | Error msg -> Alcotest.fail ("Should allow host-only cookie: " ^ msg)
30013001+30023002+let test_public_suffix_ip_address_bypass () =
30033003+ Eio_mock.Backend.run @@ fun () ->
30043004+ let clock = Eio_mock.Clock.make () in
30053005+ Eio_mock.Clock.set_time clock 1000.0;
30063006+30073007+ (* IP addresses should bypass PSL check *)
30083008+ let header = "session=abc; Domain=192.168.1.1" in
30093009+ let result =
30103010+ of_set_cookie_header
30113011+ ~now:(fun () ->
30123012+ Ptime.of_float_s (Eio.Time.now clock)
30133013+ |> Option.value ~default:Ptime.epoch)
30143014+ ~domain:"192.168.1.1" ~path:"/" header
30153015+ in
30163016+ Alcotest.(check bool)
30173017+ "IP address bypasses PSL" true
30183018+ (Result.is_ok result)
30193019+30203020+let test_public_suffix_case_insensitive () =
30213021+ Eio_mock.Backend.run @@ fun () ->
30223022+ let clock = Eio_mock.Clock.make () in
30233023+ Eio_mock.Clock.set_time clock 1000.0;
30243024+30253025+ (* Public suffix check should be case-insensitive *)
30263026+ let header = "session=abc; Domain=.COM" in
30273027+ let result =
30283028+ of_set_cookie_header
30293029+ ~now:(fun () ->
30303030+ Ptime.of_float_s (Eio.Time.now clock)
30313031+ |> Option.value ~default:Ptime.epoch)
30323032+ ~domain:"www.example.COM" ~path:"/" header
30333033+ in
30343034+ Alcotest.(check bool)
30353035+ "uppercase TLD still rejected" true
30363036+ (Result.is_error result)
30373037+23643038let () =
23653039 Eio_main.run @@ fun env ->
23663040 let open Alcotest in
···23883062 [
23893063 test_case "Cookie expiry with mock clock" `Quick
23903064 test_cookie_expiry_with_mock_clock;
30653065+ test_case "get_cookies filters expired cookies" `Quick
30663066+ test_get_cookies_filters_expired;
23913067 test_case "Max-Age parsing with mock clock" `Quick
23923068 test_max_age_parsing_with_mock_clock;
23933069 test_case "Last access time with mock clock" `Quick
···25303206 test_case "IPv6 exact match" `Quick test_ipv6_exact_match;
25313207 test_case "IPv6 full format" `Quick test_ipv6_full_format;
25323208 test_case "IP vs hostname behavior" `Quick test_ip_vs_hostname;
32093209+ ] );
32103210+ ( "rfc6265_validation",
32113211+ [
32123212+ test_case "valid cookie names" `Quick test_validate_cookie_name_valid;
32133213+ test_case "invalid cookie names" `Quick test_validate_cookie_name_invalid;
32143214+ test_case "valid cookie values" `Quick test_validate_cookie_value_valid;
32153215+ test_case "invalid cookie values" `Quick test_validate_cookie_value_invalid;
32163216+ test_case "valid domain values" `Quick test_validate_domain_valid;
32173217+ test_case "invalid domain values" `Quick test_validate_domain_invalid;
32183218+ test_case "valid path values" `Quick test_validate_path_valid;
32193219+ test_case "invalid path values" `Quick test_validate_path_invalid;
32203220+ test_case "duplicate cookie detection" `Quick test_duplicate_cookie_detection;
32213221+ test_case "validation error messages" `Quick test_validation_error_messages;
32223222+ ] );
32233223+ ( "cookie_ordering",
32243224+ [
32253225+ test_case "ordering by path length" `Quick
32263226+ test_cookie_ordering_by_path_length;
32273227+ test_case "ordering by creation time" `Quick
32283228+ test_cookie_ordering_by_creation_time;
32293229+ test_case "ordering combined" `Quick test_cookie_ordering_combined;
32303230+ ] );
32313231+ ( "creation_time_preservation",
32323232+ [
32333233+ test_case "preserved on update" `Quick
32343234+ test_creation_time_preserved_on_update;
32353235+ test_case "preserved in add_original" `Quick
32363236+ test_creation_time_preserved_add_original;
32373237+ test_case "new cookie keeps time" `Quick test_creation_time_new_cookie;
32383238+ ] );
32393239+ ( "public_suffix_validation",
32403240+ [
32413241+ test_case "reject public suffix domains" `Quick
32423242+ test_public_suffix_rejection;
32433243+ test_case "allow exact match on public suffix" `Quick
32443244+ test_public_suffix_allowed_when_exact_match;
32453245+ test_case "allow non-public-suffix domains" `Quick
32463246+ test_non_public_suffix_allowed;
32473247+ test_case "no Domain attribute bypasses PSL" `Quick
32483248+ test_public_suffix_no_domain_attribute;
32493249+ test_case "IP address bypasses PSL" `Quick
32503250+ test_public_suffix_ip_address_bypass;
32513251+ test_case "case insensitive check" `Quick
32523252+ test_public_suffix_case_insensitive;
25333253 ] );
25343254 ]