···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566-(** Centralized error handling for the Requests library *)
66+(** Centralized error handling for the Requests library using Eio.Io exceptions *)
7788let src = Logs.Src.create "requests.error" ~doc:"HTTP Request Errors"
99module Log = (val Logs.src_log src : Logs.LOG)
10101111-(** {1 Exception Types} *)
1111+(** {1 Error Type}
1212+1313+ Following the Eio.Io exception pattern for structured error handling.
1414+ Each variant contains a record with contextual information. *)
1515+1616+type error =
1717+ (* Timeout errors *)
1818+ | Timeout of { operation: string; duration: float option }
1919+2020+ (* Redirect errors *)
2121+ | Too_many_redirects of { url: string; count: int; max: int }
2222+ | Invalid_redirect of { url: string; reason: string }
2323+2424+ (* HTTP response errors *)
2525+ (* Note: headers stored as list to avoid dependency cycle with Headers module *)
2626+ | Http_error of {
2727+ url: string;
2828+ status: int;
2929+ reason: string;
3030+ body_preview: string option;
3131+ headers: (string * string) list
3232+ }
3333+3434+ (* Authentication errors *)
3535+ | Authentication_failed of { url: string; reason: string }
3636+3737+ (* Connection errors - granular breakdown per Recommendation #17 *)
3838+ | Dns_resolution_failed of { hostname: string }
3939+ | Tcp_connect_failed of { host: string; port: int; reason: string }
4040+ | Tls_handshake_failed of { host: string; reason: string }
4141+4242+ (* Security-related errors *)
4343+ | Invalid_header of { name: string; reason: string }
4444+ | Body_too_large of { limit: int64; actual: int64 option }
4545+ | Headers_too_large of { limit: int; actual: int }
4646+ | Decompression_bomb of { limit: int64; ratio: float }
4747+ | Content_length_mismatch of { expected: int64; actual: int64 }
4848+4949+ (* Other errors *)
5050+ | Proxy_error of { host: string; reason: string }
5151+ | Encoding_error of { encoding: string; reason: string }
5252+ | Invalid_url of { url: string; reason: string }
5353+ | Invalid_request of { reason: string }
5454+5555+(** {1 URL and Credential Sanitization}
5656+5757+ Per Recommendation #20: Remove sensitive info from error messages *)
5858+5959+let sanitize_url url =
6060+ try
6161+ let uri = Uri.of_string url in
6262+ let sanitized = Uri.with_userinfo uri None in
6363+ Uri.to_string sanitized
6464+ with _ -> url (* If parsing fails, return original *)
6565+6666+(** List of header names considered sensitive (lowercase) *)
6767+let sensitive_header_names =
6868+ ["authorization"; "cookie"; "proxy-authorization"; "x-api-key"; "api-key"; "set-cookie"]
6969+7070+(** Check if a header name is sensitive (case-insensitive) *)
7171+let is_sensitive_header name =
7272+ List.mem (String.lowercase_ascii name) sensitive_header_names
7373+7474+(** Sanitize a header list by redacting sensitive values *)
7575+let sanitize_headers headers =
7676+ List.map (fun (name, value) ->
7777+ if is_sensitive_header name then (name, "[REDACTED]")
7878+ else (name, value)
7979+ ) headers
8080+8181+(** {1 Pretty Printing} *)
8282+8383+let pp_error ppf = function
8484+ | Timeout { operation; duration } ->
8585+ (match duration with
8686+ | Some d -> Format.fprintf ppf "Timeout during %s after %.2fs" operation d
8787+ | None -> Format.fprintf ppf "Timeout during %s" operation)
8888+8989+ | Too_many_redirects { url; count; max } ->
9090+ Format.fprintf ppf "Too many redirects (%d/%d) for URL: %s" count max (sanitize_url url)
9191+9292+ | Invalid_redirect { url; reason } ->
9393+ Format.fprintf ppf "Invalid redirect to %s: %s" (sanitize_url url) reason
9494+9595+ | Http_error { url; status; reason; body_preview; headers = _ } ->
9696+ Format.fprintf ppf "@[<v>HTTP %d %s@ URL: %s" status reason (sanitize_url url);
9797+ Option.iter (fun body ->
9898+ let preview = if String.length body > 200
9999+ then String.sub body 0 200 ^ "..."
100100+ else body in
101101+ Format.fprintf ppf "@ Body: %s" preview
102102+ ) body_preview;
103103+ Format.fprintf ppf "@]"
104104+105105+ | Authentication_failed { url; reason } ->
106106+ Format.fprintf ppf "Authentication failed for %s: %s" (sanitize_url url) reason
107107+108108+ | Dns_resolution_failed { hostname } ->
109109+ Format.fprintf ppf "DNS resolution failed for hostname: %s" hostname
110110+111111+ | Tcp_connect_failed { host; port; reason } ->
112112+ Format.fprintf ppf "TCP connection to %s:%d failed: %s" host port reason
113113+114114+ | Tls_handshake_failed { host; reason } ->
115115+ Format.fprintf ppf "TLS handshake with %s failed: %s" host reason
116116+117117+ | Invalid_header { name; reason } ->
118118+ Format.fprintf ppf "Invalid header '%s': %s" name reason
119119+120120+ | Body_too_large { limit; actual } ->
121121+ (match actual with
122122+ | Some a -> Format.fprintf ppf "Response body too large: %Ld bytes (limit: %Ld)" a limit
123123+ | None -> Format.fprintf ppf "Response body exceeds limit of %Ld bytes" limit)
124124+125125+ | Headers_too_large { limit; actual } ->
126126+ Format.fprintf ppf "Response headers too large: %d (limit: %d)" actual limit
127127+128128+ | Decompression_bomb { limit; ratio } ->
129129+ Format.fprintf ppf "Decompression bomb detected: ratio %.1f:1 exceeds limit, max size %Ld bytes"
130130+ ratio limit
131131+132132+ | Content_length_mismatch { expected; actual } ->
133133+ Format.fprintf ppf "Content-Length mismatch: expected %Ld bytes, received %Ld bytes"
134134+ expected actual
135135+136136+ | Proxy_error { host; reason } ->
137137+ Format.fprintf ppf "Proxy error for %s: %s" host reason
138138+139139+ | Encoding_error { encoding; reason } ->
140140+ Format.fprintf ppf "Encoding error (%s): %s" encoding reason
141141+142142+ | Invalid_url { url; reason } ->
143143+ Format.fprintf ppf "Invalid URL '%s': %s" (sanitize_url url) reason
144144+145145+ | Invalid_request { reason } ->
146146+ Format.fprintf ppf "Invalid request: %s" reason
147147+148148+(** {1 Eio.Exn Integration}
149149+150150+ Following the pattern from ocaml-conpool for structured Eio exceptions *)
151151+152152+type Eio.Exn.err += E of error
153153+154154+let err e = Eio.Exn.create (E e)
155155+156156+let () =
157157+ Eio.Exn.register_pp (fun f -> function
158158+ | E e ->
159159+ Format.fprintf f "Requests: ";
160160+ pp_error f e;
161161+ true
162162+ | _ -> false)
163163+164164+(** {1 Query Functions}
165165+166166+ Per Recommendation #17: Enable smarter retry logic and error handling *)
167167+168168+let is_timeout = function
169169+ | Timeout _ -> true
170170+ | _ -> false
171171+172172+let is_dns = function
173173+ | Dns_resolution_failed _ -> true
174174+ | _ -> false
175175+176176+let is_tls = function
177177+ | Tls_handshake_failed _ -> true
178178+ | _ -> false
179179+180180+let is_connection = function
181181+ | Dns_resolution_failed _ -> true
182182+ | Tcp_connect_failed _ -> true
183183+ | Tls_handshake_failed _ -> true
184184+ | _ -> false
185185+186186+let is_http_error = function
187187+ | Http_error _ -> true
188188+ | _ -> false
189189+190190+let is_client_error = function
191191+ | Http_error { status; _ } -> status >= 400 && status < 500
192192+ | Authentication_failed _ -> true
193193+ | Invalid_url _ -> true
194194+ | Invalid_request _ -> true
195195+ | Invalid_header _ -> true
196196+ | _ -> false
121971313-exception Timeout
1414-exception TooManyRedirects of { url: string; count: int; max: int }
1515-exception ConnectionError of string
1616-exception HTTPError of {
1717- url: string;
1818- status: int;
1919- reason: string;
2020- body: string option;
2121- headers: Headers.t
2222-}
2323-exception AuthenticationError of string
2424-exception SSLError of string
2525-exception ProxyError of string
2626-exception EncodingError of string
2727-exception InvalidURL of string
2828-exception InvalidRequest of string
198198+let is_server_error = function
199199+ | Http_error { status; _ } -> status >= 500 && status < 600
200200+ | _ -> false
292013030-(** {1 Error Type} *)
202202+let is_retryable = function
203203+ | Timeout _ -> true
204204+ | Dns_resolution_failed _ -> true
205205+ | Tcp_connect_failed _ -> true
206206+ | Tls_handshake_failed _ -> true
207207+ | Http_error { status; _ } ->
208208+ (* Retryable status codes: 408, 429, 500, 502, 503, 504 *)
209209+ List.mem status [408; 429; 500; 502; 503; 504]
210210+ | Proxy_error _ -> true
211211+ | _ -> false
312123232-type t = [
3333- | `Timeout
3434- | `TooManyRedirects of string * int * int (* url, count, max *)
3535- | `ConnectionError of string
3636- | `HTTPError of string * int * string * string option * Headers.t (* url, status, reason, body, headers *)
3737- | `AuthenticationError of string
3838- | `SSLError of string
3939- | `ProxyError of string
4040- | `EncodingError of string
4141- | `InvalidURL of string
4242- | `InvalidRequest of string
4343- | `UnknownError of string
4444-]
213213+let is_security_error = function
214214+ | Invalid_header _ -> true
215215+ | Body_too_large _ -> true
216216+ | Headers_too_large _ -> true
217217+ | Decompression_bomb _ -> true
218218+ | Invalid_redirect _ -> true
219219+ | _ -> false
220220+221221+(** {1 Error Extraction}
452224646-(** {1 Conversion Functions} *)
223223+ Extract error from Eio.Io exception *)
472244848-let of_exn = function
4949- | Timeout -> Some `Timeout
5050- | TooManyRedirects { url; count; max } ->
5151- Some (`TooManyRedirects (url, count, max))
5252- | ConnectionError msg -> Some (`ConnectionError msg)
5353- | HTTPError { url; status; reason; body; headers } ->
5454- Some (`HTTPError (url, status, reason, body, headers))
5555- | AuthenticationError msg -> Some (`AuthenticationError msg)
5656- | SSLError msg -> Some (`SSLError msg)
5757- | ProxyError msg -> Some (`ProxyError msg)
5858- | EncodingError msg -> Some (`EncodingError msg)
5959- | InvalidURL msg -> Some (`InvalidURL msg)
6060- | InvalidRequest msg -> Some (`InvalidRequest msg)
225225+let of_eio_exn = function
226226+ | Eio.Io (E e, _) -> Some e
61227 | _ -> None
622286363-let to_exn = function
6464- | `Timeout -> Timeout
6565- | `TooManyRedirects (url, count, max) ->
6666- TooManyRedirects { url; count; max }
6767- | `ConnectionError msg -> ConnectionError msg
6868- | `HTTPError (url, status, reason, body, headers) ->
6969- HTTPError { url; status; reason; body; headers }
7070- | `AuthenticationError msg -> AuthenticationError msg
7171- | `SSLError msg -> SSLError msg
7272- | `ProxyError msg -> ProxyError msg
7373- | `EncodingError msg -> EncodingError msg
7474- | `InvalidURL msg -> InvalidURL msg
7575- | `InvalidRequest msg -> InvalidRequest msg
7676- | `UnknownError msg -> Failure msg
229229+(** {1 HTTP Status Helpers} *)
230230+231231+let get_http_status = function
232232+ | Http_error { status; _ } -> Some status
233233+ | _ -> None
234234+235235+let get_url = function
236236+ | Too_many_redirects { url; _ } -> Some url
237237+ | Invalid_redirect { url; _ } -> Some url
238238+ | Http_error { url; _ } -> Some url
239239+ | Authentication_failed { url; _ } -> Some url
240240+ | Invalid_url { url; _ } -> Some url
241241+ | _ -> None
772427878-let raise error = Stdlib.raise (to_exn error)
243243+(** {1 Result Combinators} *)
792448080-(** {1 Combinators} *)
245245+type 'a result = ('a, error) Result.t
8124682247let catch f =
83248 try Ok (f ())
84249 with
8585- | exn ->
8686- match of_exn exn with
8787- | Some err -> Error err
8888- | None -> Error (`UnknownError (Printexc.to_string exn))
8989-9090-let catch_async f = catch f (* In Eio, regular catch works for async too *)
250250+ | Eio.Io (E e, _) -> Error e
251251+ | exn -> Error (Invalid_request { reason = Printexc.to_string exn })
9125292253let map f = function
93254 | Ok x -> Ok (f x)
···105266106267let get_exn = function
107268 | Ok x -> x
108108- | Error e -> raise e
269269+ | Error e -> raise (err e)
109270110271let get_or ~default = function
111272 | Ok x -> x
112273 | Error _ -> default
113274114114-let is_retryable = function
115115- | `Timeout -> true
116116- | `ConnectionError _ -> true
117117- | `HTTPError (_, status, _, _, _) -> Status.is_retryable (Status.of_int status)
118118- | `SSLError _ -> true
119119- | `ProxyError _ -> true
120120- | _ -> false
121121-122122-let is_client_error = function
123123- | `HTTPError (_, status, _, _, _) -> Status.is_client_error (Status.of_int status)
124124- | `AuthenticationError _
125125- | `InvalidURL _
126126- | `InvalidRequest _ -> true
127127- | _ -> false
128128-129129-let is_server_error = function
130130- | `HTTPError (_, status, _, _, _) -> Status.is_server_error (Status.of_int status)
131131- | _ -> false
132132-275275+(** {1 String Conversion} *)
133276134134-(** {1 Pretty Printing} *)
135135-136136-let pp ppf = function
137137- | `Timeout ->
138138- Format.fprintf ppf "@[<2>Request Timeout:@ The request timed out@]"
139139- | `TooManyRedirects (url, count, max) ->
140140- Format.fprintf ppf "@[<2>Too Many Redirects:@ Exceeded maximum redirects (%d/%d) for URL: %s@]"
141141- count max url
142142- | `ConnectionError msg ->
143143- Format.fprintf ppf "@[<2>Connection Error:@ %s@]" msg
144144- | `HTTPError (url, status, reason, body, _headers) ->
145145- Format.fprintf ppf "@[<v>@[<2>HTTP Error %d (%s):@ URL: %s@]" status reason url;
146146- Option.iter (fun b ->
147147- Format.fprintf ppf "@,@[<2>Response Body:@ %s@]" b
148148- ) body;
149149- Format.fprintf ppf "@]"
150150- | `AuthenticationError msg ->
151151- Format.fprintf ppf "@[<2>Authentication Error:@ %s@]" msg
152152- | `SSLError msg ->
153153- Format.fprintf ppf "@[<2>SSL/TLS Error:@ %s@]" msg
154154- | `ProxyError msg ->
155155- Format.fprintf ppf "@[<2>Proxy Error:@ %s@]" msg
156156- | `EncodingError msg ->
157157- Format.fprintf ppf "@[<2>Encoding Error:@ %s@]" msg
158158- | `InvalidURL msg ->
159159- Format.fprintf ppf "@[<2>Invalid URL:@ %s@]" msg
160160- | `InvalidRequest msg ->
161161- Format.fprintf ppf "@[<2>Invalid Request:@ %s@]" msg
162162- | `UnknownError msg ->
163163- Format.fprintf ppf "@[<2>Unknown Error:@ %s@]" msg
164164-165165-let pp_exn ppf exn =
166166- match of_exn exn with
167167- | Some err -> pp ppf err
168168- | None -> Format.fprintf ppf "%s" (Printexc.to_string exn)
169169-170170-let to_string error =
171171- Format.asprintf "%a" pp error
277277+let to_string e =
278278+ Format.asprintf "%a" pp_error e
172279173280(** {1 Syntax Module} *)
174281···176283 let ( let* ) x f = bind f x
177284 let ( let+ ) x f = map f x
178285 let ( and* ) = both
179179-end286286+end
+141-82
lib/error.mli
···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566-(** Centralized error handling for the Requests library *)
66+(** Centralized error handling for the Requests library using Eio.Io exceptions.
77+88+ This module follows the Eio.Io exception pattern for structured error handling,
99+ providing granular error types and query functions for smart retry logic.
1010+1111+ {2 Usage}
1212+1313+ Errors are raised using the Eio.Io pattern:
1414+ {[
1515+ raise (Error.err (Error.Timeout { operation = "connect"; duration = Some 30.0 }))
1616+ ]}
1717+1818+ To catch and handle errors:
1919+ {[
2020+ try
2121+ (* ... HTTP request ... *)
2222+ with
2323+ | Eio.Io (Error.E e, _) when Error.is_retryable e ->
2424+ (* Retry the request *)
2525+ | Eio.Io (Error.E e, _) ->
2626+ Printf.eprintf "Request failed: %s\n" (Error.to_string e)
2727+ ]}
2828+*)
729830(** Log source for error reporting *)
931val src : Logs.Src.t
10321111-(** {1 Exception Types} *)
3333+(** {1 Error Type}
12341313-(** Raised when a request times out *)
1414-exception Timeout
3535+ Granular error variants with contextual information.
3636+ Each variant contains a record with relevant details. *)
15371616-(** Raised when too many redirects are encountered *)
1717-exception TooManyRedirects of { url: string; count: int; max: int }
3838+type error =
3939+ (* Timeout errors *)
4040+ | Timeout of { operation: string; duration: float option }
18411919-(** Raised when a connection error occurs *)
2020-exception ConnectionError of string
4242+ (* Redirect errors *)
4343+ | Too_many_redirects of { url: string; count: int; max: int }
4444+ | Invalid_redirect of { url: string; reason: string }
21452222-(** Raised when an HTTP error response is received *)
2323-exception HTTPError of {
2424- url: string;
2525- status: int;
2626- reason: string;
2727- body: string option;
2828- headers: Headers.t
2929-}
4646+ (* HTTP response errors *)
4747+ (* Note: headers stored as list to avoid dependency cycle with Headers module *)
4848+ | Http_error of {
4949+ url: string;
5050+ status: int;
5151+ reason: string;
5252+ body_preview: string option;
5353+ headers: (string * string) list
5454+ }
30553131-(** Raised when authentication fails *)
3232-exception AuthenticationError of string
5656+ (* Authentication errors *)
5757+ | Authentication_failed of { url: string; reason: string }
33583434-(** Raised when there's an SSL/TLS error *)
3535-exception SSLError of string
5959+ (* Connection errors - granular breakdown *)
6060+ | Dns_resolution_failed of { hostname: string }
6161+ | Tcp_connect_failed of { host: string; port: int; reason: string }
6262+ | Tls_handshake_failed of { host: string; reason: string }
36633737-(** Raised when proxy connection fails *)
3838-exception ProxyError of string
6464+ (* Security-related errors *)
6565+ | Invalid_header of { name: string; reason: string }
6666+ | Body_too_large of { limit: int64; actual: int64 option }
6767+ | Headers_too_large of { limit: int; actual: int }
6868+ | Decompression_bomb of { limit: int64; ratio: float }
6969+ | Content_length_mismatch of { expected: int64; actual: int64 }
39704040-(** Raised when content encoding/decoding fails *)
4141-exception EncodingError of string
7171+ (* Other errors *)
7272+ | Proxy_error of { host: string; reason: string }
7373+ | Encoding_error of { encoding: string; reason: string }
7474+ | Invalid_url of { url: string; reason: string }
7575+ | Invalid_request of { reason: string }
7676+7777+(** {1 Eio.Exn Integration} *)
7878+7979+(** Extension of [Eio.Exn.err] for Requests errors *)
8080+type Eio.Exn.err += E of error
8181+8282+(** Create an Eio exception from an error.
8383+ Usage: [raise (err (Timeout { operation = "read"; duration = Some 5.0 }))] *)
8484+val err : error -> exn
8585+8686+(** {1 URL and Credential Sanitization} *)
8787+8888+(** Remove userinfo (username:password) from a URL for safe logging *)
8989+val sanitize_url : string -> string
9090+9191+(** Redact sensitive headers (Authorization, Cookie, etc.) for safe logging.
9292+ Takes and returns a list of (name, value) pairs. *)
9393+val sanitize_headers : (string * string) list -> (string * string) list
9494+9595+(** Check if a header name is sensitive (case-insensitive) *)
9696+val is_sensitive_header : string -> bool
9797+9898+(** {1 Pretty Printing} *)
9999+100100+(** Pretty printer for error values *)
101101+val pp_error : Format.formatter -> error -> unit
102102+103103+(** {1 Query Functions}
104104+105105+ These functions enable smart error handling without pattern matching. *)
106106+107107+(** Returns [true] if the error is a timeout *)
108108+val is_timeout : error -> bool
421094343-(** Raised when an invalid URL is provided *)
4444-exception InvalidURL of string
110110+(** Returns [true] if the error is a DNS resolution failure *)
111111+val is_dns : error -> bool
451124646-(** Raised when request is invalid *)
4747-exception InvalidRequest of string
113113+(** Returns [true] if the error is a TLS handshake failure *)
114114+val is_tls : error -> bool
481154949-(** {1 Error Type} *)
116116+(** Returns [true] if the error is any connection-related failure
117117+ (DNS, TCP connect, or TLS handshake) *)
118118+val is_connection : error -> bool
501195151-(** Unified error type for result-based error handling *)
5252-type t = [
5353- | `Timeout
5454- | `TooManyRedirects of string * int * int (* url, count, max *)
5555- | `ConnectionError of string
5656- | `HTTPError of string * int * string * string option * Headers.t (* url, status, reason, body, headers *)
5757- | `AuthenticationError of string
5858- | `SSLError of string
5959- | `ProxyError of string
6060- | `EncodingError of string
6161- | `InvalidURL of string
6262- | `InvalidRequest of string
6363- | `UnknownError of string
6464-]
120120+(** Returns [true] if the error is an HTTP response error *)
121121+val is_http_error : error -> bool
651226666-(** {1 Conversion Functions} *)
123123+(** Returns [true] if the error is a client error (4xx status or similar) *)
124124+val is_client_error : error -> bool
671256868-(** Convert an exception to an error type *)
6969-val of_exn : exn -> t option
126126+(** Returns [true] if the error is a server error (5xx status) *)
127127+val is_server_error : error -> bool
701287171-(** Convert an error type to an exception *)
7272-val to_exn : t -> exn
129129+(** Returns [true] if the error is typically retryable.
130130+ Retryable errors include: timeouts, connection errors,
131131+ and certain HTTP status codes (408, 429, 500, 502, 503, 504) *)
132132+val is_retryable : error -> bool
731337474-(** Raise an error as an exception *)
7575-val raise : t -> 'a
134134+(** Returns [true] if the error is security-related
135135+ (header injection, body too large, decompression bomb, etc.) *)
136136+val is_security_error : error -> bool
761377777-(** {1 Combinators} *)
138138+(** {1 Error Extraction} *)
781397979-(** Wrap a function that may raise exceptions into a result type *)
8080-val catch : (unit -> 'a) -> ('a, t) result
140140+(** Extract error from an Eio.Io exception, if it's a Requests error *)
141141+val of_eio_exn : exn -> error option
811428282-(** Wrap an async function that may raise exceptions *)
8383-val catch_async : (unit -> 'a) -> ('a, t) result
143143+(** {1 HTTP Status Helpers} *)
841448585-(** Map over the success case of a result *)
8686-val map : ('a -> 'b) -> ('a, t) result -> ('b, t) result
145145+(** Get the HTTP status code from an error, if applicable *)
146146+val get_http_status : error -> int option
871478888-(** Bind for result types with error *)
8989-val bind : ('a -> ('b, t) result) -> ('a, t) result -> ('b, t) result
148148+(** Get the URL associated with an error, if applicable *)
149149+val get_url : error -> string option
901509191-(** Applicative operator for combining results *)
9292-val both : ('a, t) result -> ('b, t) result -> ('a * 'b, t) result
151151+(** {1 Result Type and Combinators} *)
931529494-(** Get value or raise the error *)
9595-val get_exn : ('a, t) result -> 'a
153153+(** Result type for error handling *)
154154+type 'a result = ('a, error) Result.t
961559797-(** Get value or use default *)
9898-val get_or : default:'a -> ('a, t) result -> 'a
156156+(** Wrap a function that may raise Eio.Io exceptions into a result type *)
157157+val catch : (unit -> 'a) -> 'a result
99158100100-(** Check if error is retryable *)
101101-val is_retryable : t -> bool
159159+(** Map over the success case of a result *)
160160+val map : ('a -> 'b) -> 'a result -> 'b result
102161103103-(** Check if error is a client error (4xx) *)
104104-val is_client_error : t -> bool
162162+(** Bind for result types with error *)
163163+val bind : ('a -> 'b result) -> 'a result -> 'b result
105164106106-(** Check if error is a server error (5xx) *)
107107-val is_server_error : t -> bool
165165+(** Applicative operator for combining results *)
166166+val both : 'a result -> 'b result -> ('a * 'b) result
108167109109-(** {1 Pretty Printing} *)
168168+(** Get value or raise the error as an Eio exception *)
169169+val get_exn : 'a result -> 'a
110170111111-(** Pretty printer for errors *)
112112-val pp : Format.formatter -> t -> unit
171171+(** Get value or use default *)
172172+val get_or : default:'a -> 'a result -> 'a
113173114114-(** Pretty printer for exceptions (falls back to Printexc if not a known exception) *)
115115-val pp_exn : Format.formatter -> exn -> unit
174174+(** {1 String Conversion} *)
116175117117-(** Convert error to string *)
118118-val to_string : t -> string
176176+(** Convert error to human-readable string *)
177177+val to_string : error -> string
119178120179(** {1 Syntax Module} *)
121180122181(** Syntax module for let-operators *)
123182module Syntax : sig
124183 (** Bind operator for result types *)
125125- val ( let* ) : ('a, t) result -> ('a -> ('b, t) result) -> ('b, t) result
184184+ val ( let* ) : 'a result -> ('a -> 'b result) -> 'b result
126185127186 (** Map operator for result types *)
128128- val ( let+ ) : ('a, t) result -> ('a -> 'b) -> ('b, t) result
187187+ val ( let+ ) : 'a result -> ('a -> 'b) -> 'b result
129188130189 (** Both operator for combining results *)
131131- val ( and* ) : ('a, t) result -> ('b, t) result -> ('a * 'b, t) result
132132-end190190+ val ( and* ) : 'a result -> 'b result -> ('a * 'b) result
191191+end
+28
lib/headers.ml
···15151616let normalize_key k = String.lowercase_ascii k
17171818+(** {1 Header Injection Prevention}
1919+2020+ Per Recommendation #3: Validate that header names and values do not contain
2121+ newlines (CR/LF) which could enable HTTP request smuggling attacks.
2222+2323+ Note: We use Invalid_argument here to avoid a dependency cycle with Error module.
2424+ The error will be caught and wrapped appropriately by higher-level code. *)
2525+2626+exception Invalid_header of { name: string; reason: string }
2727+2828+let validate_header_name name =
2929+ if String.contains name '\r' || String.contains name '\n' then
3030+ raise (Invalid_header {
3131+ name;
3232+ reason = "Header name contains CR/LF characters (potential HTTP smuggling)"
3333+ })
3434+3535+let validate_header_value name value =
3636+ if String.contains value '\r' || String.contains value '\n' then
3737+ raise (Invalid_header {
3838+ name;
3939+ reason = "Header value contains CR/LF characters (potential HTTP smuggling)"
4040+ })
4141+1842let add key value t =
4343+ validate_header_name key;
4444+ validate_header_value key value;
1945 let nkey = normalize_key key in
2046 let existing =
2147 match StringMap.find_opt nkey t with
···2652 StringMap.add nkey (key, existing @ [value]) t
27532854let set key value t =
5555+ validate_header_name key;
5656+ validate_header_value key value;
2957 let nkey = normalize_key key in
3058 StringMap.add nkey (key, [value]) t
3159
+14-2
lib/headers.mli
···4040(** [to_list headers] converts headers to an association list.
4141 The order of headers is preserved. *)
42424343+(** {1 Header Injection Prevention} *)
4444+4545+exception Invalid_header of { name: string; reason: string }
4646+(** Raised when a header name or value contains invalid characters (CR/LF)
4747+ that could enable HTTP request smuggling attacks. *)
4848+4349(** {1 Manipulation} *)
44504551val add : string -> string -> t -> t
4652(** [add name value headers] adds a header value. Multiple values
4747- for the same header name are allowed (e.g., for Set-Cookie). *)
5353+ for the same header name are allowed (e.g., for Set-Cookie).
5454+5555+ @raise Invalid_header if the header name or value contains CR/LF characters
5656+ (to prevent HTTP header injection attacks). *)
48574958val set : string -> string -> t -> t
5059(** [set name value headers] sets a header value, replacing any
5151- existing values for that header name. *)
6060+ existing values for that header name.
6161+6262+ @raise Invalid_header if the header name or value contains CR/LF characters
6363+ (to prevent HTTP header injection attacks). *)
52645365val get : string -> t -> string option
5466(** [get name headers] returns the first value for a header name,
+148-29
lib/http_client.ml
···88let src = Logs.Src.create "requests.http_client" ~doc:"Low-level HTTP client"
99module Log = (val Logs.src_log src : Logs.LOG)
10101111-(** Decompression support using the decompress library *)
1111+(** {1 Response Limits Configuration}
1212+1313+ Per Recommendation #2: Configurable limits for response body size,
1414+ header count, and header length to prevent DoS attacks. *)
1515+1616+type limits = {
1717+ max_response_body_size: int64; (** Maximum response body size in bytes *)
1818+ max_header_size: int; (** Maximum size of a single header line *)
1919+ max_header_count: int; (** Maximum number of headers *)
2020+ max_decompressed_size: int64; (** Maximum decompressed size *)
2121+ max_compression_ratio: float; (** Maximum compression ratio allowed *)
2222+}
2323+2424+let default_limits = {
2525+ max_response_body_size = 104_857_600L; (* 100MB *)
2626+ max_header_size = 16_384; (* 16KB *)
2727+ max_header_count = 100;
2828+ max_decompressed_size = 104_857_600L; (* 100MB *)
2929+ max_compression_ratio = 100.0; (* 100:1 *)
3030+}
3131+3232+(** {1 Decompression Support} *)
12331334(** Decompress gzip-encoded data *)
1435let decompress_gzip data =
···93114 Log.warn (fun m -> m "Zlib decompression failed: %s" e);
94115 Error e
951169696-(** Decompress body based on Content-Encoding header *)
9797-let decompress_body ~content_encoding body =
117117+(** {1 Decompression Bomb Prevention}
118118+119119+ Per Recommendation #25: Check decompressed size and ratio limits *)
120120+121121+let check_decompression_limits ~limits ~compressed_size decompressed =
122122+ let decompressed_size = Int64.of_int (String.length decompressed) in
123123+ let compressed_size_i64 = Int64.of_int compressed_size in
124124+125125+ (* Check absolute size *)
126126+ if decompressed_size > limits.max_decompressed_size then begin
127127+ let ratio = Int64.to_float decompressed_size /. Int64.to_float compressed_size_i64 in
128128+ raise (Error.err (Error.Decompression_bomb {
129129+ limit = limits.max_decompressed_size;
130130+ ratio
131131+ }))
132132+ end;
133133+134134+ (* Check ratio - only if compressed size is > 0 to avoid division by zero *)
135135+ if compressed_size > 0 then begin
136136+ let ratio = Int64.to_float decompressed_size /. Int64.to_float compressed_size_i64 in
137137+ if ratio > limits.max_compression_ratio then
138138+ raise (Error.err (Error.Decompression_bomb {
139139+ limit = limits.max_decompressed_size;
140140+ ratio
141141+ }))
142142+ end;
143143+144144+ decompressed
145145+146146+(** Decompress body based on Content-Encoding header with limits *)
147147+let decompress_body ~limits ~content_encoding body =
98148 let encoding = String.lowercase_ascii (String.trim content_encoding) in
149149+ let compressed_size = String.length body in
99150 match encoding with
100151 | "gzip" | "x-gzip" ->
101152 (match decompress_gzip body with
102102- | Ok decompressed -> decompressed
153153+ | Ok decompressed -> check_decompression_limits ~limits ~compressed_size decompressed
103154 | Error _ -> body) (* Fall back to raw body on error *)
104155 | "deflate" ->
105156 (* "deflate" in HTTP can mean either raw DEFLATE or zlib-wrapped.
106157 Many servers send zlib-wrapped data despite the spec. Try zlib first,
107158 then fall back to raw deflate. *)
108159 (match decompress_zlib body with
109109- | Ok decompressed -> decompressed
160160+ | Ok decompressed -> check_decompression_limits ~limits ~compressed_size decompressed
110161 | Error _ ->
111162 match decompress_deflate body with
112112- | Ok decompressed -> decompressed
163163+ | Ok decompressed -> check_decompression_limits ~limits ~compressed_size decompressed
113164 | Error _ -> body)
114165 | "identity" | "" -> body
115166 | other ->
116167 Log.warn (fun m -> m "Unknown Content-Encoding '%s', returning raw body" other);
117168 body
169169+170170+(** {1 Request Building} *)
118171119172(** Build HTTP/1.1 request as a string *)
120173let build_request ~method_ ~uri ~headers ~body_str =
···128181129182 let host = match Uri.host uri with
130183 | Some h -> h
131131- | None -> failwith "URI must have a host"
184184+ | None -> raise (Error.err (Error.Invalid_url {
185185+ url = Uri.to_string uri;
186186+ reason = "URI must have a host"
187187+ }))
132188 in
133189134190 (* RFC 7230: default ports should be omitted from Host header *)
···169225170226 request_line ^ headers_str ^ "\r\n" ^ body_str
171227228228+(** {1 Response Parsing} *)
229229+172230(** Parse HTTP response status line *)
173231let parse_status_line line =
174232 match String.split_on_char ' ' line with
175233 | "HTTP/1.1" :: code :: _ | "HTTP/1.0" :: code :: _ ->
176234 (try int_of_string code
177177- with _ -> failwith ("Invalid status code: " ^ code))
178178- | _ -> failwith ("Invalid status line: " ^ line)
235235+ with _ -> raise (Error.err (Error.Invalid_request {
236236+ reason = "Invalid status code: " ^ code
237237+ })))
238238+ | _ -> raise (Error.err (Error.Invalid_request {
239239+ reason = "Invalid status line: " ^ line
240240+ }))
241241+242242+(** Parse HTTP headers from buffer reader with limits
179243180180-(** Parse HTTP headers from buffer reader *)
181181-let parse_headers buf_read =
182182- let rec read_headers acc =
244244+ Per Recommendation #2: Enforce header count and size limits *)
245245+let parse_headers ~limits buf_read =
246246+ let rec read_headers acc count =
183247 let line = Eio.Buf_read.line buf_read in
248248+249249+ (* Check for end of headers *)
184250 if line = "" then List.rev acc
185251 else begin
252252+ (* Check header count limit *)
253253+ if count >= limits.max_header_count then
254254+ raise (Error.err (Error.Headers_too_large {
255255+ limit = limits.max_header_count;
256256+ actual = count + 1
257257+ }));
258258+259259+ (* Check header line size limit *)
260260+ if String.length line > limits.max_header_size then
261261+ raise (Error.err (Error.Headers_too_large {
262262+ limit = limits.max_header_size;
263263+ actual = String.length line
264264+ }));
265265+186266 match String.index_opt line ':' with
187187- | None -> read_headers acc
267267+ | None -> read_headers acc (count + 1)
188268 | Some idx ->
189269 let name = String.sub line 0 idx |> String.trim |> String.lowercase_ascii in
190270 let value = String.sub line (idx + 1) (String.length line - idx - 1) |> String.trim in
191191- read_headers ((name, value) :: acc)
271271+ read_headers ((name, value) :: acc) (count + 1)
192272 end
193273 in
194194- read_headers [] |> Headers.of_list
274274+ read_headers [] 0 |> Headers.of_list
195275196196-(** Read body with Content-Length *)
197197-let read_fixed_body buf_read length =
276276+(** Read body with Content-Length and size limit
277277+278278+ Per Recommendation #26: Validate Content-Length matches actual body size
279279+ Per Recommendation #2: Enforce body size limits *)
280280+let read_fixed_body ~limits buf_read length =
281281+ (* Check size limit before allocating *)
282282+ if length > limits.max_response_body_size then
283283+ raise (Error.err (Error.Body_too_large {
284284+ limit = limits.max_response_body_size;
285285+ actual = Some length
286286+ }));
287287+198288 let buf = Buffer.create (Int64.to_int length) in
289289+ let bytes_read = ref 0L in
290290+199291 let rec read_n remaining =
200292 if remaining > 0L then begin
201293 let to_read = min 8192 (Int64.to_int remaining) in
202294 let chunk = Eio.Buf_read.take to_read buf_read in
203203- Buffer.add_string buf chunk;
204204- read_n (Int64.sub remaining (Int64.of_int (String.length chunk)))
295295+ let chunk_len = String.length chunk in
296296+297297+ if chunk_len = 0 then
298298+ (* Connection closed prematurely - Content-Length mismatch *)
299299+ raise (Error.err (Error.Content_length_mismatch {
300300+ expected = length;
301301+ actual = !bytes_read
302302+ }))
303303+ else begin
304304+ Buffer.add_string buf chunk;
305305+ bytes_read := Int64.add !bytes_read (Int64.of_int chunk_len);
306306+ read_n (Int64.sub remaining (Int64.of_int chunk_len))
307307+ end
205308 end
206309 in
207310 read_n length;
208311 Buffer.contents buf
209312210210-(** Read chunked body *)
211211-let read_chunked_body buf_read =
313313+(** Read chunked body with size limit
314314+315315+ Per Recommendation #2: Enforce body size limits *)
316316+let read_chunked_body ~limits buf_read =
212317 let buf = Buffer.create 4096 in
318318+ let total_size = ref 0L in
319319+213320 let rec read_chunks () =
214321 let size_line = Eio.Buf_read.line buf_read in
215322 (* Parse hex chunk size, ignore extensions after ';' *)
···218325 | None -> size_line
219326 in
220327 let chunk_size = int_of_string ("0x" ^ size_str) in
328328+221329 if chunk_size = 0 then begin
222330 (* Read trailing headers (if any) until empty line *)
223331 let rec skip_trailers () =
···226334 in
227335 skip_trailers ()
228336 end else begin
337337+ (* Check size limit before reading chunk *)
338338+ let new_total = Int64.add !total_size (Int64.of_int chunk_size) in
339339+ if new_total > limits.max_response_body_size then
340340+ raise (Error.err (Error.Body_too_large {
341341+ limit = limits.max_response_body_size;
342342+ actual = Some new_total
343343+ }));
344344+229345 let chunk = Eio.Buf_read.take chunk_size buf_read in
230346 Buffer.add_string buf chunk;
347347+ total_size := new_total;
231348 let _crlf = Eio.Buf_read.line buf_read in (* Read trailing CRLF *)
232349 read_chunks ()
233350 end
234351 in
235352 read_chunks ();
236353 Buffer.contents buf
354354+355355+(** {1 Request Execution} *)
237356238357(** Make HTTP request over a pooled connection *)
239239-let make_request ~method_ ~uri ~headers ~body_str flow =
358358+let make_request ?(limits=default_limits) ~method_ ~uri ~headers ~body_str flow =
240359 Log.debug (fun m -> m "Making %s request to %s" method_ (Uri.to_string uri));
241360242361 (* Build and send request *)
···252371253372 Log.debug (fun m -> m "Received response status: %d" status);
254373255255- (* Parse headers *)
256256- let resp_headers = parse_headers buf_read in
374374+ (* Parse headers with limits *)
375375+ let resp_headers = parse_headers ~limits buf_read in
257376258377 (* Determine how to read body *)
259378 let transfer_encoding = Headers.get "transfer-encoding" resp_headers in
···262381 let body_str = match transfer_encoding, content_length with
263382 | Some te, _ when String.lowercase_ascii te |> String.trim = "chunked" ->
264383 Log.debug (fun m -> m "Reading chunked response body");
265265- read_chunked_body buf_read
384384+ read_chunked_body ~limits buf_read
266385 | _, Some len ->
267386 Log.debug (fun m -> m "Reading fixed-length response body (%Ld bytes)" len);
268268- read_fixed_body buf_read len
387387+ read_fixed_body ~limits buf_read len
269388 | Some other_te, None ->
270389 Log.warn (fun m -> m "Unsupported transfer-encoding: %s, assuming no body" other_te);
271390 ""
···277396 (status, resp_headers, body_str)
278397279398(** Make HTTP request with optional auto-decompression *)
280280-let make_request_decompress ~method_ ~uri ~headers ~body_str ~auto_decompress flow =
281281- let (status, resp_headers, body_str) = make_request ~method_ ~uri ~headers ~body_str flow in
399399+let make_request_decompress ?(limits=default_limits) ~method_ ~uri ~headers ~body_str ~auto_decompress flow =
400400+ let (status, resp_headers, body_str) = make_request ~limits ~method_ ~uri ~headers ~body_str flow in
282401 if auto_decompress then
283402 let body_str = match Headers.get "content-encoding" resp_headers with
284284- | Some encoding -> decompress_body ~content_encoding:encoding body_str
403403+ | Some encoding -> decompress_body ~limits ~content_encoding:encoding body_str
285404 | None -> body_str
286405 in
287406 (* Remove Content-Encoding header after decompression since body is now uncompressed *)
+71-30
lib/one.ml
···2424 (scheme1 = "http" && scheme2 = "https")
2525 | _ -> false
26262727-(* Strip sensitive headers for cross-origin redirects to prevent credential leakage *)
2727+(* Strip sensitive headers for cross-origin redirects to prevent credential leakage
2828+ Per Recommendation #1: Also strip Cookie, Proxy-Authorization, WWW-Authenticate *)
2829let strip_sensitive_headers headers =
2930 headers
3031 |> Headers.remove "Authorization"
3232+ |> Headers.remove "Cookie"
3333+ |> Headers.remove "Proxy-Authorization"
3434+ |> Headers.remove "WWW-Authenticate"
3535+3636+(* Validate redirect URL scheme to prevent SSRF attacks
3737+ Per Recommendation #5: Only allow http:// and https:// schemes *)
3838+let allowed_redirect_schemes = ["http"; "https"]
3939+4040+let validate_redirect_url location =
4141+ let uri = Uri.of_string location in
4242+ match Uri.scheme uri with
4343+ | Some scheme when List.mem (String.lowercase_ascii scheme) allowed_redirect_schemes ->
4444+ Ok uri
4545+ | Some scheme ->
4646+ Error (Error.err (Error.Invalid_redirect {
4747+ url = location;
4848+ reason = Printf.sprintf "Disallowed redirect scheme: %s" scheme
4949+ }))
5050+ | None ->
5151+ Ok uri (* Relative URLs are OK - they will be resolved against current URL *)
31523253(* Helper to create TCP connection to host:port *)
3354let connect_tcp ~sw ~net ~host ~port =
···4364 Log.err (fun m -> m "%s" msg);
4465 failwith msg
45666767+(** Minimum TLS version configuration.
6868+ Per Recommendation #6: Allow enforcing minimum TLS version. *)
6969+type tls_version =
7070+ | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *)
7171+ | TLS_1_3 (** TLS 1.3 minimum (most secure, may not work with older servers) *)
7272+7373+let tls_version_to_tls = function
7474+ | TLS_1_2 -> `TLS_1_2
7575+ | TLS_1_3 -> `TLS_1_3
7676+4677(* Helper to wrap connection with TLS if needed *)
4747-let wrap_tls flow ~host ~verify_tls ~tls_config =
7878+let wrap_tls flow ~host ~verify_tls ~tls_config ~min_tls_version =
4879 Log.debug (fun m -> m "Wrapping connection with TLS for %s (verify=%b)" host verify_tls);
49805050- (* Get or create TLS config *)
8181+ (* Get or create TLS config with minimum version enforcement *)
8282+ let min_version = tls_version_to_tls min_tls_version in
5183 let tls_cfg = match tls_config, verify_tls with
5284 | Some cfg, _ -> cfg
5385 | None, true ->
5454- (* Use CA certificates for verification *)
8686+ (* Use CA certificates for verification with minimum TLS version *)
5587 (match Ca_certs.authenticator () with
5688 | Ok authenticator ->
5757- (match Tls.Config.client ~authenticator () with
8989+ (match Tls.Config.client ~authenticator ~version:(min_version, `TLS_1_3) () with
5890 | Ok cfg -> cfg
5991 | Error (`Msg msg) ->
6092 Log.err (fun m -> m "Failed to create TLS config: %s" msg);
···6395 Log.err (fun m -> m "Failed to load CA certificates: %s" msg);
6496 failwith ("CA certificates error: " ^ msg))
6597 | None, false ->
6666- (* No verification *)
6767- match Tls.Config.client ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None) () with
9898+ (* No verification but still enforce minimum TLS version *)
9999+ match Tls.Config.client
100100+ ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None)
101101+ ~version:(min_version, `TLS_1_3)
102102+ () with
68103 | Ok cfg -> cfg
69104 | Error (`Msg msg) -> failwith ("TLS config error: " ^ msg)
70105 in
···84119 (Tls_eio.client_of_flow ~host:domain tls_cfg flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
8512086121(* Parse URL and connect directly (no pooling) *)
8787-let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config =
122122+let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config ~min_tls_version =
88123 let uri = Uri.of_string url in
8912490125 (* Extract host and port *)
···101136 let connect_fn () =
102137 let tcp_flow = connect_tcp ~sw ~net ~host ~port in
103138 if is_https then
104104- wrap_tls tcp_flow ~host ~verify_tls ~tls_config
139139+ wrap_tls tcp_flow ~host ~verify_tls ~tls_config ~min_tls_version
105140 else
106141 (tcp_flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
107142 in
···115150(* Main request implementation - completely stateless *)
116151let request ~sw ~clock ~net ?headers ?body ?auth ?timeout
117152 ?(follow_redirects = true) ?(max_redirects = 10)
118118- ?(verify_tls = true) ?tls_config ?(auto_decompress = true) ~method_ url =
153153+ ?(verify_tls = true) ?tls_config ?(auto_decompress = true)
154154+ ?(min_tls_version = TLS_1_2) ~method_ url =
119155120156 let start_time = Unix.gettimeofday () in
121157 let method_str = Method.to_string method_ in
···156192157193 (* Connect to URL (opens new TCP connection) *)
158194 let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
159159- ~timeout ~verify_tls ~tls_config in
195195+ ~timeout ~verify_tls ~tls_config ~min_tls_version in
160196161197 (* Make HTTP request using low-level client with optional auto-decompression *)
162198 let status, resp_headers, response_body_str =
···171207 if follow_redirects && (status >= 300 && status < 400) then begin
172208 if redirects_left <= 0 then begin
173209 Log.err (fun m -> m "Too many redirects (%d) for %s" max_redirects url);
174174- raise (Error.TooManyRedirects { url; count = max_redirects; max = max_redirects })
210210+ raise (Error.err (Error.Too_many_redirects { url; count = max_redirects; max = max_redirects }))
175211 end;
176212177213 match Headers.get "location" resp_headers with
···179215 Log.debug (fun m -> m "Redirect response missing Location header");
180216 (status, resp_headers, response_body_str, url_to_fetch)
181217 | Some location ->
218218+ (* Validate redirect URL scheme - Per Recommendation #5 *)
219219+ (match validate_redirect_url location with
220220+ | Error exn -> raise exn
221221+ | Ok _ -> ());
222222+182223 Log.info (fun m -> m "Following redirect to %s (%d remaining)" location redirects_left);
183224 (* Strip sensitive headers on cross-origin redirects (security)
184184- Following Python requests behavior: auth headers should not leak to other hosts *)
225225+ Per Recommendation #1: Strip auth headers to prevent credential leakage *)
185226 let redirect_uri = Uri.of_string location in
186227 let headers_for_redirect =
187228 if same_origin original_uri redirect_uri then
188229 headers_for_request
189230 else begin
190190- Log.debug (fun m -> m "Cross-origin redirect detected: stripping Authorization header");
231231+ Log.debug (fun m -> m "Cross-origin redirect detected: stripping sensitive headers");
191232 strip_sensitive_headers headers_for_request
192233 end
193234 in
···216257217258(* Convenience methods *)
218259let get ~sw ~clock ~net ?headers ?auth ?timeout
219219- ?follow_redirects ?max_redirects ?verify_tls ?tls_config url =
260260+ ?follow_redirects ?max_redirects ?verify_tls ?tls_config ?min_tls_version url =
220261 request ~sw ~clock ~net ?headers ?auth ?timeout
221221- ?follow_redirects ?max_redirects ?verify_tls ?tls_config
262262+ ?follow_redirects ?max_redirects ?verify_tls ?tls_config ?min_tls_version
222263 ~method_:`GET url
223264224265let post ~sw ~clock ~net ?headers ?body ?auth ?timeout
225225- ?verify_tls ?tls_config url =
266266+ ?verify_tls ?tls_config ?min_tls_version url =
226267 request ~sw ~clock ~net ?headers ?body ?auth ?timeout
227227- ?verify_tls ?tls_config ~method_:`POST url
268268+ ?verify_tls ?tls_config ?min_tls_version ~method_:`POST url
228269229270let put ~sw ~clock ~net ?headers ?body ?auth ?timeout
230230- ?verify_tls ?tls_config url =
271271+ ?verify_tls ?tls_config ?min_tls_version url =
231272 request ~sw ~clock ~net ?headers ?body ?auth ?timeout
232232- ?verify_tls ?tls_config ~method_:`PUT url
273273+ ?verify_tls ?tls_config ?min_tls_version ~method_:`PUT url
233274234275let delete ~sw ~clock ~net ?headers ?auth ?timeout
235235- ?verify_tls ?tls_config url =
276276+ ?verify_tls ?tls_config ?min_tls_version url =
236277 request ~sw ~clock ~net ?headers ?auth ?timeout
237237- ?verify_tls ?tls_config ~method_:`DELETE url
278278+ ?verify_tls ?tls_config ?min_tls_version ~method_:`DELETE url
238279239280let head ~sw ~clock ~net ?headers ?auth ?timeout
240240- ?verify_tls ?tls_config url =
281281+ ?verify_tls ?tls_config ?min_tls_version url =
241282 request ~sw ~clock ~net ?headers ?auth ?timeout
242242- ?verify_tls ?tls_config ~method_:`HEAD url
283283+ ?verify_tls ?tls_config ?min_tls_version ~method_:`HEAD url
243284244285let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout
245245- ?verify_tls ?tls_config url =
286286+ ?verify_tls ?tls_config ?min_tls_version url =
246287 request ~sw ~clock ~net ?headers ?body ?auth ?timeout
247247- ?verify_tls ?tls_config ~method_:`PATCH url
288288+ ?verify_tls ?tls_config ?min_tls_version ~method_:`PATCH url
248289249290let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length
250250- ?on_progress ?verify_tls ?tls_config ~source url =
291291+ ?on_progress ?verify_tls ?tls_config ?min_tls_version ~source url =
251292 let method_ = Option.value method_ ~default:`POST in
252293 let mime = Option.value mime ~default:Mime.octet_stream in
253294···264305265306 let body = Body.of_stream ?length mime tracked_source in
266307 request ~sw ~clock ~net ?headers ~body ?auth ?timeout
267267- ?verify_tls ?tls_config ~method_ url
308308+ ?verify_tls ?tls_config ?min_tls_version ~method_ url
268309269310let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress
270270- ?verify_tls ?tls_config url ~sink =
311311+ ?verify_tls ?tls_config ?min_tls_version url ~sink =
271312 let response = get ~sw ~clock ~net ?headers ?auth ?timeout
272272- ?verify_tls ?tls_config url in
313313+ ?verify_tls ?tls_config ?min_tls_version url in
273314274315 try
275316 (* Get content length for progress tracking *)
+20-2
lib/one.mli
···4646(** Log source for one-shot request operations *)
4747val src : Logs.Src.t
48484949+(** {1 TLS Configuration} *)
5050+5151+(** Minimum TLS version configuration.
5252+ Per security recommendations, allows enforcing minimum TLS version. *)
5353+type tls_version =
5454+ | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *)
5555+ | TLS_1_3 (** TLS 1.3 minimum (most secure, may not work with older servers) *)
5656+4957(** {1 HTTP Request Methods}
50585159 All functions are stateless - they open a new TCP connection for each request
···6472 ?verify_tls:bool ->
6573 ?tls_config:Tls.Config.client ->
6674 ?auto_decompress:bool ->
7575+ ?min_tls_version:tls_version ->
6776 method_:Method.t ->
6877 string ->
6978 Response.t
7079(** [request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?follow_redirects
7171- ?max_redirects ?verify_tls ?tls_config ?auto_decompress ~method_ url] makes
7272- a single HTTP request without connection pooling.
8080+ ?max_redirects ?verify_tls ?tls_config ?auto_decompress ?min_tls_version
8181+ ~method_ url] makes a single HTTP request without connection pooling.
73827483 Each call opens a new TCP connection (with TLS if https://), makes the
7584 request, and closes the connection when the switch closes.
···8695 @param verify_tls Whether to verify TLS certificates (default: true)
8796 @param tls_config Custom TLS configuration (default: system CA certs)
8897 @param auto_decompress Whether to automatically decompress gzip/deflate responses (default: true)
9898+ @param min_tls_version Minimum TLS version to accept (default: TLS_1_2)
8999 @param method_ HTTP method (GET, POST, etc.)
90100 @param url URL to request
91101*)
···101111 ?max_redirects:int ->
102112 ?verify_tls:bool ->
103113 ?tls_config:Tls.Config.client ->
114114+ ?min_tls_version:tls_version ->
104115 string ->
105116 Response.t
106117(** GET request. See {!request} for parameter details. *)
···115126 ?timeout:Timeout.t ->
116127 ?verify_tls:bool ->
117128 ?tls_config:Tls.Config.client ->
129129+ ?min_tls_version:tls_version ->
118130 string ->
119131 Response.t
120132(** POST request. See {!request} for parameter details. *)
···129141 ?timeout:Timeout.t ->
130142 ?verify_tls:bool ->
131143 ?tls_config:Tls.Config.client ->
144144+ ?min_tls_version:tls_version ->
132145 string ->
133146 Response.t
134147(** PUT request. See {!request} for parameter details. *)
···142155 ?timeout:Timeout.t ->
143156 ?verify_tls:bool ->
144157 ?tls_config:Tls.Config.client ->
158158+ ?min_tls_version:tls_version ->
145159 string ->
146160 Response.t
147161(** DELETE request. See {!request} for parameter details. *)
···155169 ?timeout:Timeout.t ->
156170 ?verify_tls:bool ->
157171 ?tls_config:Tls.Config.client ->
172172+ ?min_tls_version:tls_version ->
158173 string ->
159174 Response.t
160175(** HEAD request. See {!request} for parameter details. *)
···169184 ?timeout:Timeout.t ->
170185 ?verify_tls:bool ->
171186 ?tls_config:Tls.Config.client ->
187187+ ?min_tls_version:tls_version ->
172188 string ->
173189 Response.t
174190(** PATCH request. See {!request} for parameter details. *)
···186202 ?on_progress:(sent:int64 -> total:int64 option -> unit) ->
187203 ?verify_tls:bool ->
188204 ?tls_config:Tls.Config.client ->
205205+ ?min_tls_version:tls_version ->
189206 source:Eio.Flow.source_ty Eio.Resource.t ->
190207 string ->
191208 Response.t
···201218 ?on_progress:(received:int64 -> total:int64 option -> unit) ->
202219 ?verify_tls:bool ->
203220 ?tls_config:Tls.Config.client ->
221221+ ?min_tls_version:tls_version ->
204222 string ->
205223 sink:Eio.Flow.sink_ty Eio.Resource.t ->
206224 unit
+70-12
lib/requests.ml
···2121module Error = Error
2222module Retry = Retry
23232424+(** Minimum TLS version configuration.
2525+ Per Recommendation #6: Allow enforcing minimum TLS version. *)
2626+type tls_version =
2727+ | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *)
2828+ | TLS_1_3 (** TLS 1.3 minimum (most secure, may not work with older servers) *)
2929+3030+let tls_version_to_tls = function
3131+ | TLS_1_2 -> `TLS_1_2
3232+ | TLS_1_3 -> `TLS_1_3
3333+2434(* Note: RNG initialization should be done by the application using
2535 Mirage_crypto_rng_unix.initialize before calling Eio_main.run.
2636 We don't call use_default() here as it spawns background threads
···6979 ?(max_redirects = 10)
7080 ?(verify_tls = true)
7181 ?tls_config
8282+ ?(min_tls_version = TLS_1_2)
7283 ?(max_connections_per_host = 10)
7384 ?(connection_idle_timeout = 60.0)
7485 ?(connection_lifetime = 300.0)
···8798 | None, false -> None
8899 in
891009090- (* Create TLS config for HTTPS pool if needed *)
101101+ (* Create TLS config for HTTPS pool if needed
102102+ Per Recommendation #6: Enforce minimum TLS version *)
103103+ let min_version = tls_version_to_tls min_tls_version in
91104 let tls_config = match tls_config, verify_tls with
92105 | Some cfg, _ -> Some cfg
93106 | None, true ->
9494- (* Use CA certificates for verification *)
107107+ (* Use CA certificates for verification with minimum TLS version *)
95108 (match Ca_certs.authenticator () with
96109 | Ok authenticator ->
9797- (match Tls.Config.client ~authenticator () with
110110+ (match Tls.Config.client ~authenticator ~version:(min_version, `TLS_1_3) () with
98111 | Ok cfg -> Some cfg
99112 | Error (`Msg msg) ->
100113 Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
···102115 | Error (`Msg msg) ->
103116 Log.warn (fun m -> m "Failed to load CA certificates: %s" msg);
104117 None)
105105- | None, false -> None
118118+ | None, false ->
119119+ (* No verification but still enforce minimum TLS version *)
120120+ (match Tls.Config.client
121121+ ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None)
122122+ ~version:(min_version, `TLS_1_3)
123123+ () with
124124+ | Ok cfg -> Some cfg
125125+ | Error (`Msg msg) ->
126126+ Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
127127+ None)
106128 in
107129108130 (* Create connection pools if not provided *)
···207229 (scheme1 = "http" && scheme2 = "https")
208230 | _ -> false
209231210210-(* Strip sensitive headers for cross-origin redirects to prevent credential leakage *)
232232+(* Strip sensitive headers for cross-origin redirects to prevent credential leakage
233233+ Per Recommendation #1: Also strip Cookie, Proxy-Authorization, WWW-Authenticate *)
211234let strip_sensitive_headers headers =
212235 headers
213236 |> Headers.remove "Authorization"
237237+ |> Headers.remove "Cookie"
238238+ |> Headers.remove "Proxy-Authorization"
239239+ |> Headers.remove "WWW-Authenticate"
240240+241241+(* Validate redirect URL scheme to prevent SSRF attacks
242242+ Per Recommendation #5: Only allow http:// and https:// schemes *)
243243+let allowed_redirect_schemes = ["http"; "https"]
244244+245245+let validate_redirect_url location =
246246+ let uri = Uri.of_string location in
247247+ match Uri.scheme uri with
248248+ | Some scheme when List.mem (String.lowercase_ascii scheme) allowed_redirect_schemes ->
249249+ Ok uri
250250+ | Some scheme ->
251251+ Error (Error.err (Error.Invalid_redirect {
252252+ url = location;
253253+ reason = Printf.sprintf "Disallowed redirect scheme: %s" scheme
254254+ }))
255255+ | None ->
256256+ Ok uri (* Relative URLs are OK - they will be resolved against current URL *)
214257215258(* Internal request function using connection pools *)
216259let make_request_internal (T t) ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
···387430 if follow && (status >= 300 && status < 400) then begin
388431 if redirects_left <= 0 then begin
389432 Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url);
390390- raise (Error.TooManyRedirects { url; count = max_redir; max = max_redir })
433433+ raise (Error.err (Error.Too_many_redirects { url; count = max_redir; max = max_redir }))
391434 end;
392435393436 match Headers.get "location" resp_headers with
···395438 Log.debug (fun m -> m "Redirect response missing Location header");
396439 (status, resp_headers, response_body_str, url_to_fetch)
397440 | Some location ->
441441+ (* Validate redirect URL scheme - Per Recommendation #5 *)
442442+ (match validate_redirect_url location with
443443+ | Error exn -> raise exn
444444+ | Ok _ -> ());
445445+398446 (* Resolve relative redirects against the current URL *)
399447 let location_uri = Uri.of_string location in
400448 let absolute_location =
···409457 in
410458 Log.info (fun m -> m "Following redirect to %s (%d remaining)" absolute_location redirects_left);
411459 (* Strip sensitive headers on cross-origin redirects (security)
412412- Following Python requests behavior: auth headers should not leak to other hosts *)
460460+ Per Recommendation #1: Strip auth headers to prevent credential leakage *)
413461 let redirect_uri = Uri.of_string absolute_location in
414462 let headers_for_redirect =
415463 if same_origin original_uri redirect_uri then
416464 headers_for_request
417465 else begin
418418- Log.debug (fun m -> m "Cross-origin redirect detected: stripping Authorization header");
466466+ Log.debug (fun m -> m "Cross-origin redirect detected: stripping sensitive headers");
419467 strip_sensitive_headers headers_for_request
420468 end
421469 in
···516564 with_digest_handling response
517565 | Some retry_config ->
518566 (* Wrap in retry logic *)
567567+ (* Check if an Eio.Io exception is retryable using the new error types *)
519568 let should_retry_exn = function
520520- | Error.Timeout -> true
521521- | Error.ConnectionError _ -> true
522522- | Error.SSLError _ -> true
569569+ | Eio.Io (Error.E e, _) -> Error.is_retryable e
570570+ | Eio.Time.Timeout -> true
523571 | _ -> false
524572 in
525573···540588 if attempt <= retry_config.Retry.max_retries &&
541589 Retry.should_retry ~config:retry_config ~method_ ~status
542590 then begin
543543- let delay = Retry.calculate_backoff ~config:retry_config ~attempt in
591591+ (* Per Recommendation #4: Use Retry-After header when available *)
592592+ let delay =
593593+ if retry_config.respect_retry_after && (status = 429 || status = 503) then
594594+ match Response.header "retry-after" response with
595595+ | Some value ->
596596+ Retry.parse_retry_after value
597597+ |> Option.value ~default:(Retry.calculate_backoff ~config:retry_config ~attempt)
598598+ | None -> Retry.calculate_backoff ~config:retry_config ~attempt
599599+ else
600600+ Retry.calculate_backoff ~config:retry_config ~attempt
601601+ in
544602 Log.warn (fun m -> m "Request returned status %d (attempt %d/%d). Retrying in %.2f seconds..."
545603 status attempt (retry_config.Retry.max_retries + 1) delay);
546604 Eio.Time.sleep t.clock delay;
+10
lib/requests.mli
···208208 connection pools across requests. The clock and network resources are
209209 existentially quantified and hidden behind this abstract type. *)
210210211211+(** {2 TLS Configuration} *)
212212+213213+type tls_version =
214214+ | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *)
215215+ | TLS_1_3 (** TLS 1.3 minimum (most secure, may not work with older servers) *)
216216+(** Minimum TLS version to require for HTTPS connections.
217217+ Per Recommendation #6: Allow enforcing minimum TLS version for security. *)
218218+211219(** {2 Creation and Configuration} *)
212220213221val create :
···222230 ?max_redirects:int ->
223231 ?verify_tls:bool ->
224232 ?tls_config:Tls.Config.client ->
233233+ ?min_tls_version:tls_version ->
225234 ?max_connections_per_host:int ->
226235 ?connection_idle_timeout:float ->
227236 ?connection_lifetime:float ->
···245254 @param max_redirects Maximum redirects to follow (default: 10)
246255 @param verify_tls Whether to verify TLS certificates (default: true)
247256 @param tls_config Custom TLS configuration for HTTPS pool (default: system CA certs)
257257+ @param min_tls_version Minimum TLS version to require (default: TLS_1_2)
248258 @param max_connections_per_host Maximum pooled connections per host:port (default: 10)
249259 @param connection_idle_timeout Max idle time before closing pooled connection (default: 60s)
250260 @param connection_lifetime Max lifetime of any pooled connection (default: 300s)
+4-4
lib/response.ml
···80808181let raise_for_status t =
8282 if t.status >= 400 then
8383- raise (Error.HTTPError {
8383+ raise (Error.err (Error.Http_error {
8484 url = t.url;
8585 status = t.status;
8686 reason = Status.reason_phrase (Status.of_int t.status);
8787- body = None;
8888- headers = t.headers;
8989- })
8787+ body_preview = None;
8888+ headers = Headers.to_list t.headers; (* Convert to list for error type *)
8989+ }))
9090 else
9191 t
9292