OCaml HTTP cookie handling library with support for Eio-based storage jars
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
7
8module Log = (val Logs.src_log src : Logs.LOG)
9
10(** SameSite attribute for cross-site request control.
11
12 The SameSite attribute is defined in the RFC 6265bis draft and controls
13 whether cookies are sent with cross-site requests.
14
15 @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - The SameSite Attribute *)
16module SameSite = struct
17 type t = [ `Strict | `Lax | `None ]
18
19 let equal = ( = )
20
21 let pp ppf = function
22 | `Strict -> Format.pp_print_string ppf "Strict"
23 | `Lax -> Format.pp_print_string ppf "Lax"
24 | `None -> Format.pp_print_string ppf "None"
25end
26
27(** Cookie expiration type.
28
29 Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3},
30 cookies have either a persistent expiry time or are session cookies that
31 expire when the user agent session ends.
32
33 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
34module Expiration = struct
35 type t = [ `Session | `DateTime of Ptime.t ]
36
37 let equal e1 e2 =
38 match (e1, e2) with
39 | `Session, `Session -> true
40 | `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
41 | _ -> false
42
43 let pp ppf = function
44 | `Session -> Format.pp_print_string ppf "Session"
45 | `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
46end
47
48type t = {
49 domain : string;
50 path : string;
51 name : string;
52 value : string;
53 secure : bool;
54 http_only : bool;
55 partitioned : bool;
56 host_only : bool;
57 expires : Expiration.t option;
58 max_age : Ptime.Span.t option;
59 same_site : SameSite.t option;
60 creation_time : Ptime.t;
61 last_access : Ptime.t;
62}
63(** HTTP Cookie *)
64
65(** {1 Cookie Accessors} *)
66
67let domain cookie = cookie.domain
68let path cookie = cookie.path
69let name cookie = cookie.name
70let value cookie = cookie.value
71
72let value_trimmed cookie =
73 let v = cookie.value in
74 let len = String.length v in
75 if len < 2 then v
76 else
77 match (v.[0], v.[len - 1]) with
78 | '"', '"' -> String.sub v 1 (len - 2)
79 | _ -> v
80
81let secure cookie = cookie.secure
82let http_only cookie = cookie.http_only
83let partitioned cookie = cookie.partitioned
84let host_only cookie = cookie.host_only
85let expires cookie = cookie.expires
86let max_age cookie = cookie.max_age
87let same_site cookie = cookie.same_site
88let creation_time cookie = cookie.creation_time
89let last_access cookie = cookie.last_access
90
91let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
92 ?expires ?max_age ?same_site ?(partitioned = false) ?(host_only = false)
93 ~creation_time ~last_access () =
94 {
95 domain;
96 path;
97 name;
98 value;
99 secure;
100 http_only;
101 partitioned;
102 host_only;
103 expires;
104 max_age;
105 same_site;
106 creation_time;
107 last_access;
108 }
109
110(** {1 RFC 6265 Validation}
111
112 Validation functions for cookie names, values, and attributes per
113 {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1} RFC 6265 Section 4.1.1}.
114
115 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 - Syntax *)
116module Validate = struct
117 (** Check if a character is a valid RFC 2616 token character.
118
119 Per RFC 6265, cookie-name must be a token as defined in RFC 2616 Section 2.2:
120 token = 1*<any CHAR except CTLs or separators>
121 separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" |
122 <"> | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
123
124 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
125 let is_token_char = function
126 | '\x00' .. '\x1F' | '\x7F' -> false (* CTL characters *)
127 | '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '\\' | '"' | '/' | '['
128 | ']' | '?' | '=' | '{' | '}' | ' ' ->
129 false (* separators - note: HT (0x09) is already covered by CTL range *)
130 | _ -> true
131
132 (** Validate a cookie name per RFC 6265.
133
134 Cookie names must be valid RFC 2616 tokens: one or more characters
135 excluding control characters and separators.
136
137 @param name The cookie name to validate
138 @return [Ok name] if valid, [Error message] with explanation if invalid
139
140 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
141 let cookie_name name =
142 let len = String.length name in
143 if len = 0 then
144 Error "Cookie name is empty; RFC 6265 requires at least one character"
145 else
146 let rec find_invalid i acc =
147 if i >= len then acc
148 else
149 let c = String.unsafe_get name i in
150 if is_token_char c then find_invalid (i + 1) acc
151 else find_invalid (i + 1) (c :: acc)
152 in
153 match find_invalid 0 [] with
154 | [] -> Ok name
155 | invalid_chars ->
156 let chars_str =
157 invalid_chars
158 |> List.rev
159 |> List.map (fun c -> Printf.sprintf "%C" c)
160 |> String.concat ", "
161 in
162 Error
163 (Printf.sprintf
164 "Cookie name %S contains invalid characters: %s. RFC 6265 requires \
165 cookie names to be valid tokens (no control characters, spaces, \
166 or separators like ()[]{}=,;:@\\\"/?<>)"
167 name chars_str)
168
169 (** Check if a character is a valid cookie-octet.
170
171 Per RFC 6265 Section 4.1.1:
172 cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
173 (US-ASCII excluding CTLs, whitespace, DQUOTE, comma, semicolon, backslash)
174
175 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
176 let is_cookie_octet = function
177 | '\x21' -> true (* ! *)
178 | '\x23' .. '\x2B' -> true (* # $ % & ' ( ) * + *)
179 | '\x2D' .. '\x3A' -> true (* - . / 0-9 : *)
180 | '\x3C' .. '\x5B' -> true (* < = > ? @ A-Z [ *)
181 | '\x5D' .. '\x7E' -> true (* ] ^ _ ` a-z { | } ~ *)
182 | _ -> false
183
184 (** Validate a cookie value per RFC 6265.
185
186 Cookie values must contain only cookie-octets, optionally wrapped in
187 double quotes. Invalid characters include: control characters, space,
188 double quote (except as wrapper), comma, semicolon, and backslash.
189
190 @param value The cookie value to validate
191 @return [Ok value] if valid, [Error message] with explanation if invalid
192
193 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
194 let cookie_value value =
195 (* Handle optional DQUOTE wrapper *)
196 let len = String.length value in
197 let inner_value, inner_len =
198 if len >= 2 && value.[0] = '"' && value.[len - 1] = '"' then
199 (String.sub value 1 (len - 2), len - 2)
200 else (value, len)
201 in
202 let rec find_invalid i acc =
203 if i >= inner_len then acc
204 else
205 let c = String.unsafe_get inner_value i in
206 if is_cookie_octet c then find_invalid (i + 1) acc
207 else find_invalid (i + 1) (c :: acc)
208 in
209 match find_invalid 0 [] with
210 | [] -> Ok value
211 | invalid_chars ->
212 let chars_str =
213 invalid_chars
214 |> List.rev
215 |> List.map (fun c ->
216 match c with
217 | ' ' -> "space (0x20)"
218 | '"' -> "double-quote (0x22)"
219 | ',' -> "comma (0x2C)"
220 | ';' -> "semicolon (0x3B)"
221 | '\\' -> "backslash (0x5C)"
222 | c when Char.code c < 0x20 ->
223 Printf.sprintf "control char (0x%02X)" (Char.code c)
224 | c -> Printf.sprintf "%C (0x%02X)" c (Char.code c))
225 |> String.concat ", "
226 in
227 Error
228 (Printf.sprintf
229 "Cookie value %S contains invalid characters: %s. RFC 6265 cookie \
230 values may only contain printable ASCII excluding space, \
231 double-quote, comma, semicolon, and backslash"
232 value chars_str)
233
234 (** Validate a domain attribute value.
235
236 Domain values must be either:
237 - A valid domain name per RFC 1034 Section 3.5
238 - A valid IPv4 address
239 - A valid IPv6 address
240
241 @param domain The domain value to validate (leading dot is stripped first)
242 @return [Ok domain] if valid, [Error message] with explanation if invalid
243
244 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3> RFC 6265 Section 4.1.2.3
245 @see <https://datatracker.ietf.org/doc/html/rfc1034#section-3.5> RFC 1034 Section 3.5 *)
246 let domain_value domain =
247 (* Strip leading dot per RFC 6265 Section 5.2.3 *)
248 let domain =
249 if String.starts_with ~prefix:"." domain && String.length domain > 1 then
250 String.sub domain 1 (String.length domain - 1)
251 else domain
252 in
253 if String.length domain = 0 then
254 Error "Domain attribute is empty"
255 else
256 (* First check if it's an IP address *)
257 match Ipaddr.of_string domain with
258 | Ok _ -> Ok domain (* Valid IP address *)
259 | Error _ -> (
260 (* Not an IP, validate as domain name using domain-name library *)
261 match Domain_name.of_string domain with
262 | Ok _ -> Ok domain
263 | Error (`Msg msg) ->
264 Error
265 (Printf.sprintf
266 "Domain %S is not a valid domain name: %s. Domain names \
267 must follow RFC 1034: labels must start with a letter, \
268 contain only letters/digits/hyphens, not end with a \
269 hyphen, and be at most 63 characters each"
270 domain msg))
271
272 (** Validate a path attribute value.
273
274 Per RFC 6265 Section 4.1.1, path-value may contain any CHAR except
275 control characters and semicolon.
276
277 @param path The path value to validate
278 @return [Ok path] if valid, [Error message] with explanation if invalid
279
280 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1 *)
281 let path_value path =
282 let len = String.length path in
283 let rec find_invalid i acc =
284 if i >= len then acc
285 else
286 let c = String.unsafe_get path i in
287 match c with
288 | '\x00' .. '\x1F' | '\x7F' | ';' -> find_invalid (i + 1) (c :: acc)
289 | _ -> find_invalid (i + 1) acc
290 in
291 match find_invalid 0 [] with
292 | [] -> Ok path
293 | invalid_chars ->
294 let chars_str =
295 invalid_chars
296 |> List.rev
297 |> List.map (fun c -> Printf.sprintf "0x%02X" (Char.code c))
298 |> String.concat ", "
299 in
300 Error
301 (Printf.sprintf
302 "Path %S contains invalid characters: %s. Paths may not contain \
303 control characters or semicolons"
304 path chars_str)
305
306 (** Validate a Max-Age attribute value.
307
308 Per RFC 6265 Section 4.1.1, max-age-av uses non-zero-digit *DIGIT.
309 However, per Section 5.2.2, user agents should treat values <= 0 as
310 "delete immediately". This function returns [Ok] for any integer since
311 the parsing code handles negative values by converting to 0.
312
313 @param seconds The Max-Age value in seconds
314 @return [Ok seconds] always (negative values are handled in parsing)
315
316 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1> RFC 6265 Section 4.1.1
317 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2> RFC 6265 Section 5.2.2 *)
318 let max_age seconds = Ok seconds
319end
320
321(** {1 Public Suffix Validation}
322
323 Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3 Step 5},
324 cookies with Domain attributes that are public suffixes must be rejected
325 to prevent domain-wide cookie attacks.
326
327 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model
328 @see <https://publicsuffix.org/list/> Public Suffix List *)
329
330(** Module-level Public Suffix List instance.
331
332 Lazily initialized on first use. The PSL data is compiled into the
333 publicsuffix library at build time from the Mozilla Public Suffix List. *)
334let psl = lazy (Publicsuffix.create ())
335
336(** Validate that a cookie domain is not a public suffix.
337
338 Per RFC 6265 Section 5.3 Step 5, user agents MUST reject cookies where
339 the Domain attribute is a public suffix (e.g., ".com", ".co.uk") unless
340 the request host exactly matches that domain.
341
342 This prevents attackers from setting domain-wide cookies that would affect
343 all sites under a TLD.
344
345 @param request_domain The host from the HTTP request
346 @param cookie_domain The Domain attribute value (already normalized, without leading dot)
347 @return [Ok ()] if the domain is allowed, [Error msg] if it's a public suffix
348
349 Examples:
350 - Request from "www.example.com", Domain=".com" → Error (public suffix)
351 - Request from "www.example.com", Domain=".example.com" → Ok (not public suffix)
352 - Request from "com", Domain=".com" → Ok (request host matches domain exactly)
353
354 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 *)
355let validate_not_public_suffix ~request_domain ~cookie_domain =
356 (* IP addresses bypass PSL check per RFC 6265 Section 5.1.3 *)
357 match Ipaddr.of_string cookie_domain with
358 | Ok _ -> Ok () (* IP addresses are not subject to PSL rules *)
359 | Error _ ->
360 let psl = Lazy.force psl in
361 (match Publicsuffix.is_public_suffix psl cookie_domain with
362 | Error _ ->
363 (* If PSL lookup fails (e.g., invalid domain), allow the cookie.
364 Domain name validation is handled separately. *)
365 Ok ()
366 | Ok false ->
367 (* Not a public suffix, allow the cookie *)
368 Ok ()
369 | Ok true ->
370 (* It's a public suffix - only allow if request host matches exactly.
371 This allows a server that IS a public suffix (rare but possible with
372 private domains like blogspot.com) to set cookies for itself. *)
373 let request_lower = String.lowercase_ascii request_domain in
374 let cookie_lower = String.lowercase_ascii cookie_domain in
375 if request_lower = cookie_lower then Ok ()
376 else
377 Error
378 (Printf.sprintf
379 "Domain %S is a public suffix; RFC 6265 Section 5.3 prohibits \
380 setting cookies for public suffixes to prevent domain-wide \
381 cookie attacks. The request host %S does not exactly match \
382 the domain."
383 cookie_domain request_domain))
384
385(** {1 Cookie Parsing Helpers} *)
386
387(** Normalize a domain by stripping the leading dot.
388
389 Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3} RFC 6265 Section 5.2.3},
390 if the first character of the Domain attribute value is ".", that character
391 is ignored (the domain remains case-insensitive).
392
393 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> RFC 6265 Section 5.2.3 - The Domain Attribute *)
394let normalize_domain domain =
395 match String.starts_with ~prefix:"." domain with
396 | true when String.length domain > 1 ->
397 String.sub domain 1 (String.length domain - 1)
398 | _ -> domain
399
400(** {1 HTTP Date Parsing}
401
402 Date parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1}
403 which requires parsing dates in various HTTP formats. *)
404
405module DateParser = struct
406 (** Month name to number mapping (case-insensitive).
407
408 Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1},
409 month tokens are matched case-insensitively.
410
411 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *)
412 let month_of_string s =
413 match String.lowercase_ascii s with
414 | "jan" -> Some 1
415 | "feb" -> Some 2
416 | "mar" -> Some 3
417 | "apr" -> Some 4
418 | "may" -> Some 5
419 | "jun" -> Some 6
420 | "jul" -> Some 7
421 | "aug" -> Some 8
422 | "sep" -> Some 9
423 | "oct" -> Some 10
424 | "nov" -> Some 11
425 | "dec" -> Some 12
426 | _ -> None
427
428 (** Normalize abbreviated years per RFC 6265.
429
430 Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1} RFC 6265 Section 5.1.1}:
431 - Years 70-99 get 1900 added (e.g., 95 → 1995)
432 - Years 0-69 get 2000 added (e.g., 25 → 2025)
433 - Years >= 100 are returned as-is
434
435 Note: This implementation treats year 69 as 1969 (adding 1900), which
436 technically differs from the RFC's "70 and less than or equal to 99" rule.
437
438 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1> RFC 6265 Section 5.1.1 - Dates *)
439 let normalize_year year =
440 if year >= 0 && year <= 68 then year + 2000
441 else if year >= 69 && year <= 99 then year + 1900
442 else year
443
444 (** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
445 let parse_fmt1 s =
446 try
447 Scanf.sscanf s "%s %d %s %d %d:%d:%d %s"
448 (fun _wday day mon year hour min sec tz ->
449 (* Check timezone is GMT (case-insensitive) *)
450 if String.lowercase_ascii tz <> "gmt" then None
451 else
452 match month_of_string mon with
453 | None -> None
454 | Some month ->
455 let year = normalize_year year in
456 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
457 with _ -> None
458
459 (** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *)
460 let parse_fmt2 s =
461 try
462 Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s"
463 (fun _wday day mon year hour min sec tz ->
464 (* Check timezone is GMT (case-insensitive) *)
465 if String.lowercase_ascii tz <> "gmt" then None
466 else
467 match month_of_string mon with
468 | None -> None
469 | Some month ->
470 let year = normalize_year year in
471 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
472 with _ -> None
473
474 (** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
475 let parse_fmt3 s =
476 try
477 Scanf.sscanf s "%s %s %d %d:%d:%d %d"
478 (fun _wday mon day hour min sec year ->
479 match month_of_string mon with
480 | None -> None
481 | Some month ->
482 let year = normalize_year year in
483 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
484 with _ -> None
485
486 (** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
487 let parse_fmt4 s =
488 try
489 Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s"
490 (fun _wday day mon year hour min sec tz ->
491 (* Check timezone is GMT (case-insensitive) *)
492 if String.lowercase_ascii tz <> "gmt" then None
493 else
494 match month_of_string mon with
495 | None -> None
496 | Some month ->
497 let year = normalize_year year in
498 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
499 with _ -> None
500
501 (** Parse HTTP date by trying all supported formats in sequence *)
502 let parse_http_date s =
503 match parse_fmt1 s with
504 | Some t -> Some t
505 | None -> (
506 match parse_fmt2 s with
507 | Some t -> Some t
508 | None -> (
509 match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
510end
511
512(** {1 Cookie Parsing} *)
513
514type cookie_attributes = {
515 mutable domain : string option;
516 mutable path : string option;
517 mutable secure : bool;
518 mutable http_only : bool;
519 mutable partitioned : bool;
520 mutable expires : Expiration.t option;
521 mutable max_age : Ptime.Span.t option;
522 mutable same_site : SameSite.t option;
523}
524(** Accumulated attributes from parsing Set-Cookie header *)
525
526(** Create empty attribute accumulator *)
527let empty_attributes () =
528 {
529 domain = None;
530 path = None;
531 secure = false;
532 http_only = false;
533 partitioned = false;
534 expires = None;
535 max_age = None;
536 same_site = None;
537 }
538
539(** Parse a single cookie attribute and update the accumulator in-place.
540
541 Attribute parsing follows {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2}
542 which defines the grammar and semantics for each cookie attribute.
543
544 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
545let parse_attribute now attrs attr_name attr_value =
546 let attr_lower = String.lowercase_ascii attr_name in
547 match attr_lower with
548 | "domain" -> attrs.domain <- Some (normalize_domain attr_value)
549 | "path" -> attrs.path <- Some attr_value
550 | "expires" -> (
551 if
552 (* Special case: Expires=0 means session cookie *)
553 attr_value = "0"
554 then attrs.expires <- Some `Session
555 else
556 match Ptime.of_rfc3339 attr_value with
557 | Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
558 | Error (`RFC3339 (_, err)) -> (
559 (* Try HTTP date format as fallback *)
560 match DateParser.parse_http_date attr_value with
561 | Some time -> attrs.expires <- Some (`DateTime time)
562 | None ->
563 Log.warn (fun m ->
564 m "Failed to parse expires attribute '%s': %a" attr_value
565 Ptime.pp_rfc3339_error err)))
566 | "max-age" -> (
567 match int_of_string_opt attr_value with
568 | Some seconds ->
569 (* Handle negative values as 0 per RFC 6265 *)
570 let seconds = max 0 seconds in
571 let current_time = now () in
572 (* Store the max-age as a Ptime.Span *)
573 attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
574 (* Also compute and store expires as DateTime *)
575 let expires =
576 Ptime.add_span current_time (Ptime.Span.of_int_s seconds)
577 in
578 (match expires with
579 | Some time -> attrs.expires <- Some (`DateTime time)
580 | None -> ());
581 Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
582 | None ->
583 Log.warn (fun m ->
584 m "Failed to parse max-age attribute '%s'" attr_value))
585 | "secure" -> attrs.secure <- true
586 | "httponly" -> attrs.http_only <- true
587 | "partitioned" -> attrs.partitioned <- true
588 | "samesite" -> (
589 match String.lowercase_ascii attr_value with
590 | "strict" -> attrs.same_site <- Some `Strict
591 | "lax" -> attrs.same_site <- Some `Lax
592 | "none" -> attrs.same_site <- Some `None
593 | _ ->
594 Log.warn (fun m ->
595 m "Invalid samesite value '%s', ignoring" attr_value))
596 | _ ->
597 Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
598
599(** Validate cookie attributes and log warnings for invalid combinations.
600
601 Validates:
602 - SameSite=None requires the Secure flag (per RFC 6265bis)
603 - Partitioned requires the Secure flag (per CHIPS specification)
604
605 @see <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7> RFC 6265bis Section 5.4.7 - SameSite
606 @see <https://developer.chrome.com/docs/privacy-sandbox/chips/> CHIPS - Cookies Having Independent Partitioned State *)
607let validate_attributes attrs =
608 (* SameSite=None requires Secure flag *)
609 let samesite_valid =
610 match attrs.same_site with
611 | Some `None when not attrs.secure ->
612 Log.warn (fun m ->
613 m
614 "Cookie has SameSite=None but Secure flag is not set; this \
615 violates RFC requirements");
616 false
617 | _ -> true
618 in
619 (* Partitioned requires Secure flag *)
620 let partitioned_valid =
621 if attrs.partitioned && not attrs.secure then (
622 Log.warn (fun m ->
623 m
624 "Cookie has Partitioned attribute but Secure flag is not set; this \
625 violates CHIPS requirements");
626 false)
627 else true
628 in
629 samesite_valid && partitioned_valid
630
631(** Build final cookie from name/value and accumulated attributes.
632
633 Per {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.3} RFC 6265 Section 5.3}:
634 - If Domain attribute is present, host-only-flag = false, domain = attribute value
635 - If Domain attribute is absent, host-only-flag = true, domain = request host
636
637 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *)
638let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
639 let host_only, domain =
640 match attrs.domain with
641 | Some d -> (false, normalize_domain d)
642 | None -> (true, request_domain)
643 in
644 let path = Option.value attrs.path ~default:request_path in
645 make ~domain ~path ~name ~value ~secure:attrs.secure
646 ~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
647 ?same_site:attrs.same_site ~partitioned:attrs.partitioned ~host_only
648 ~creation_time:now ~last_access:now ()
649
650(** {1 Pretty Printing} *)
651
652let pp ppf cookie =
653 Format.fprintf ppf
654 "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
655 http_only=%b;@ partitioned=%b;@ host_only=%b;@ expires=%a;@ max_age=%a;@ \
656 same_site=%a }@]"
657 (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
658 (http_only cookie) (partitioned cookie) (host_only cookie)
659 (Format.pp_print_option Expiration.pp)
660 (expires cookie)
661 (Format.pp_print_option Ptime.Span.pp)
662 (max_age cookie)
663 (Format.pp_print_option SameSite.pp)
664 (same_site cookie)
665
666(** {1 Cookie Parsing} *)
667
668(** Parse a Set-Cookie HTTP response header.
669
670 Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-5.2} RFC 6265 Section 5.2},
671 extracting the cookie name, value, and all attributes. Returns [Error msg] if
672 the cookie is invalid or fails validation, with a descriptive error message.
673
674 @param now Function returning current time for Max-Age computation
675 @param domain The request host (used as default domain)
676 @param path The request path (used as default path)
677 @param header_value The Set-Cookie header value string
678 @return [Ok cookie] if parsing succeeds, [Error msg] with explanation if invalid
679
680 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2> RFC 6265 Section 5.2 - The Set-Cookie Header *)
681let of_set_cookie_header ~now ~domain:request_domain ~path:request_path
682 header_value =
683 Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
684
685 (* Split into attributes *)
686 let parts = String.split_on_char ';' header_value |> List.map String.trim in
687
688 match parts with
689 | [] -> Error "Empty Set-Cookie header"
690 | name_value :: attrs -> (
691 (* Parse name=value *)
692 match String.index_opt name_value '=' with
693 | None ->
694 Error
695 (Printf.sprintf
696 "Set-Cookie header missing '=' separator in name-value pair: %S"
697 name_value)
698 | Some eq_pos -> (
699 let name = String.sub name_value 0 eq_pos |> String.trim in
700 let cookie_value =
701 String.sub name_value (eq_pos + 1)
702 (String.length name_value - eq_pos - 1)
703 |> String.trim
704 in
705
706 (* Validate cookie name per RFC 6265 *)
707 match Validate.cookie_name name with
708 | Error msg -> Error msg
709 | Ok name -> (
710 (* Validate cookie value per RFC 6265 *)
711 match Validate.cookie_value cookie_value with
712 | Error msg -> Error msg
713 | Ok cookie_value ->
714 let current_time = now () in
715
716 (* Parse all attributes into mutable accumulator *)
717 let accumulated_attrs = empty_attributes () in
718 let attr_errors = ref [] in
719 List.iter
720 (fun attr ->
721 match String.index_opt attr '=' with
722 | None ->
723 (* Attribute without value (e.g., Secure, HttpOnly) *)
724 parse_attribute now accumulated_attrs attr ""
725 | Some eq ->
726 let attr_name = String.sub attr 0 eq |> String.trim in
727 let attr_value =
728 String.sub attr (eq + 1)
729 (String.length attr - eq - 1)
730 |> String.trim
731 in
732 (* Validate domain and path attributes *)
733 (match String.lowercase_ascii attr_name with
734 | "domain" -> (
735 match Validate.domain_value attr_value with
736 | Error msg -> attr_errors := msg :: !attr_errors
737 | Ok _ -> ())
738 | "path" -> (
739 match Validate.path_value attr_value with
740 | Error msg -> attr_errors := msg :: !attr_errors
741 | Ok _ -> ())
742 | "max-age" -> (
743 match int_of_string_opt attr_value with
744 | Some seconds -> (
745 match Validate.max_age seconds with
746 | Error msg ->
747 attr_errors := msg :: !attr_errors
748 | Ok _ -> ())
749 | None -> ())
750 | _ -> ());
751 parse_attribute now accumulated_attrs attr_name
752 attr_value)
753 attrs;
754
755 (* Check for attribute validation errors *)
756 if List.length !attr_errors > 0 then
757 Error (String.concat "; " (List.rev !attr_errors))
758 else if not (validate_attributes accumulated_attrs) then
759 Error
760 "Cookie validation failed: SameSite=None requires \
761 Secure flag, and Partitioned requires Secure flag"
762 else
763 (* Public suffix validation per RFC 6265 Section 5.3 Step 5.
764 Only applies when Domain attribute is present. *)
765 let psl_result =
766 match accumulated_attrs.domain with
767 | None ->
768 (* No Domain attribute - cookie is host-only, no PSL check needed *)
769 Ok ()
770 | Some cookie_domain ->
771 let normalized = normalize_domain cookie_domain in
772 validate_not_public_suffix ~request_domain ~cookie_domain:normalized
773 in
774 (match psl_result with
775 | Error msg -> Error msg
776 | Ok () ->
777 let cookie =
778 build_cookie ~request_domain ~request_path ~name
779 ~value:cookie_value accumulated_attrs ~now:current_time
780 in
781 Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
782 Ok cookie))))
783
784(** Parse a Cookie HTTP request header.
785
786 Parses the header according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}.
787 The Cookie header contains semicolon-separated name=value pairs.
788
789 Validates cookie names and values per RFC 6265 and detects duplicate
790 cookie names (which is an error per Section 4.2.1).
791
792 Cookies parsed from the Cookie header have [host_only = true] since we
793 cannot determine from the header alone whether they originally had a
794 Domain attribute.
795
796 @param now Function returning current time for timestamps
797 @param domain The request host (assigned to all parsed cookies)
798 @param path The request path (assigned to all parsed cookies)
799 @param header_value The Cookie header value string
800 @return [Ok cookies] if all cookies parse successfully with no duplicates,
801 [Error msg] with explanation if validation fails
802
803 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
804let of_cookie_header ~now ~domain ~path header_value =
805 Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
806
807 (* Split on semicolons *)
808 let parts = String.split_on_char ';' header_value |> List.map String.trim in
809
810 (* Filter out empty parts *)
811 let parts = List.filter (fun s -> String.length s > 0) parts in
812
813 (* Parse each name=value pair, collecting results *)
814 let results =
815 List.fold_left
816 (fun acc name_value ->
817 match acc with
818 | Error _ -> acc (* Propagate earlier errors *)
819 | Ok (cookies, seen_names) -> (
820 match String.index_opt name_value '=' with
821 | None ->
822 Error
823 (Printf.sprintf "Cookie missing '=' separator: %S" name_value)
824 | Some eq_pos -> (
825 let cookie_name =
826 String.sub name_value 0 eq_pos |> String.trim
827 in
828 (* Validate cookie name per RFC 6265 *)
829 match Validate.cookie_name cookie_name with
830 | Error msg -> Error msg
831 | Ok cookie_name -> (
832 (* Check for duplicate names per RFC 6265 Section 4.2.1 *)
833 if List.mem cookie_name seen_names then
834 Error
835 (Printf.sprintf
836 "Duplicate cookie name %S in Cookie header; RFC \
837 6265 Section 4.2.1 forbids duplicate names"
838 cookie_name)
839 else
840 let cookie_value =
841 String.sub name_value (eq_pos + 1)
842 (String.length name_value - eq_pos - 1)
843 |> String.trim
844 in
845 (* Validate cookie value per RFC 6265 *)
846 match Validate.cookie_value cookie_value with
847 | Error msg -> Error msg
848 | Ok cookie_value ->
849 let current_time = now () in
850 (* Create cookie with defaults from Cookie header context.
851 Cookies from Cookie headers have host_only=true since we don't
852 know if they originally had a Domain attribute. *)
853 let cookie =
854 make ~domain ~path ~name:cookie_name
855 ~value:cookie_value ~secure:false ~http_only:false
856 ~partitioned:false ~host_only:true
857 ~creation_time:current_time
858 ~last_access:current_time ()
859 in
860 Ok (cookie :: cookies, cookie_name :: seen_names)))))
861 (Ok ([], []))
862 parts
863 in
864 match results with
865 | Error msg -> Error msg
866 | Ok (cookies, _) -> Ok (List.rev cookies)
867
868(** Generate a Cookie HTTP request header from a list of cookies.
869
870 Formats cookies according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.2} RFC 6265 Section 4.2}
871 as semicolon-separated name=value pairs.
872
873 @param cookies List of cookies to include
874 @return The Cookie header value string
875
876 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.2> RFC 6265 Section 4.2 - The Cookie Header *)
877let make_cookie_header cookies =
878 cookies
879 |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
880 |> String.concat "; "
881
882(** Generate a Set-Cookie HTTP response header from a cookie.
883
884 Formats the cookie according to {{:https://datatracker.ietf.org/doc/html/rfc6265#section-4.1} RFC 6265 Section 4.1}
885 including all attributes.
886
887 Note: The Expires attribute is currently formatted using RFC 3339, which
888 differs from the RFC-recommended rfc1123-date format.
889
890 @param cookie The cookie to serialize
891 @return The Set-Cookie header value string
892
893 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-4.1> RFC 6265 Section 4.1 - The Set-Cookie Header *)
894let make_set_cookie_header cookie =
895 let buffer = Buffer.create 128 in
896 Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
897
898 (* Add Max-Age if present *)
899 (match max_age cookie with
900 | Some span -> (
901 match Ptime.Span.to_int_s span with
902 | Some seconds ->
903 Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
904 | None -> ())
905 | None -> ());
906
907 (* Add Expires if present *)
908 (match expires cookie with
909 | Some `Session ->
910 (* Session cookies can be indicated with Expires=0 or a past date *)
911 Buffer.add_string buffer "; Expires=0"
912 | Some (`DateTime exp_time) ->
913 (* Format as HTTP date *)
914 let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
915 Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
916 | None -> ());
917
918 (* Add Domain *)
919 Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
920
921 (* Add Path *)
922 Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie));
923
924 (* Add Secure flag *)
925 if secure cookie then Buffer.add_string buffer "; Secure";
926
927 (* Add HttpOnly flag *)
928 if http_only cookie then Buffer.add_string buffer "; HttpOnly";
929
930 (* Add Partitioned flag *)
931 if partitioned cookie then Buffer.add_string buffer "; Partitioned";
932
933 (* Add SameSite *)
934 (match same_site cookie with
935 | Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
936 | Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
937 | Some `None -> Buffer.add_string buffer "; SameSite=None"
938 | None -> ());
939
940 Buffer.contents buffer