···2233module Log = (val Logs.src_log src : Logs.LOG)
4455-type same_site = [ `Strict | `Lax | `None ]
66-(** Cookie same-site policy *)
55+module SameSite = struct
66+ type t = [ `Strict | `Lax | `None ]
77+88+ let equal = ( = )
99+1010+ let pp ppf = function
1111+ | `Strict -> Format.pp_print_string ppf "Strict"
1212+ | `Lax -> Format.pp_print_string ppf "Lax"
1313+ | `None -> Format.pp_print_string ppf "None"
1414+end
1515+1616+module Expiration = struct
1717+ type t = [ `Session | `DateTime of Ptime.t ]
1818+1919+ let equal e1 e2 =
2020+ match (e1, e2) with
2121+ | `Session, `Session -> true
2222+ | `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
2323+ | _ -> false
2424+2525+ let pp ppf = function
2626+ | `Session -> Format.pp_print_string ppf "Session"
2727+ | `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
2828+end
729830type t = {
931 domain : string;
···1234 value : string;
1335 secure : bool;
1436 http_only : bool;
1515- expires : Ptime.t option;
3737+ partitioned : bool;
3838+ expires : Expiration.t option;
1639 max_age : Ptime.Span.t option;
1717- same_site : same_site option;
4040+ same_site : SameSite.t option;
1841 creation_time : Ptime.t;
1942 last_access : Ptime.t;
2043}
···3356let path cookie = cookie.path
3457let name cookie = cookie.name
3558let value cookie = cookie.value
5959+6060+let value_trimmed cookie =
6161+ let v = cookie.value in
6262+ let len = String.length v in
6363+ if len < 2 then v
6464+ else
6565+ match (v.[0], v.[len - 1]) with
6666+ | '"', '"' -> String.sub v 1 (len - 2)
6767+ | _ -> v
6868+3669let secure cookie = cookie.secure
3770let http_only cookie = cookie.http_only
7171+let partitioned cookie = cookie.partitioned
3872let expires cookie = cookie.expires
3973let max_age cookie = cookie.max_age
4074let same_site cookie = cookie.same_site
···4276let last_access cookie = cookie.last_access
43774478let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
4545- ?expires ?max_age ?same_site ~creation_time ~last_access () =
7979+ ?expires ?max_age ?same_site ?(partitioned = false) ~creation_time
8080+ ~last_access () =
4681 {
4782 domain;
4883 path;
···5085 value;
5186 secure;
5287 http_only;
8888+ partitioned;
5389 expires;
5490 max_age;
5591 same_site;
···89125(** {1 HTTP Date Parsing} *)
90126let is_expired cookie clock =
91127 match cookie.expires with
9292- | None -> false (* Session cookie *)
9393- | Some exp_time ->
128128+ | None -> false (* No expiration *)
129129+ | Some `Session -> false (* Session cookie - not expired until browser closes *)
130130+ | Some (`DateTime exp_time) ->
94131 let now =
95132 Ptime.of_float_s (Eio.Time.now clock)
96133 |> Option.value ~default:Ptime.epoch
···199236 mutable path : string option;
200237 mutable secure : bool;
201238 mutable http_only : bool;
202202- mutable expires : Ptime.t option;
239239+ mutable partitioned : bool;
240240+ mutable expires : Expiration.t option;
203241 mutable max_age : Ptime.Span.t option;
204204- mutable same_site : same_site option;
242242+ mutable same_site : SameSite.t option;
205243}
206244(** Accumulated attributes from parsing Set-Cookie header *)
207245···212250 path = None;
213251 secure = false;
214252 http_only = false;
253253+ partitioned = false;
215254 expires = None;
216255 max_age = None;
217256 same_site = None;
···224263 | "domain" -> attrs.domain <- Some (normalize_domain attr_value)
225264 | "path" -> attrs.path <- Some attr_value
226265 | "expires" -> (
227227- match Ptime.of_rfc3339 attr_value with
228228- | Ok (time, _, _) -> attrs.expires <- Some time
229229- | Error (`RFC3339 (_, err)) -> (
230230- (* Try HTTP date format as fallback *)
231231- match DateParser.parse_http_date attr_value with
232232- | Some time -> attrs.expires <- Some time
233233- | None ->
234234- Log.warn (fun m ->
235235- m "Failed to parse expires attribute '%s': %a" attr_value
236236- Ptime.pp_rfc3339_error err)))
266266+ (* Special case: Expires=0 means session cookie *)
267267+ if attr_value = "0" then attrs.expires <- Some `Session
268268+ else
269269+ match Ptime.of_rfc3339 attr_value with
270270+ | Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
271271+ | Error (`RFC3339 (_, err)) -> (
272272+ (* Try HTTP date format as fallback *)
273273+ match DateParser.parse_http_date attr_value with
274274+ | Some time -> attrs.expires <- Some (`DateTime time)
275275+ | None ->
276276+ Log.warn (fun m ->
277277+ m "Failed to parse expires attribute '%s': %a" attr_value
278278+ Ptime.pp_rfc3339_error err)))
237279 | "max-age" -> (
238280 match int_of_string_opt attr_value with
239281 | Some seconds ->
···242284 let now = Eio.Time.now clock in
243285 (* Store the max-age as a Ptime.Span *)
244286 attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
245245- (* Also compute and store expires *)
287287+ (* Also compute and store expires as DateTime *)
246288 let expires = Ptime.of_float_s (now +. float_of_int seconds) in
247247- attrs.expires <- expires;
289289+ (match expires with
290290+ | Some time -> attrs.expires <- Some (`DateTime time)
291291+ | None -> ());
248292 Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
249293 | None ->
250294 Log.warn (fun m ->
251295 m "Failed to parse max-age attribute '%s'" attr_value))
252296 | "secure" -> attrs.secure <- true
253297 | "httponly" -> attrs.http_only <- true
298298+ | "partitioned" -> attrs.partitioned <- true
254299 | "samesite" -> (
255300 match String.lowercase_ascii attr_value with
256301 | "strict" -> attrs.same_site <- Some `Strict
···265310(** Validate cookie attributes and log warnings for invalid combinations *)
266311let validate_attributes attrs =
267312 (* SameSite=None requires Secure flag *)
268268- match attrs.same_site with
269269- | Some `None when not attrs.secure ->
313313+ let samesite_valid =
314314+ match attrs.same_site with
315315+ | Some `None when not attrs.secure ->
316316+ Log.warn (fun m ->
317317+ m
318318+ "Cookie has SameSite=None but Secure flag is not set; this \
319319+ violates RFC requirements");
320320+ false
321321+ | _ -> true
322322+ in
323323+ (* Partitioned requires Secure flag *)
324324+ let partitioned_valid =
325325+ if attrs.partitioned && not attrs.secure then (
270326 Log.warn (fun m ->
271327 m
272272- "Cookie has SameSite=None but Secure flag is not set; this \
273273- violates RFC requirements");
274274- false
275275- | _ -> true
328328+ "Cookie has Partitioned attribute but Secure flag is not set; \
329329+ this violates CHIPS requirements");
330330+ false)
331331+ else true
332332+ in
333333+ samesite_valid && partitioned_valid
276334277335(** Build final cookie from name/value and accumulated attributes *)
278336let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
···282340 let path = Option.value attrs.path ~default:request_path in
283341 make ~domain ~path ~name ~value ~secure:attrs.secure
284342 ~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
285285- ?same_site:attrs.same_site ~creation_time:now ~last_access:now ()
343343+ ?same_site:attrs.same_site ~partitioned:attrs.partitioned
344344+ ~creation_time:now ~last_access:now ()
286345287346let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
288347 header_value =
···339398 Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
340399 Some cookie)
341400401401+and of_cookie_header ~clock ~domain ~path header_value =
402402+ Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
403403+404404+ (* Split on semicolons *)
405405+ let parts = String.split_on_char ';' header_value |> List.map String.trim in
406406+407407+ (* Filter out empty parts *)
408408+ let parts = List.filter (fun s -> String.length s > 0) parts in
409409+410410+ (* Parse each name=value pair *)
411411+ List.map
412412+ (fun name_value ->
413413+ match String.index_opt name_value '=' with
414414+ | None ->
415415+ Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
416416+ | Some eq_pos ->
417417+ let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
418418+ if String.length cookie_name = 0 then
419419+ Error "Cookie has empty name"
420420+ else
421421+ let cookie_value =
422422+ String.sub name_value (eq_pos + 1)
423423+ (String.length name_value - eq_pos - 1)
424424+ |> String.trim
425425+ in
426426+ let now =
427427+ Ptime.of_float_s (Eio.Time.now clock)
428428+ |> Option.value ~default:Ptime.epoch
429429+ in
430430+ (* Create cookie with defaults from Cookie header context *)
431431+ let cookie =
432432+ make ~domain ~path ~name:cookie_name ~value:cookie_value
433433+ ~secure:false ~http_only:false ~partitioned:false ~creation_time:now
434434+ ~last_access:now ()
435435+ in
436436+ Ok cookie)
437437+ parts
438438+342439and make_cookie_header cookies =
343440 cookies
344441 |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
···359456360457 (* Add Expires if present *)
361458 (match expires cookie with
362362- | Some exp_time ->
459459+ | Some `Session ->
460460+ (* Session cookies can be indicated with Expires=0 or a past date *)
461461+ Buffer.add_string buffer "; Expires=0"
462462+ | Some (`DateTime exp_time) ->
363463 (* Format as HTTP date *)
364464 let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
365465 Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
···377477 (* Add HttpOnly flag *)
378478 if http_only cookie then Buffer.add_string buffer "; HttpOnly";
379479480480+ (* Add Partitioned flag *)
481481+ if partitioned cookie then Buffer.add_string buffer "; Partitioned";
482482+380483 (* Add SameSite *)
381484 (match same_site cookie with
382485 | Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
···388491389492(** {1 Pretty Printing} *)
390493391391-and pp_same_site ppf = function
392392- | `Strict -> Format.pp_print_string ppf "Strict"
393393- | `Lax -> Format.pp_print_string ppf "Lax"
394394- | `None -> Format.pp_print_string ppf "None"
395395-396494and pp ppf cookie =
397495 Format.fprintf ppf
398496 "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
399399- http_only=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
497497+ http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
400498 (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
401401- (http_only cookie)
402402- (Format.pp_print_option Ptime.pp)
499499+ (http_only cookie) (partitioned cookie)
500500+ (Format.pp_print_option Expiration.pp)
403501 (expires cookie)
404502 (Format.pp_print_option Ptime.Span.pp)
405503 (max_age cookie)
406406- (Format.pp_print_option pp_same_site)
504504+ (Format.pp_print_option SameSite.pp)
407505 (same_site cookie)
408506409507let pp_jar ppf jar =
···465563 |> Option.value ~default:Ptime.epoch
466564 in
467565 make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) ~value:""
468468- ~secure:(secure cookie) ~http_only:(http_only cookie) ~expires:past_expiry
469469- ~max_age:(Ptime.Span.of_int_s 0) ?same_site:(same_site cookie)
566566+ ~secure:(secure cookie) ~http_only:(http_only cookie)
567567+ ~expires:(`DateTime past_expiry) ~max_age:(Ptime.Span.of_int_s 0)
568568+ ?same_site:(same_site cookie) ~partitioned:(partitioned cookie)
470569 ~creation_time:now ~last_access:now ()
471570472571let remove jar ~clock cookie =
···585684 let before_count =
586685 List.length jar.original_cookies + List.length jar.delta_cookies
587686 in
588588- jar.original_cookies <-
589589- List.filter (fun c -> expires c <> None) jar.original_cookies;
590590- jar.delta_cookies <-
591591- List.filter (fun c -> expires c <> None) jar.delta_cookies;
687687+ (* Keep only cookies that are NOT session cookies *)
688688+ let is_not_session c =
689689+ match expires c with
690690+ | Some `Session -> false (* This is a session cookie, remove it *)
691691+ | None | Some (`DateTime _) -> true (* Keep these *)
692692+ in
693693+ jar.original_cookies <- List.filter is_not_session jar.original_cookies;
694694+ jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
592695 let removed =
593696 before_count
594697 - (List.length jar.original_cookies + List.length jar.delta_cookies)
···663766 let secure_flag = if secure cookie then "TRUE" else "FALSE" in
664767 let expires_str =
665768 match expires cookie with
666666- | None -> "0" (* Session cookie *)
667667- | Some t ->
769769+ | None -> "0" (* No expiration *)
770770+ | Some `Session -> "0" (* Session cookie *)
771771+ | Some (`DateTime t) ->
668772 let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
669773 epoch
670774 in
···701805 let expires =
702806 let exp_int = try int_of_string expires with _ -> 0 in
703807 if exp_int = 0 then None
704704- else Ptime.of_float_s (float_of_int exp_int)
808808+ else
809809+ match Ptime.of_float_s (float_of_int exp_int) with
810810+ | Some t -> Some (`DateTime t)
811811+ | None -> None
705812 in
706813707814 let cookie =
708815 make ~domain:(normalize_domain domain) ~path ~name ~value
709709- ~secure:(secure = "TRUE") ~http_only:false ?expires
710710- ?max_age:None ?same_site:None ~creation_time:now
816816+ ~secure:(secure = "TRUE") ~http_only:false ?expires ?max_age:None
817817+ ?same_site:None ~partitioned:false ~creation_time:now
711818 ~last_access:now ()
712819 in
713820 add_original jar cookie;
+100-16
lib/cookeio.mli
···2929 - Path matching allows subset URL specification for fine-grained control
3030 - More specific path mappings are sent first in Cookie headers *)
31313232-type same_site = [ `Strict | `Lax | `None ]
3333-(** Cookie same-site policy for controlling cross-site request behavior.
3232+module SameSite : sig
3333+ type t = [ `Strict | `Lax | `None ]
3434+ (** Cookie same-site policy for controlling cross-site request behavior.
3535+3636+ - [`Strict]: Cookie only sent for same-site requests, providing maximum
3737+ protection
3838+ - [`Lax]: Cookie sent for same-site requests and top-level navigation
3939+ (default for modern browsers)
4040+ - [`None]: Cookie sent for all cross-site requests (requires [secure] flag) *)
4141+4242+ val equal : t -> t -> bool
4343+ (** Equality function for same-site values *)
4444+4545+ val pp : Format.formatter -> t -> unit
4646+ (** Pretty printer for same-site values *)
4747+end
34483535- - [`Strict]: Cookie only sent for same-site requests, providing maximum
3636- protection
3737- - [`Lax]: Cookie sent for same-site requests and top-level navigation
3838- (default for modern browsers)
3939- - [`None]: Cookie sent for all cross-site requests (requires [secure] flag)
4040-*)
4949+module Expiration : sig
5050+ type t = [ `Session | `DateTime of Ptime.t ]
5151+ (** Cookie expiration strategy.
5252+5353+ - [`Session]: Session cookie that expires when browser session ends
5454+ - [`DateTime time]: Persistent cookie that expires at specific time *)
5555+5656+ val equal : t -> t -> bool
5757+ (** Equality function for expiration values *)
5858+5959+ val pp : Format.formatter -> t -> unit
6060+ (** Pretty printer for expiration values *)
6161+end
41624263type t
4364(** HTTP Cookie representation with all standard attributes.
···7192val value : t -> string
7293(** Get the value of a cookie *)
73949595+val value_trimmed : t -> string
9696+(** Get cookie value with surrounding double-quotes removed if they form a
9797+ matching pair.
9898+9999+ Only removes quotes when both opening and closing quotes are present. The
100100+ raw value is always preserved in {!value}. This is useful for handling
101101+ quoted cookie values per RFC 6265.
102102+103103+ Examples:
104104+ - ["value"] → ["value"]
105105+ - ["\"value\""] → ["value"]
106106+ - ["\"value"] → ["\"value"] (no matching pair)
107107+ - ["\"val\"\""] → ["val\""] (removes outer pair only) *)
108108+74109val secure : t -> bool
75110(** Check if cookie is secure only *)
7611177112val http_only : t -> bool
78113(** Check if cookie is HTTP only *)
791148080-val expires : t -> Ptime.t option
8181-(** Get the expiry time of a cookie *)
115115+val partitioned : t -> bool
116116+(** Check if cookie has the Partitioned attribute.
117117+118118+ Partitioned cookies are part of CHIPS (Cookies Having Independent
119119+ Partitioned State) and are stored separately per top-level site, enabling
120120+ privacy-preserving third-party cookie functionality. Partitioned cookies
121121+ must always be Secure. *)
122122+123123+val expires : t -> Expiration.t option
124124+(** Get the expiration attribute if set.
125125+126126+ - [None]: No expiration specified (browser decides lifetime)
127127+ - [Some `Session]: Session cookie (expires when browser session ends)
128128+ - [Some (`DateTime t)]: Expires at specific time [t]
129129+130130+ Both [max_age] and [expires] can be present simultaneously. This library
131131+ stores both independently. *)
8213283133val max_age : t -> Ptime.Span.t option
8484-(** Get the max-age of a cookie *)
134134+(** Get the max-age attribute if set.
851358686-val same_site : t -> same_site option
136136+ Both [max_age] and [expires] can be present simultaneously. When both are
137137+ present in a Set-Cookie header, browsers prioritize [max_age] per RFC 6265.
138138+ This library stores both independently and serializes both when present. *)
139139+140140+val same_site : t -> SameSite.t option
87141(** Get the same-site policy of a cookie *)
8814289143val creation_time : t -> Ptime.t
···99153 value:string ->
100154 ?secure:bool ->
101155 ?http_only:bool ->
102102- ?expires:Ptime.t ->
156156+ ?expires:Expiration.t ->
103157 ?max_age:Ptime.Span.t ->
104104- ?same_site:same_site ->
158158+ ?same_site:SameSite.t ->
159159+ ?partitioned:bool ->
105160 creation_time:Ptime.t ->
106161 last_access:Ptime.t ->
107162 unit ->
108163 t
109109-(** Create a new cookie with the given attributes *)
164164+(** Create a new cookie with the given attributes.
165165+166166+ Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
167167+ combinations will result in validation errors. *)
110168111169(** {1 Cookie Jar Creation and Loading} *)
112170···194252 Parses a Set-Cookie header value following RFC specifications:
195253 - Basic format: [NAME=VALUE; attribute1; attribute2=value2]
196254 - Supports all standard attributes: [expires], [max-age], [domain], [path],
197197- [secure], [httponly], [samesite]
255255+ [secure], [httponly], [samesite], [partitioned]
198256 - Returns [None] if parsing fails or cookie validation fails
199257 - The [domain] and [path] parameters provide the request context for default
200258 values
···203261204262 Cookie validation rules:
205263 - [SameSite=None] requires the [Secure] flag to be set
264264+ - [Partitioned] requires the [Secure] flag to be set
206265207266 Example:
208267 [parse_set_cookie ~clock ~domain:"example.com" ~path:"/" "session=abc123;
209268 Secure; HttpOnly"] *)
269269+270270+val of_cookie_header :
271271+ clock:_ Eio.Time.clock ->
272272+ domain:string ->
273273+ path:string ->
274274+ string ->
275275+ (t, string) result list
276276+(** Parse Cookie header containing semicolon-separated name=value pairs.
277277+278278+ Cookie headers (client→server) contain only name=value pairs without
279279+ attributes: ["name1=value1; name2=value2; name3=value3"]
280280+281281+ Creates cookies with:
282282+ - Provided [domain] and [path] from request context
283283+ - All security flags set to [false] (defaults)
284284+ - All optional attributes set to [None]
285285+ - [creation_time] and [last_access] set to current time from [clock]
286286+287287+ Returns a list of parse results, one per cookie. Parse errors for individual
288288+ cookies are returned as [Error msg] without failing the entire parse. Empty
289289+ values and excess whitespace are ignored.
290290+291291+ Example:
292292+ [of_cookie_header ~clock ~domain:"example.com" ~path:"/"
293293+ "session=abc; theme=dark"] *)
210294211295val make_cookie_header : t list -> string
212296(** Create cookie header value from cookies.