A batteries included HTTP/1.1 client in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** Redirect handling and cross-origin security utilities
7
8 This module provides shared functions for handling HTTP redirects safely,
9 including cross-origin detection and sensitive header stripping. *)
10
11let src = Logs.Src.create "requests.redirect" ~doc:"HTTP Redirect Handling"
12module Log = (val Logs.src_log src : Logs.LOG)
13
14(** {1 Cross-Origin Detection} *)
15
16(** Get the effective port for a URI, using default ports for http/https.
17 Per RFC 6454, the port is part of the origin tuple. *)
18let effective_port uri =
19 match Uri.port uri with
20 | Some p -> p
21 | None ->
22 match Uri.scheme uri with
23 | Some "https" -> 443
24 | Some "http" | None -> 80
25 | Some _ -> 80 (* Default for unknown schemes *)
26
27(** Check if two URIs have the same origin for security purposes.
28 Per RFC 6454 (Web Origin), origins are tuples of (scheme, host, port).
29 Used to determine if sensitive headers (Authorization, Cookie) should be
30 stripped during redirects. Following Python requests behavior:
31 - Same host, same scheme, same port = same origin
32 - http -> https upgrade on same host with default ports = allowed (more secure)
33 TODO: Support .netrc for re-acquiring auth credentials on new hosts *)
34let same_origin uri1 uri2 =
35 let host1 = Uri.host uri1 |> Option.map String.lowercase_ascii in
36 let host2 = Uri.host uri2 |> Option.map String.lowercase_ascii in
37 let scheme1 = Uri.scheme uri1 |> Option.value ~default:"http" in
38 let scheme2 = Uri.scheme uri2 |> Option.value ~default:"http" in
39 let port1 = effective_port uri1 in
40 let port2 = effective_port uri2 in
41 match host1, host2 with
42 | Some h1, Some h2 when String.equal h1 h2 ->
43 if String.equal scheme1 scheme2 && port1 = port2 then
44 (* Same scheme, host, and port = same origin *)
45 true
46 else if scheme1 = "http" && scheme2 = "https" && port1 = 80 && port2 = 443 then
47 (* http->https upgrade on default ports is allowed (more secure) *)
48 true
49 else
50 false
51 | _ -> false
52
53(** {1 Sensitive Header Protection} *)
54
55(** Strip sensitive headers for cross-origin redirects to prevent credential leakage.
56 Per Recommendation #1: Also strip Cookie, Proxy-Authorization, WWW-Authenticate *)
57let strip_sensitive_headers headers =
58 headers
59 |> Headers.remove `Authorization
60 |> Headers.remove `Cookie
61 |> Headers.remove `Proxy_authorization
62 |> Headers.remove `Www_authenticate
63
64(** {1 Redirect URL Validation} *)
65
66(** Allowed redirect URL schemes to prevent SSRF attacks.
67 Per Recommendation #5: Only allow http:// and https:// schemes *)
68let allowed_schemes = ["http"; "https"]
69
70(** Validate redirect URL scheme to prevent SSRF attacks.
71 Per Recommendation #5: Only allow http:// and https:// schemes.
72 @raise Error.Invalid_redirect if scheme is not allowed *)
73let validate_url location =
74 let uri = Uri.of_string location in
75 match Uri.scheme uri with
76 | Some scheme when List.mem (String.lowercase_ascii scheme) allowed_schemes ->
77 uri
78 | Some scheme ->
79 raise (Error.invalid_redirectf ~url:location "Disallowed redirect scheme: %s" scheme)
80 | None ->
81 uri (* Relative URLs are OK - they will be resolved against current URL *)