OCaml HTTP cookie handling library with support for Eio-based storage jars
1let src = Logs.Src.create "cookeio" ~doc:"Cookie management" 2 3module Log = (val Logs.src_log src : Logs.LOG) 4 5module SameSite = struct 6 type t = [ `Strict | `Lax | `None ] 7 8 let equal = ( = ) 9 10 let pp ppf = function 11 | `Strict -> Format.pp_print_string ppf "Strict" 12 | `Lax -> Format.pp_print_string ppf "Lax" 13 | `None -> Format.pp_print_string ppf "None" 14end 15 16module Expiration = struct 17 type t = [ `Session | `DateTime of Ptime.t ] 18 19 let equal e1 e2 = 20 match (e1, e2) with 21 | `Session, `Session -> true 22 | `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2 23 | _ -> false 24 25 let pp ppf = function 26 | `Session -> Format.pp_print_string ppf "Session" 27 | `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t 28end 29 30type t = { 31 domain : string; 32 path : string; 33 name : string; 34 value : string; 35 secure : bool; 36 http_only : bool; 37 partitioned : bool; 38 expires : Expiration.t option; 39 max_age : Ptime.Span.t option; 40 same_site : SameSite.t option; 41 creation_time : Ptime.t; 42 last_access : Ptime.t; 43} 44(** HTTP Cookie *) 45 46(** {1 Cookie Accessors} *) 47 48let domain cookie = cookie.domain 49let path cookie = cookie.path 50let name cookie = cookie.name 51let value cookie = cookie.value 52 53let value_trimmed cookie = 54 let v = cookie.value in 55 let len = String.length v in 56 if len < 2 then v 57 else 58 match (v.[0], v.[len - 1]) with 59 | '"', '"' -> String.sub v 1 (len - 2) 60 | _ -> v 61 62let secure cookie = cookie.secure 63let http_only cookie = cookie.http_only 64let partitioned cookie = cookie.partitioned 65let expires cookie = cookie.expires 66let max_age cookie = cookie.max_age 67let same_site cookie = cookie.same_site 68let creation_time cookie = cookie.creation_time 69let last_access cookie = cookie.last_access 70 71let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false) 72 ?expires ?max_age ?same_site ?(partitioned = false) ~creation_time 73 ~last_access () = 74 { 75 domain; 76 path; 77 name; 78 value; 79 secure; 80 http_only; 81 partitioned; 82 expires; 83 max_age; 84 same_site; 85 creation_time; 86 last_access; 87 } 88 89(** {1 Cookie Parsing Helpers} *) 90 91let normalize_domain domain = 92 (* Strip leading dot per RFC 6265 *) 93 match String.starts_with ~prefix:"." domain with 94 | true when String.length domain > 1 -> 95 String.sub domain 1 (String.length domain - 1) 96 | _ -> domain 97 98(** {1 HTTP Date Parsing} *) 99 100module DateParser = struct 101 (** Month name to number mapping (case-insensitive) *) 102 let month_of_string s = 103 match String.lowercase_ascii s with 104 | "jan" -> Some 1 105 | "feb" -> Some 2 106 | "mar" -> Some 3 107 | "apr" -> Some 4 108 | "may" -> Some 5 109 | "jun" -> Some 6 110 | "jul" -> Some 7 111 | "aug" -> Some 8 112 | "sep" -> Some 9 113 | "oct" -> Some 10 114 | "nov" -> Some 11 115 | "dec" -> Some 12 116 | _ -> None 117 118 (** Normalize abbreviated years: 119 - Years 69-99 get 1900 added (e.g., 95 → 1995) 120 - Years 0-68 get 2000 added (e.g., 25 → 2025) 121 - Years >= 100 are returned as-is *) 122 let normalize_year year = 123 if year >= 0 && year <= 68 then year + 2000 124 else if year >= 69 && year <= 99 then year + 1900 125 else year 126 127 (** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *) 128 let parse_fmt1 s = 129 try 130 Scanf.sscanf s "%s %d %s %d %d:%d:%d %s" 131 (fun _wday day mon year hour min sec tz -> 132 (* Check timezone is GMT (case-insensitive) *) 133 if String.lowercase_ascii tz <> "gmt" then None 134 else 135 match month_of_string mon with 136 | None -> None 137 | Some month -> 138 let year = normalize_year year in 139 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 140 with _ -> None 141 142 (** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *) 143 let parse_fmt2 s = 144 try 145 Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s" 146 (fun _wday day mon year hour min sec tz -> 147 (* Check timezone is GMT (case-insensitive) *) 148 if String.lowercase_ascii tz <> "gmt" then None 149 else 150 match month_of_string mon with 151 | None -> None 152 | Some month -> 153 let year = normalize_year year in 154 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 155 with _ -> None 156 157 (** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *) 158 let parse_fmt3 s = 159 try 160 Scanf.sscanf s "%s %s %d %d:%d:%d %d" 161 (fun _wday mon day hour min sec year -> 162 match month_of_string mon with 163 | None -> None 164 | Some month -> 165 let year = normalize_year year in 166 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 167 with _ -> None 168 169 (** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *) 170 let parse_fmt4 s = 171 try 172 Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s" 173 (fun _wday day mon year hour min sec tz -> 174 (* Check timezone is GMT (case-insensitive) *) 175 if String.lowercase_ascii tz <> "gmt" then None 176 else 177 match month_of_string mon with 178 | None -> None 179 | Some month -> 180 let year = normalize_year year in 181 Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))) 182 with _ -> None 183 184 (** Parse HTTP date by trying all supported formats in sequence *) 185 let parse_http_date s = 186 match parse_fmt1 s with 187 | Some t -> Some t 188 | None -> ( 189 match parse_fmt2 s with 190 | Some t -> Some t 191 | None -> ( 192 match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s)) 193end 194 195(** {1 Cookie Parsing} *) 196 197type cookie_attributes = { 198 mutable domain : string option; 199 mutable path : string option; 200 mutable secure : bool; 201 mutable http_only : bool; 202 mutable partitioned : bool; 203 mutable expires : Expiration.t option; 204 mutable max_age : Ptime.Span.t option; 205 mutable same_site : SameSite.t option; 206} 207(** Accumulated attributes from parsing Set-Cookie header *) 208 209(** Create empty attribute accumulator *) 210let empty_attributes () = 211 { 212 domain = None; 213 path = None; 214 secure = false; 215 http_only = false; 216 partitioned = false; 217 expires = None; 218 max_age = None; 219 same_site = None; 220 } 221 222(** Parse a single attribute and update the accumulator in-place *) 223let parse_attribute now attrs attr_name attr_value = 224 let attr_lower = String.lowercase_ascii attr_name in 225 match attr_lower with 226 | "domain" -> attrs.domain <- Some (normalize_domain attr_value) 227 | "path" -> attrs.path <- Some attr_value 228 | "expires" -> ( 229 (* Special case: Expires=0 means session cookie *) 230 if attr_value = "0" then attrs.expires <- Some `Session 231 else 232 match Ptime.of_rfc3339 attr_value with 233 | Ok (time, _, _) -> attrs.expires <- Some (`DateTime time) 234 | Error (`RFC3339 (_, err)) -> ( 235 (* Try HTTP date format as fallback *) 236 match DateParser.parse_http_date attr_value with 237 | Some time -> attrs.expires <- Some (`DateTime time) 238 | None -> 239 Log.warn (fun m -> 240 m "Failed to parse expires attribute '%s': %a" attr_value 241 Ptime.pp_rfc3339_error err))) 242 | "max-age" -> ( 243 match int_of_string_opt attr_value with 244 | Some seconds -> 245 (* Handle negative values as 0 per RFC 6265 *) 246 let seconds = max 0 seconds in 247 let current_time = now () in 248 (* Store the max-age as a Ptime.Span *) 249 attrs.max_age <- Some (Ptime.Span.of_int_s seconds); 250 (* Also compute and store expires as DateTime *) 251 let expires = Ptime.add_span current_time (Ptime.Span.of_int_s seconds) in 252 (match expires with 253 | Some time -> attrs.expires <- Some (`DateTime time) 254 | None -> ()); 255 Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds) 256 | None -> 257 Log.warn (fun m -> 258 m "Failed to parse max-age attribute '%s'" attr_value)) 259 | "secure" -> attrs.secure <- true 260 | "httponly" -> attrs.http_only <- true 261 | "partitioned" -> attrs.partitioned <- true 262 | "samesite" -> ( 263 match String.lowercase_ascii attr_value with 264 | "strict" -> attrs.same_site <- Some `Strict 265 | "lax" -> attrs.same_site <- Some `Lax 266 | "none" -> attrs.same_site <- Some `None 267 | _ -> 268 Log.warn (fun m -> 269 m "Invalid samesite value '%s', ignoring" attr_value)) 270 | _ -> 271 Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name) 272 273(** Validate cookie attributes and log warnings for invalid combinations *) 274let validate_attributes attrs = 275 (* SameSite=None requires Secure flag *) 276 let samesite_valid = 277 match attrs.same_site with 278 | Some `None when not attrs.secure -> 279 Log.warn (fun m -> 280 m 281 "Cookie has SameSite=None but Secure flag is not set; this \ 282 violates RFC requirements"); 283 false 284 | _ -> true 285 in 286 (* Partitioned requires Secure flag *) 287 let partitioned_valid = 288 if attrs.partitioned && not attrs.secure then ( 289 Log.warn (fun m -> 290 m 291 "Cookie has Partitioned attribute but Secure flag is not set; \ 292 this violates CHIPS requirements"); 293 false) 294 else true 295 in 296 samesite_valid && partitioned_valid 297 298(** Build final cookie from name/value and accumulated attributes *) 299let build_cookie ~request_domain ~request_path ~name ~value attrs ~now = 300 let domain = 301 normalize_domain (Option.value attrs.domain ~default:request_domain) 302 in 303 let path = Option.value attrs.path ~default:request_path in 304 make ~domain ~path ~name ~value ~secure:attrs.secure 305 ~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age 306 ?same_site:attrs.same_site ~partitioned:attrs.partitioned 307 ~creation_time:now ~last_access:now () 308 309(** {1 Pretty Printing} *) 310 311let pp ppf cookie = 312 Format.fprintf ppf 313 "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \ 314 http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]" 315 (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie) 316 (http_only cookie) (partitioned cookie) 317 (Format.pp_print_option Expiration.pp) 318 (expires cookie) 319 (Format.pp_print_option Ptime.Span.pp) 320 (max_age cookie) 321 (Format.pp_print_option SameSite.pp) 322 (same_site cookie) 323 324(** {1 Cookie Parsing} *) 325 326let of_set_cookie_header ~now ~domain:request_domain ~path:request_path 327 header_value = 328 Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value); 329 330 (* Split into attributes *) 331 let parts = String.split_on_char ';' header_value |> List.map String.trim in 332 333 match parts with 334 | [] -> None 335 | name_value :: attrs -> ( 336 (* Parse name=value *) 337 match String.index_opt name_value '=' with 338 | None -> None 339 | Some eq_pos -> 340 let name = String.sub name_value 0 eq_pos |> String.trim in 341 let cookie_value = 342 String.sub name_value (eq_pos + 1) 343 (String.length name_value - eq_pos - 1) 344 |> String.trim 345 in 346 347 let current_time = now () in 348 349 (* Parse all attributes into mutable accumulator *) 350 let accumulated_attrs = empty_attributes () in 351 List.iter 352 (fun attr -> 353 match String.index_opt attr '=' with 354 | None -> 355 (* Attribute without value (e.g., Secure, HttpOnly) *) 356 parse_attribute now accumulated_attrs attr "" 357 | Some eq -> 358 let attr_name = String.sub attr 0 eq |> String.trim in 359 let attr_value = 360 String.sub attr (eq + 1) (String.length attr - eq - 1) 361 |> String.trim 362 in 363 parse_attribute now accumulated_attrs attr_name attr_value) 364 attrs; 365 366 (* Validate attributes *) 367 if not (validate_attributes accumulated_attrs) then ( 368 Log.warn (fun m -> m "Cookie validation failed, rejecting cookie"); 369 None) 370 else 371 let cookie = 372 build_cookie ~request_domain ~request_path ~name 373 ~value:cookie_value accumulated_attrs ~now:current_time 374 in 375 Log.debug (fun m -> m "Parsed cookie: %a" pp cookie); 376 Some cookie) 377 378let of_cookie_header ~now ~domain ~path header_value = 379 Log.debug (fun m -> m "Parsing Cookie header: %s" header_value); 380 381 (* Split on semicolons *) 382 let parts = String.split_on_char ';' header_value |> List.map String.trim in 383 384 (* Filter out empty parts *) 385 let parts = List.filter (fun s -> String.length s > 0) parts in 386 387 (* Parse each name=value pair *) 388 List.map 389 (fun name_value -> 390 match String.index_opt name_value '=' with 391 | None -> 392 Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value) 393 | Some eq_pos -> 394 let cookie_name = String.sub name_value 0 eq_pos |> String.trim in 395 if String.length cookie_name = 0 then 396 Error "Cookie has empty name" 397 else 398 let cookie_value = 399 String.sub name_value (eq_pos + 1) 400 (String.length name_value - eq_pos - 1) 401 |> String.trim 402 in 403 let current_time = now () in 404 (* Create cookie with defaults from Cookie header context *) 405 let cookie = 406 make ~domain ~path ~name:cookie_name ~value:cookie_value 407 ~secure:false ~http_only:false ~partitioned:false ~creation_time:current_time 408 ~last_access:current_time () 409 in 410 Ok cookie) 411 parts 412 413let make_cookie_header cookies = 414 cookies 415 |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c)) 416 |> String.concat "; " 417 418let make_set_cookie_header cookie = 419 let buffer = Buffer.create 128 in 420 Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie)); 421 422 (* Add Max-Age if present *) 423 (match max_age cookie with 424 | Some span -> ( 425 match Ptime.Span.to_int_s span with 426 | Some seconds -> 427 Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds) 428 | None -> ()) 429 | None -> ()); 430 431 (* Add Expires if present *) 432 (match expires cookie with 433 | Some `Session -> 434 (* Session cookies can be indicated with Expires=0 or a past date *) 435 Buffer.add_string buffer "; Expires=0" 436 | Some (`DateTime exp_time) -> 437 (* Format as HTTP date *) 438 let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in 439 Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str) 440 | None -> ()); 441 442 (* Add Domain *) 443 Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie)); 444 445 (* Add Path *) 446 Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie)); 447 448 (* Add Secure flag *) 449 if secure cookie then Buffer.add_string buffer "; Secure"; 450 451 (* Add HttpOnly flag *) 452 if http_only cookie then Buffer.add_string buffer "; HttpOnly"; 453 454 (* Add Partitioned flag *) 455 if partitioned cookie then Buffer.add_string buffer "; Partitioned"; 456 457 (* Add SameSite *) 458 (match same_site cookie with 459 | Some `Strict -> Buffer.add_string buffer "; SameSite=Strict" 460 | Some `Lax -> Buffer.add_string buffer "; SameSite=Lax" 461 | Some `None -> Buffer.add_string buffer "; SameSite=None" 462 | None -> ()); 463 464 Buffer.contents buffer