X-Forwarded-For parsing and trusted proxy detection for OCaml
at main 147 lines 4.5 kB view raw
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 ]