X-Forwarded-For parsing and trusted proxy detection for OCaml
1let ip_testable =
2 Alcotest.testable
3 (fun ppf ip -> Fmt.pf ppf "%a" Ipaddr.pp ip)
4 (fun a b -> Ipaddr.compare a b = 0)
5
6let test_parse_cidr () =
7 (* Valid CIDR *)
8 let prefix = Xff.parse_cidr_exn "192.168.0.0/16" in
9 Alcotest.(check bool)
10 "192.168.1.1 in 192.168.0.0/16" true
11 (Xff.ip_in_prefix (Ipaddr.of_string_exn "192.168.1.1") prefix);
12 Alcotest.(check bool)
13 "10.0.0.1 not in 192.168.0.0/16" false
14 (Xff.ip_in_prefix (Ipaddr.of_string_exn "10.0.0.1") prefix);
15
16 (* IPv6 CIDR *)
17 let prefix6 = Xff.parse_cidr_exn "2001:db8::/32" in
18 Alcotest.(check bool)
19 "2001:db8::1 in 2001:db8::/32" true
20 (Xff.ip_in_prefix (Ipaddr.of_string_exn "2001:db8::1") prefix6);
21
22 (* Invalid CIDR *)
23 match Xff.parse_cidr "not-a-cidr" with
24 | Error _ -> ()
25 | Ok _ -> Alcotest.fail "Expected error for invalid CIDR"
26
27let test_parse_xff () =
28 (* Single IP *)
29 let ips = Xff.parse_xff "203.0.113.50" in
30 Alcotest.(check int) "single IP count" 1 (List.length ips);
31 Alcotest.(check ip_testable)
32 "single IP value"
33 (Ipaddr.of_string_exn "203.0.113.50")
34 (List.hd ips);
35
36 (* Multiple IPs *)
37 let ips = Xff.parse_xff "203.0.113.50, 70.41.3.18, 150.172.238.178" in
38 Alcotest.(check int) "multiple IPs count" 3 (List.length ips);
39 Alcotest.(check ip_testable)
40 "first IP"
41 (Ipaddr.of_string_exn "203.0.113.50")
42 (List.hd ips);
43
44 (* With port suffix *)
45 let ips = Xff.parse_xff "203.0.113.50:8080" in
46 Alcotest.(check int) "IP with port count" 1 (List.length ips);
47 Alcotest.(check ip_testable)
48 "IP without port"
49 (Ipaddr.of_string_exn "203.0.113.50")
50 (List.hd ips);
51
52 (* IPv6 *)
53 let ips = Xff.parse_xff "2001:db8::1, 203.0.113.50" in
54 Alcotest.(check int) "mixed IP count" 2 (List.length ips);
55 Alcotest.(check ip_testable)
56 "IPv6 first"
57 (Ipaddr.of_string_exn "2001:db8::1")
58 (List.hd ips);
59
60 (* Empty and invalid entries *)
61 let ips = Xff.parse_xff "203.0.113.50, , invalid, 10.0.0.1" in
62 Alcotest.(check int) "skip invalid entries" 2 (List.length ips)
63
64let test_client_ip () =
65 (* Extract leftmost IP *)
66 let ip = Xff.first_xff_ip "203.0.113.50, 10.0.0.1, 10.0.0.2" in
67 Alcotest.(check (option ip_testable))
68 "leftmost IP"
69 (Some (Ipaddr.of_string_exn "203.0.113.50"))
70 ip;
71
72 (* Empty header *)
73 let ip = Xff.first_xff_ip "" in
74 Alcotest.(check (option ip_testable)) "empty header" None ip;
75
76 (* Invalid only *)
77 let ip = Xff.first_xff_ip "not-an-ip" in
78 Alcotest.(check (option ip_testable)) "invalid only" None ip
79
80let test_trusted_proxy () =
81 let trusted = [ Xff.parse_cidr_exn "10.0.0.0/8" ] in
82 let proxy_ip = Ipaddr.of_string_exn "10.0.0.1" in
83 let untrusted_ip = Ipaddr.of_string_exn "203.0.113.1" in
84
85 Alcotest.(check bool)
86 "proxy is trusted" true
87 (Xff.is_trusted_proxy proxy_ip trusted);
88 Alcotest.(check bool)
89 "external is not trusted" false
90 (Xff.is_trusted_proxy untrusted_ip trusted)
91
92let test_get_client_ip () =
93 let trusted = [ Xff.parse_cidr_exn "10.0.0.0/8" ] in
94 let proxy_ip = Ipaddr.of_string_exn "10.0.0.1" in
95 let client_ip = Ipaddr.of_string_exn "203.0.113.50" in
96 let untrusted_ip = Ipaddr.of_string_exn "198.51.100.1" in
97
98 (* Trusted proxy with XFF - use XFF client *)
99 let ip =
100 Xff.client_ip ~socket_ip:(Some proxy_ip)
101 ~xff_header:(Some "203.0.113.50, 10.0.0.1")
102 ~trusted_proxies:(Some trusted)
103 in
104 Alcotest.(check (option ip_testable))
105 "trusted proxy uses XFF" (Some client_ip) ip;
106
107 (* Untrusted connection with XFF - ignore XFF, use socket IP *)
108 let ip =
109 Xff.client_ip ~socket_ip:(Some untrusted_ip)
110 ~xff_header:(Some "203.0.113.50") ~trusted_proxies:(Some trusted)
111 in
112 Alcotest.(check (option ip_testable))
113 "untrusted ignores XFF" (Some untrusted_ip) ip;
114
115 (* No trusted proxies configured - use socket IP *)
116 let ip =
117 Xff.client_ip ~socket_ip:(Some proxy_ip) ~xff_header:(Some "203.0.113.50")
118 ~trusted_proxies:None
119 in
120 Alcotest.(check (option ip_testable)) "no trusted config" (Some proxy_ip) ip;
121
122 (* No XFF header - use socket IP *)
123 let ip =
124 Xff.client_ip ~socket_ip:(Some proxy_ip) ~xff_header:None
125 ~trusted_proxies:(Some trusted)
126 in
127 Alcotest.(check (option ip_testable)) "no XFF header" (Some proxy_ip) ip
128
129let test_get_client_ip_string () =
130 let ip =
131 Xff.client_ip_string ~socket_ip:None ~xff_header:None ~trusted_proxies:None
132 in
133 Alcotest.(check string) "unknown for None" "unknown" ip;
134
135 let ip =
136 Xff.client_ip_string
137 ~socket_ip:(Some (Ipaddr.of_string_exn "203.0.113.50"))
138 ~xff_header:None ~trusted_proxies:None
139 in
140 Alcotest.(check string) "formats IP" "203.0.113.50" ip
141
142let test_private_ranges () =
143 let ip_10 = Ipaddr.of_string_exn "10.0.0.1" in
144 let ip_172 = Ipaddr.of_string_exn "172.16.0.1" in
145 let ip_192 = Ipaddr.of_string_exn "192.168.1.1" in
146 let ip_127 = Ipaddr.of_string_exn "127.0.0.1" in
147 let ip_public = Ipaddr.of_string_exn "8.8.8.8" in
148
149 Alcotest.(check bool)
150 "10.x is private" true
151 (Xff.is_trusted_proxy ip_10 Xff.private_ranges);
152 Alcotest.(check bool)
153 "172.16.x is private" true
154 (Xff.is_trusted_proxy ip_172 Xff.private_ranges);
155 Alcotest.(check bool)
156 "192.168.x is private" true
157 (Xff.is_trusted_proxy ip_192 Xff.private_ranges);
158 Alcotest.(check bool)
159 "127.x is private" true
160 (Xff.is_trusted_proxy ip_127 Xff.private_ranges);
161 Alcotest.(check bool)
162 "8.8.8.8 is not private" false
163 (Xff.is_trusted_proxy ip_public Xff.private_ranges)
164
165let suite =
166 ( "xff",
167 [
168 Alcotest.test_case "cidr parse and match" `Quick test_parse_cidr;
169 Alcotest.test_case "xff parse header" `Quick test_parse_xff;
170 Alcotest.test_case "xff client ip extraction" `Quick test_client_ip;
171 Alcotest.test_case "trusted_proxy detection" `Quick test_trusted_proxy;
172 Alcotest.test_case "client_ip" `Quick test_get_client_ip;
173 Alcotest.test_case "client_ip_string" `Quick test_get_client_ip_string;
174 Alcotest.test_case "private_ranges" `Quick test_private_ranges;
175 ] )