OCaml HTTP cookie handling library with support for Eio-based storage jars

more efficient parsing, and more validation

Changed files
+175 -118
lib
test
+115 -88
lib/cookeio.ml
···
(** {1 Cookie Parsing} *)
-
let parse_cookie_attribute attr attr_value cookie =
-
let attr_lower = String.lowercase_ascii attr in
+
(** Accumulated attributes from parsing Set-Cookie header *)
+
type cookie_attributes = {
+
mutable domain : string option;
+
mutable path : string option;
+
mutable secure : bool;
+
mutable http_only : bool;
+
mutable expires : Ptime.t option;
+
mutable same_site : same_site option;
+
}
+
+
(** Create empty attribute accumulator *)
+
let empty_attributes () =
+
{
+
domain = None;
+
path = None;
+
secure = false;
+
http_only = false;
+
expires = None;
+
same_site = None;
+
}
+
+
(** Parse a single attribute and update the accumulator in-place *)
+
let parse_attribute clock attrs attr_name attr_value =
+
let attr_lower = String.lowercase_ascii attr_name in
match attr_lower with
-
| "domain" -> make ~domain:attr_value ~path:(path cookie) ~name:(name cookie)
-
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
-
?expires:(expires cookie) ?same_site:(same_site cookie)
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
-
| "path" -> make ~domain:(domain cookie) ~path:attr_value ~name:(name cookie)
-
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
-
?expires:(expires cookie) ?same_site:(same_site cookie)
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
| "domain" -> attrs.domain <- Some attr_value
+
| "path" -> attrs.path <- Some attr_value
| "expires" -> (
-
try
-
let time, _tz_offset, _tz_string =
-
Ptime.of_rfc3339 attr_value |> Result.get_ok
-
in
-
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
-
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
-
~expires:time ?same_site:(same_site cookie)
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
-
with _ ->
-
Log.debug (fun m -> m "Failed to parse expires: %s" attr_value);
-
cookie)
+
match Ptime.of_rfc3339 attr_value with
+
| Ok (time, _, _) -> attrs.expires <- Some time
+
| Error (`RFC3339 (_, err)) ->
+
Log.warn (fun m ->
+
m "Failed to parse expires attribute '%s': %a" attr_value
+
Ptime.pp_rfc3339_error err))
| "max-age" -> (
-
try
-
let seconds = int_of_string attr_value in
-
let now = Unix.time () in
-
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
-
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
-
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
-
?expires ?same_site:(same_site cookie)
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
-
with _ -> cookie)
-
| "secure" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
-
~value:(value cookie) ~secure:true ~http_only:(http_only cookie)
-
?expires:(expires cookie) ?same_site:(same_site cookie)
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
-
| "httponly" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
-
~value:(value cookie) ~secure:(secure cookie) ~http_only:true
-
?expires:(expires cookie) ?same_site:(same_site cookie)
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
-
| "samesite" ->
-
let same_site_val =
-
match String.lowercase_ascii attr_value with
-
| "strict" -> Some `Strict
-
| "lax" -> Some `Lax
-
| "none" -> Some `None
-
| _ -> None
-
in
-
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
-
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
-
?expires:(expires cookie) ?same_site:same_site_val
-
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
-
| _ -> cookie
+
match int_of_string_opt attr_value with
+
| Some seconds ->
+
let now = Eio.Time.now clock in
+
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
+
attrs.expires <- expires
+
| None ->
+
Log.warn (fun m -> m "Failed to parse max-age attribute '%s'" attr_value))
+
| "secure" -> attrs.secure <- true
+
| "httponly" -> attrs.http_only <- true
+
| "samesite" -> (
+
match String.lowercase_ascii attr_value with
+
| "strict" -> attrs.same_site <- Some `Strict
+
| "lax" -> attrs.same_site <- Some `Lax
+
| "none" -> attrs.same_site <- Some `None
+
| _ ->
+
Log.warn (fun m -> m "Invalid samesite value '%s', ignoring" attr_value))
+
| _ ->
+
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
+
+
(** Validate cookie attributes and log warnings for invalid combinations *)
+
let validate_attributes attrs =
+
(* SameSite=None requires Secure flag *)
+
match attrs.same_site with
+
| Some `None when not attrs.secure ->
+
Log.warn (fun m ->
+
m
+
"Cookie has SameSite=None but Secure flag is not set; this violates \
+
RFC requirements");
+
false
+
| _ -> true
+
+
(** Build final cookie from name/value and accumulated attributes *)
+
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
+
let domain = Option.value attrs.domain ~default:request_domain in
+
let path = Option.value attrs.path ~default:request_path in
+
make ~domain ~path ~name ~value ~secure:attrs.secure ~http_only:attrs.http_only
+
?expires:attrs.expires ?same_site:attrs.same_site ~creation_time:now
+
~last_access:now ()
-
let rec parse_set_cookie ~domain:request_domain ~path:request_path header_value =
+
let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
+
header_value =
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
(* Split into attributes *)
···
in
let now =
-
Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch
-
in
-
let base_cookie =
-
make ~domain:request_domain ~path:request_path ~name ~value:cookie_value ~secure:false ~http_only:false
-
?expires:None ?same_site:None ~creation_time:now ~last_access:now ()
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
in
-
(* Parse attributes *)
-
let cookie =
-
List.fold_left
-
(fun cookie attr ->
-
match String.index_opt attr '=' with
-
| None -> parse_cookie_attribute attr "" cookie
-
| Some eq ->
-
let attr_name = String.sub attr 0 eq |> String.trim in
-
let attr_value =
-
String.sub attr (eq + 1) (String.length attr - eq - 1)
-
|> String.trim
-
in
-
parse_cookie_attribute attr_name attr_value cookie)
-
base_cookie attrs
-
in
+
(* Parse all attributes into mutable accumulator *)
+
let accumulated_attrs = empty_attributes () in
+
List.iter
+
(fun attr ->
+
match String.index_opt attr '=' with
+
| None ->
+
(* Attribute without value (e.g., Secure, HttpOnly) *)
+
parse_attribute clock accumulated_attrs attr ""
+
| Some eq ->
+
let attr_name = String.sub attr 0 eq |> String.trim in
+
let attr_value =
+
String.sub attr (eq + 1) (String.length attr - eq - 1)
+
|> String.trim
+
in
+
parse_attribute clock accumulated_attrs attr_name attr_value)
+
attrs;
-
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
-
Some cookie)
+
(* Validate attributes *)
+
if not (validate_attributes accumulated_attrs) then (
+
Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
+
None)
+
else
+
let cookie =
+
build_cookie ~request_domain ~request_path ~name ~value:cookie_value
+
accumulated_attrs ~now
+
in
+
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
+
Some cookie)
and make_cookie_header cookies =
cookies
···
jar.cookies <- cookie :: jar.cookies;
Eio.Mutex.unlock jar.mutex
-
let get_cookies jar ~domain:request_domain ~path:request_path ~is_secure =
+
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure =
Log.debug (fun m ->
-
m "Getting cookies for domain=%s path=%s secure=%b" request_domain request_path is_secure);
+
m "Getting cookies for domain=%s path=%s secure=%b" request_domain
+
request_path is_secure);
Eio.Mutex.lock jar.mutex;
let applicable =
···
(* Update last access time *)
let now =
-
Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
in
let updated =
List.map
···
if List.memq c applicable then
make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c)
~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c)
-
?same_site:(same_site c) ~creation_time:(creation_time c) ~last_access:now ()
+
?same_site:(same_site c) ~creation_time:(creation_time c)
+
~last_access:now ()
else c)
jar.cookies
in
···
Eio.Mutex.unlock jar.mutex;
result
-
let from_mozilla_format content =
+
let from_mozilla_format ~clock content =
Log.debug (fun m -> m "Parsing Mozilla format cookies");
let jar = create () in
···
match String.split_on_char '\t' line with
| [ domain; _include_subdomains; path; secure; expires; name; value ] ->
let now =
-
Ptime.of_float_s (Unix.time ())
+
Ptime.of_float_s (Eio.Time.now clock)
|> Option.value ~default:Ptime.epoch
in
let expires =
let exp_int = try int_of_string expires with _ -> 0 in
-
if exp_int = 0 then None
-
else Ptime.of_float_s (float_of_int exp_int)
+
if exp_int = 0 then None else Ptime.of_float_s (float_of_int exp_int)
in
let cookie =
-
make ~domain ~path ~name ~value
-
~secure:(secure = "TRUE") ~http_only:false
-
?expires ?same_site:None
-
~creation_time:now ~last_access:now ()
+
make ~domain ~path ~name ~value ~secure:(secure = "TRUE")
+
~http_only:false ?expires ?same_site:None ~creation_time:now
+
~last_access:now ()
in
add_cookie jar cookie;
Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
···
(** {1 File Operations} *)
-
let load path =
+
let load ~clock path =
Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
try
let content = Eio.Path.load path in
-
from_mozilla_format content
+
from_mozilla_format ~clock content
with
| Eio.Io _ ->
Log.info (fun m -> m "Cookie file not found, creating empty jar");
+34 -12
lib/cookeio.mli
···
val create : unit -> jar
(** Create an empty cookie jar *)
-
val load : Eio.Fs.dir_ty Eio.Path.t -> jar
-
(** Load cookies from Mozilla format file *)
+
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> jar
+
(** Load cookies from Mozilla format file.
+
+
Loads cookies from a file in Mozilla format, using the provided clock to set
+
creation and last access times. Returns an empty jar if the file doesn't
+
exist or cannot be loaded. *)
val save : Eio.Fs.dir_ty Eio.Path.t -> jar -> unit
(** Save cookies to Mozilla format file *)
···
(** Add a cookie to the jar *)
val get_cookies :
-
jar -> domain:string -> path:string -> is_secure:bool -> t list
-
(** Get cookies applicable for a URL *)
+
jar ->
+
clock:_ Eio.Time.clock ->
+
domain:string ->
+
path:string ->
+
is_secure:bool ->
+
t list
+
(** Get cookies applicable for a URL.
+
+
Returns all cookies that match the given domain and path, and satisfy the
+
secure flag requirement. Also updates the last access time of matching
+
cookies using the provided clock. *)
val clear : jar -> unit
(** Clear all cookies *)
···
(** {1 Cookie Creation and Parsing} *)
-
val parse_set_cookie : domain:string -> path:string -> string -> t option
+
val parse_set_cookie :
+
clock:_ Eio.Time.clock -> domain:string -> path:string -> string -> t option
(** Parse Set-Cookie header value into a cookie.
Parses a Set-Cookie header value following RFC specifications:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
-
- Supports all standard attributes: [expires], [domain], [path], [secure],
-
[httponly], [samesite]
-
- Returns [None] if parsing fails or cookie is invalid
+
- Supports all standard attributes: [expires], [max-age], [domain], [path],
+
[secure], [httponly], [samesite]
+
- Returns [None] if parsing fails or cookie validation fails
- The [domain] and [path] parameters provide the request context for default
values
+
- The [clock] parameter is used for calculating expiry times from [max-age]
+
attributes
+
+
Cookie validation rules:
+
- [SameSite=None] requires the [Secure] flag to be set
Example:
-
[parse_set_cookie ~domain:"example.com" ~path:"/" "session=abc123; Secure;
-
HttpOnly"] *)
+
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/"
+
"session=abc123; Secure; HttpOnly"] *)
val make_cookie_header : t list -> string
(** Create cookie header value from cookies.
···
val to_mozilla_format : jar -> string
(** Write cookies in Mozilla format *)
-
val from_mozilla_format : string -> jar
-
(** Parse Mozilla format cookies *)
+
val from_mozilla_format : clock:_ Eio.Time.clock -> string -> jar
+
(** Parse Mozilla format cookies.
+
+
Creates a cookie jar from a string in Mozilla cookie format, using the
+
provided clock to set creation and last access times. *)
+26 -18
test/test_cookeio.ml
···
&& Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
-
let test_load_mozilla_cookies () =
+
let test_load_mozilla_cookies env =
+
let clock = Eio.Stdenv.clock env in
let content =
{|# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
···
#HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
|}
in
-
let jar = from_mozilla_format content in
+
let jar = from_mozilla_format ~clock content in
let cookies = get_all_cookies jar in
(* Check total number of cookies (should skip commented lines) *)
···
let test_load_from_file env =
(* This test loads from the actual test/cookies.txt file using the load function *)
+
let clock = Eio.Stdenv.clock env in
let cwd = Eio.Stdenv.cwd env in
let cookie_path = Eio.Path.(cwd / "cookies.txt") in
-
let jar = load cookie_path in
+
let jar = load ~clock cookie_path in
let cookies = get_all_cookies jar in
(* Should have the same 5 cookies as the string test *)
···
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"file cookie-2 expires" None (Cookeio.expires cookie2)
-
let test_cookie_matching () =
+
let test_cookie_matching env =
+
let clock = Eio.Stdenv.clock env in
let jar = create () in
(* Add test cookies with different domain patterns *)
···
(* Test exact domain matching *)
let cookies_http =
-
get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
in
Alcotest.(check int) "http cookies count" 2 (List.length cookies_http);
let cookies_https =
-
get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:true
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:true
in
Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
(* Test subdomain matching *)
let cookies_sub =
-
get_cookies jar ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
in
Alcotest.(check int) "subdomain cookies count" 1 (List.length cookies_sub);
let sub_cookie = List.hd cookies_sub in
Alcotest.(check string) "subdomain cookie name" "subdomain" (Cookeio.name sub_cookie)
-
let test_empty_jar () =
+
let test_empty_jar env =
+
let clock = Eio.Stdenv.clock env in
let jar = create () in
Alcotest.(check bool) "empty jar" true (is_empty jar);
Alcotest.(check int) "empty count" 0 (count jar);
···
"empty cookies" [] (get_all_cookies jar);
let cookies =
-
get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
in
Alcotest.(check int) "no matching cookies" 0 (List.length cookies)
-
let test_round_trip_mozilla_format () =
+
let test_round_trip_mozilla_format env =
+
let clock = Eio.Stdenv.clock env in
let jar = create () in
let test_cookie =
···
(* Convert to Mozilla format and back *)
let mozilla_format = to_mozilla_format jar in
-
let jar2 = from_mozilla_format mozilla_format in
+
let jar2 = from_mozilla_format ~clock mozilla_format in
let cookies2 = get_all_cookies jar2 in
Alcotest.(check int) "round trip count" 1 (List.length cookies2);
···
[
( "mozilla_format",
[
-
test_case "Load Mozilla format from string" `Quick
-
test_load_mozilla_cookies;
+
test_case "Load Mozilla format from string" `Quick (fun () ->
+
test_load_mozilla_cookies env);
test_case "Load Mozilla format from file" `Quick (fun () ->
test_load_from_file env);
-
test_case "Round trip Mozilla format" `Quick
-
test_round_trip_mozilla_format;
+
test_case "Round trip Mozilla format" `Quick (fun () ->
+
test_round_trip_mozilla_format env);
] );
( "cookie_matching",
-
[ test_case "Domain and security matching" `Quick test_cookie_matching ]
+
[
+
test_case "Domain and security matching" `Quick (fun () ->
+
test_cookie_matching env);
+
] );
+
( "basic_operations",
+
[ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ]
);
-
( "basic_operations",
-
[ test_case "Empty jar operations" `Quick test_empty_jar ] );
]