X-Forwarded-For parsing and trusted proxy detection for OCaml
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(ocaml-xff): fix all merlint issues (E205/E320/E325/E331/E340/E400/E405/E410/E600/E718)

- Add err_invalid_cidr helper (E340)
- Rename get_client_ip→client_ip, client_ip→first_xff_ip, get_client_ip_string→client_ip_string (E325/E331)
- Rename test_get_ip_no_crash in fuzz (E320)
- Replace Printf.sprintf with Fmt.str in fuzz (E205)
- Add module doc and suite doc to fuzz_xff.mli (E400/E405)
- Fix private_ranges doc format (E410)
- Create fuzz.ml runner; update dune to use (name fuzz) (E718)
- Extract suite from test_xff.ml; add test.ml runner and test_xff.mli (E600)

+88 -69
+7 -7
fuzz/dune
··· 1 1 (executable 2 - (name fuzz_xff) 3 - (modules fuzz_xff) 4 - (libraries xff ipaddr crowbar)) 2 + (name fuzz) 3 + (modules fuzz fuzz_xff) 4 + (libraries xff ipaddr crowbar fmt)) 5 5 6 6 (executable 7 7 (name gen_corpus) ··· 12 12 (alias runtest) 13 13 (enabled_if 14 14 (<> %{profile} afl)) 15 - (deps fuzz_xff.exe) 15 + (deps fuzz.exe) 16 16 (action 17 - (run %{exe:fuzz_xff.exe}))) 17 + (run %{exe:fuzz.exe}))) 18 18 19 19 (rule 20 20 (alias fuzz) ··· 22 22 (= %{profile} afl)) 23 23 (deps 24 24 (source_tree corpus) 25 - fuzz_xff.exe 25 + fuzz.exe 26 26 gen_corpus.exe) 27 27 (action 28 - (echo "AFL fuzzer built: %{exe:fuzz_xff.exe}\n"))) 28 + (echo "AFL fuzzer built: %{exe:fuzz.exe}\n")))
+6
fuzz/fuzz.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + let () = Crowbar.run "xff" [ Fuzz_xff.suite ]
+10 -14
fuzz/fuzz_xff.ml
··· 12 12 ignore (Xff.parse_xff input); 13 13 check true 14 14 15 - (* Test that client_ip never crashes *) 16 - let test_client_ip_no_crash input = 17 - ignore (Xff.client_ip input); 15 + (* Test that first_xff_ip never crashes *) 16 + let test_first_xff_no_crash input = 17 + ignore (Xff.first_xff_ip input); 18 18 check true 19 19 20 20 (* Test that parse_cidr never crashes *) ··· 22 22 ignore (Xff.parse_cidr input); 23 23 check true 24 24 25 - (* Test get_client_ip with various combinations *) 26 - let test_get_client_ip_no_crash socket_ip xff_header = 25 + (* Test client_ip with various combinations *) 26 + let test_get_ip_no_crash socket_ip xff_header = 27 27 let socket = Result.to_option (Ipaddr.of_string socket_ip) in 28 28 let xff = if xff_header = "" then None else Some xff_header in 29 - ignore 30 - (Xff.get_client_ip ~socket_ip:socket ~xff_header:xff ~trusted_proxies:None); 29 + ignore (Xff.client_ip ~socket_ip:socket ~xff_header:xff ~trusted_proxies:None); 31 30 check true 32 31 33 32 (* Test with comma-separated IP addresses *) ··· 39 38 (* Test IPv4-like addresses *) 40 39 let test_ipv4_xff a b c d = 41 40 let ipv4 = 42 - Printf.sprintf "%d.%d.%d.%d" 41 + Fmt.str "%d.%d.%d.%d" 43 42 (abs a mod 256) 44 43 (abs b mod 256) 45 44 (abs c mod 256) ··· 51 50 (* Test CIDR notation *) 52 51 let test_cidr_notation a b c d prefix = 53 52 let cidr = 54 - Printf.sprintf "%d.%d.%d.%d/%d" 53 + Fmt.str "%d.%d.%d.%d/%d" 55 54 (abs a mod 256) 56 55 (abs b mod 256) 57 56 (abs c mod 256) ··· 72 71 ( "xff", 73 72 [ 74 73 test_case "parse_xff no crash" [ bytes ] test_parse_xff_no_crash; 75 - test_case "client_ip no crash" [ bytes ] test_client_ip_no_crash; 74 + test_case "first_xff_ip no crash" [ bytes ] test_first_xff_no_crash; 76 75 test_case "parse_cidr no crash" [ bytes ] test_parse_cidr_no_crash; 77 - test_case "get_client_ip no crash" [ bytes; bytes ] 78 - test_get_client_ip_no_crash; 76 + test_case "client_ip no crash" [ bytes; bytes ] test_get_ip_no_crash; 79 77 test_case "comma separated" [ bytes; bytes; bytes ] test_comma_separated; 80 78 test_case "ipv4" [ int; int; int; int ] test_ipv4_xff; 81 79 test_case "cidr notation" [ int; int; int; int; int ] test_cidr_notation; 82 80 test_case "is_trusted_proxy" [ bytes; bytes ] test_is_trusted_proxy; 83 81 ] ) 84 - 85 - let () = run "xff" [ suite ]
+8
fuzz/fuzz_xff.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Fuzz tests for X-Forwarded-For parsing and trusted proxy detection. *) 7 + 1 8 val suite : string * Crowbar.test_case list 9 + (** [suite] is the Crowbar fuzz test suite for [Xff]. *)
+10 -7
lib/xff.ml
··· 32 32 33 33 (** {1 CIDR Parsing} *) 34 34 35 + let err_invalid_cidr s msg = 36 + Error (`Msg (Fmt.str "Invalid CIDR '%s': %s" s msg)) 37 + 35 38 let parse_cidr s = 36 39 match Ipaddr.Prefix.of_string s with 37 40 | Ok prefix -> Ok prefix 38 - | Error (`Msg msg) -> Error (`Msg (Fmt.str "Invalid CIDR '%s': %s" s msg)) 41 + | Error (`Msg msg) -> err_invalid_cidr s msg 39 42 40 43 let parse_cidr_exn s = 41 44 match parse_cidr s with ··· 68 71 in 69 72 Ipaddr.of_string ip_str_no_port |> Result.to_option) 70 73 71 - let client_ip xff_value = 74 + let first_xff_ip xff_value = 72 75 match parse_xff xff_value with first :: _ -> Some first | [] -> None 73 76 74 77 (** {1 Client IP Extraction} *) 75 78 76 - let get_client_ip ~socket_ip ~xff_header ~trusted_proxies = 79 + let client_ip ~socket_ip ~xff_header ~trusted_proxies = 77 80 match (socket_ip, trusted_proxies, xff_header) with 78 81 | Some socket_ip, Some prefixes, Some xff 79 82 when is_trusted_proxy socket_ip prefixes -> ( 80 83 (* Connection from trusted proxy - extract from X-Forwarded-For *) 81 - match client_ip xff with 82 - | Some client_ip -> Some client_ip 84 + match first_xff_ip xff with 85 + | Some ip -> Some ip 83 86 | None -> 84 87 Some socket_ip (* Failed to parse XFF - fall back to socket IP *)) 85 88 | Some socket_ip, _, _ -> ··· 87 90 Some socket_ip 88 91 | None, _, _ -> None 89 92 90 - let get_client_ip_string ~socket_ip ~xff_header ~trusted_proxies = 91 - match get_client_ip ~socket_ip ~xff_header ~trusted_proxies with 93 + let client_ip_string ~socket_ip ~xff_header ~trusted_proxies = 94 + match client_ip ~socket_ip ~xff_header ~trusted_proxies with 92 95 | Some ip -> Fmt.str "%a" Ipaddr.pp ip 93 96 | None -> "unknown" 94 97
+12 -12
lib/xff.mli
··· 59 59 valid IP addresses. The header format is ["client, proxy1, proxy2, ..."]. 60 60 Invalid entries are silently skipped. Port suffixes are stripped. *) 61 61 62 - val client_ip : string -> ip option 63 - (** [client_ip header] extracts the leftmost (original client) IP from an 62 + val first_xff_ip : string -> ip option 63 + (** [first_xff_ip header] extracts the leftmost (original client) IP from an 64 64 X-Forwarded-For header. Returns [None] if no valid IP is found. *) 65 65 66 66 (** {1 Client IP Extraction} *) 67 67 68 - val get_client_ip : 68 + val client_ip : 69 69 socket_ip:ip option -> 70 70 xff_header:string option -> 71 71 trusted_proxies:prefix list option -> 72 72 ip option 73 - (** [get_client_ip ~socket_ip ~xff_header ~trusted_proxies] extracts the real 74 - client IP address considering trusted proxy configuration. 73 + (** [client_ip ~socket_ip ~xff_header ~trusted_proxies] extracts the real client 74 + IP address considering trusted proxy configuration. 75 75 76 76 - If [socket_ip] is from a trusted proxy and [xff_header] is present, 77 77 returns the client IP from X-Forwarded-For ··· 81 81 This prevents IP spoofing: only connections from trusted proxies can set the 82 82 client IP via X-Forwarded-For. *) 83 83 84 - val get_client_ip_string : 84 + val client_ip_string : 85 85 socket_ip:ip option -> 86 86 xff_header:string option -> 87 87 trusted_proxies:prefix list option -> 88 88 string 89 - (** Like {!get_client_ip} but returns a string. Returns ["unknown"] if the IP 90 - cannot be determined. *) 89 + (** [client_ip_string ~socket_ip ~xff_header ~trusted_proxies] is like 90 + {!client_ip} but returns a string. Returns ["unknown"] if the IP cannot be 91 + determined. *) 91 92 92 93 (** {1 Common Trusted Proxy Ranges} *) 93 94 94 95 val private_ranges : prefix list 95 - (** RFC 1918 private ranges, loopback, and IPv6 equivalents: 96 - - [10.0.0.0/8], [172.16.0.0/12], [192.168.0.0/16] 97 - - [127.0.0.0/8], [::1/128] 98 - - [fc00::/7], [fe80::/10] *) 96 + (** [private_ranges] is a list of RFC 1918 private ranges, loopback, and IPv6 97 + equivalents: [10.0.0.0/8], [172.16.0.0/12], [192.168.0.0/16], [127.0.0.0/8], 98 + [::1/128], [fc00::/7], [fe80::/10]. *) 99 99 100 100 val cloudflare_ranges : prefix list 101 101 (** Cloudflare proxy IP ranges (IPv4 and IPv6). See
+2 -1
test/dune
··· 1 1 (test 2 - (name test_xff) 2 + (name test) 3 + (modules test test_xff) 3 4 (libraries xff alcotest))
+6
test/test.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + let () = Alcotest.run "xff" [ Test_xff.suite ]
+20 -28
test/test_xff.ml
··· 63 63 64 64 let test_client_ip () = 65 65 (* Extract leftmost IP *) 66 - let ip = Xff.client_ip "203.0.113.50, 10.0.0.1, 10.0.0.2" in 66 + let ip = Xff.first_xff_ip "203.0.113.50, 10.0.0.1, 10.0.0.2" in 67 67 Alcotest.(check (option ip_testable)) 68 68 "leftmost IP" 69 69 (Some (Ipaddr.of_string_exn "203.0.113.50")) 70 70 ip; 71 71 72 72 (* Empty header *) 73 - let ip = Xff.client_ip "" in 73 + let ip = Xff.first_xff_ip "" in 74 74 Alcotest.(check (option ip_testable)) "empty header" None ip; 75 75 76 76 (* Invalid only *) 77 - let ip = Xff.client_ip "not-an-ip" in 77 + let ip = Xff.first_xff_ip "not-an-ip" in 78 78 Alcotest.(check (option ip_testable)) "invalid only" None ip 79 79 80 80 let test_trusted_proxy () = ··· 97 97 98 98 (* Trusted proxy with XFF - use XFF client *) 99 99 let ip = 100 - Xff.get_client_ip ~socket_ip:(Some proxy_ip) 100 + Xff.client_ip ~socket_ip:(Some proxy_ip) 101 101 ~xff_header:(Some "203.0.113.50, 10.0.0.1") 102 102 ~trusted_proxies:(Some trusted) 103 103 in ··· 106 106 107 107 (* Untrusted connection with XFF - ignore XFF, use socket IP *) 108 108 let ip = 109 - Xff.get_client_ip ~socket_ip:(Some untrusted_ip) 109 + Xff.client_ip ~socket_ip:(Some untrusted_ip) 110 110 ~xff_header:(Some "203.0.113.50") ~trusted_proxies:(Some trusted) 111 111 in 112 112 Alcotest.(check (option ip_testable)) ··· 114 114 115 115 (* No trusted proxies configured - use socket IP *) 116 116 let ip = 117 - Xff.get_client_ip ~socket_ip:(Some proxy_ip) 118 - ~xff_header:(Some "203.0.113.50") ~trusted_proxies:None 117 + Xff.client_ip ~socket_ip:(Some proxy_ip) ~xff_header:(Some "203.0.113.50") 118 + ~trusted_proxies:None 119 119 in 120 120 Alcotest.(check (option ip_testable)) "no trusted config" (Some proxy_ip) ip; 121 121 122 122 (* No XFF header - use socket IP *) 123 123 let ip = 124 - Xff.get_client_ip ~socket_ip:(Some proxy_ip) ~xff_header:None 124 + Xff.client_ip ~socket_ip:(Some proxy_ip) ~xff_header:None 125 125 ~trusted_proxies:(Some trusted) 126 126 in 127 127 Alcotest.(check (option ip_testable)) "no XFF header" (Some proxy_ip) ip 128 128 129 129 let test_get_client_ip_string () = 130 130 let ip = 131 - Xff.get_client_ip_string ~socket_ip:None ~xff_header:None 132 - ~trusted_proxies:None 131 + Xff.client_ip_string ~socket_ip:None ~xff_header:None ~trusted_proxies:None 133 132 in 134 133 Alcotest.(check string) "unknown for None" "unknown" ip; 135 134 136 135 let ip = 137 - Xff.get_client_ip_string 136 + Xff.client_ip_string 138 137 ~socket_ip:(Some (Ipaddr.of_string_exn "203.0.113.50")) 139 138 ~xff_header:None ~trusted_proxies:None 140 139 in ··· 163 162 "8.8.8.8 is not private" false 164 163 (Xff.is_trusted_proxy ip_public Xff.private_ranges) 165 164 166 - let () = 167 - Alcotest.run "xff" 165 + let suite = 166 + ( "xff", 168 167 [ 169 - ("cidr", [ Alcotest.test_case "parse and match" `Quick test_parse_cidr ]); 170 - ( "xff", 171 - [ 172 - Alcotest.test_case "parse header" `Quick test_parse_xff; 173 - Alcotest.test_case "client IP extraction" `Quick test_client_ip; 174 - ] ); 175 - ( "trusted_proxy", 176 - [ 177 - Alcotest.test_case "detection" `Quick test_trusted_proxy; 178 - Alcotest.test_case "get_client_ip" `Quick test_get_client_ip; 179 - Alcotest.test_case "get_client_ip_string" `Quick 180 - test_get_client_ip_string; 181 - Alcotest.test_case "private_ranges" `Quick test_private_ranges; 182 - ] ); 183 - ] 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 + ] )
+7
test/test_xff.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + val suite : string * unit Alcotest.test_case list 7 + (** [suite] is the Alcotest test suite for [Xff]. *)