X-Forwarded-For parsing and trusted proxy detection for OCaml
1(** X-Forwarded-For parsing and trusted proxy detection.
2
3 This module provides utilities for extracting client IP addresses from
4 X-Forwarded-For headers when running behind reverse proxies, with support
5 for trusted proxy validation to prevent IP spoofing.
6
7 {2 Background}
8
9 When a web server runs behind a reverse proxy (nginx, HAProxy, cloud load
10 balancers), the direct TCP connection comes from the proxy, not the client.
11 Proxies add the original client IP to the X-Forwarded-For header.
12
13 {2 Security}
14
15 Without trusted proxy validation, malicious clients can spoof their IP by
16 sending a fake X-Forwarded-For header. This module only trusts XFF headers
17 from connections originating from configured trusted proxy CIDR ranges.
18
19 {2 References}
20
21 - {{:https://datatracker.ietf.org/doc/html/rfc7239} RFC 7239} - Forwarded
22 HTTP Extension (standardized header)
23 - X-Forwarded-For is a de-facto standard documented by MDN *)
24
25(** {1 Types} *)
26
27type ip = Ipaddr.t
28(** An IP address (IPv4 or IPv6). *)
29
30type prefix = Ipaddr.Prefix.t
31(** A CIDR prefix for matching IP ranges. *)
32
33(** {1 CIDR Parsing} *)
34
35let err_invalid_cidr s msg =
36 Error (`Msg (Fmt.str "Invalid CIDR '%s': %s" s msg))
37
38let parse_cidr s =
39 match Ipaddr.Prefix.of_string s with
40 | Ok prefix -> Ok prefix
41 | Error (`Msg msg) -> err_invalid_cidr s msg
42
43let parse_cidr_exn s =
44 match parse_cidr s with
45 | Ok prefix -> prefix
46 | Error (`Msg msg) -> invalid_arg msg
47
48(** {1 Trusted Proxy Detection} *)
49
50let ip_in_prefix ip prefix = Ipaddr.Prefix.mem ip prefix
51
52let is_trusted_proxy ip trusted_prefixes =
53 List.exists (fun prefix -> ip_in_prefix ip prefix) trusted_prefixes
54
55(** {1 X-Forwarded-For Parsing} *)
56
57let parse_xff xff_value =
58 String.split_on_char ',' xff_value
59 |> List.filter_map (fun s ->
60 let ip_str = String.trim s in
61 if ip_str = "" then None
62 else
63 (* Remove port if present (e.g., "1.2.3.4:5678") *)
64 let ip_str_no_port =
65 match String.rindex_opt ip_str ':' with
66 | Some idx ->
67 let before_colon = String.sub ip_str 0 idx in
68 (* Check if this looks like IPv6 (has multiple colons) *)
69 if String.contains before_colon ':' then ip_str else before_colon
70 | None -> ip_str
71 in
72 Ipaddr.of_string ip_str_no_port |> Result.to_option)
73
74let first_xff_ip xff_value =
75 match parse_xff xff_value with first :: _ -> Some first | [] -> None
76
77(** {1 Client IP Extraction} *)
78
79let client_ip ~socket_ip ~xff_header ~trusted_proxies =
80 match (socket_ip, trusted_proxies, xff_header) with
81 | Some socket_ip, Some prefixes, Some xff
82 when is_trusted_proxy socket_ip prefixes -> (
83 (* Connection from trusted proxy - extract from X-Forwarded-For *)
84 match first_xff_ip xff with
85 | Some ip -> Some ip
86 | None ->
87 Some socket_ip (* Failed to parse XFF - fall back to socket IP *))
88 | Some socket_ip, _, _ ->
89 (* No trusted proxies, no XFF header, or not from trusted proxy *)
90 Some socket_ip
91 | None, _, _ -> None
92
93let client_ip_string ~socket_ip ~xff_header ~trusted_proxies =
94 match client_ip ~socket_ip ~xff_header ~trusted_proxies with
95 | Some ip -> Fmt.str "%a" Ipaddr.pp ip
96 | None -> "unknown"
97
98(** {1 Common Trusted Proxy Ranges} *)
99
100let private_ranges =
101 List.filter_map
102 (fun s -> Result.to_option (parse_cidr s))
103 [
104 "10.0.0.0/8";
105 (* RFC 1918 private *)
106 "172.16.0.0/12";
107 (* RFC 1918 private *)
108 "192.168.0.0/16";
109 (* RFC 1918 private *)
110 "127.0.0.0/8";
111 (* Loopback *)
112 "::1/128";
113 (* IPv6 loopback *)
114 "fc00::/7";
115 (* IPv6 unique local *)
116 "fe80::/10" (* IPv6 link-local *);
117 ]
118
119let cloudflare_ranges =
120 List.filter_map
121 (fun s -> Result.to_option (parse_cidr s))
122 [
123 (* Cloudflare IPv4 ranges - see https://www.cloudflare.com/ips-v4 *)
124 "173.245.48.0/20";
125 "103.21.244.0/22";
126 "103.22.200.0/22";
127 "103.31.4.0/22";
128 "141.101.64.0/18";
129 "108.162.192.0/18";
130 "190.93.240.0/20";
131 "188.114.96.0/20";
132 "197.234.240.0/22";
133 "198.41.128.0/17";
134 "162.158.0.0/15";
135 "104.16.0.0/13";
136 "104.24.0.0/14";
137 "172.64.0.0/13";
138 "131.0.72.0/22";
139 (* Cloudflare IPv6 ranges - see https://www.cloudflare.com/ips-v6 *)
140 "2400:cb00::/32";
141 "2606:4700::/32";
142 "2803:f800::/32";
143 "2405:b500::/32";
144 "2405:8100::/32";
145 "2a06:98c0::/29";
146 "2c0f:f248::/32";
147 ]