···11+ISC License
22+33+Copyright (c) 2025 Thomas Gazagnaire
44+55+Permission to use, copy, modify, and/or distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
1010+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
1111+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
1212+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
1313+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1414+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1515+PERFORMANCE OF THIS SOFTWARE.
+28
dune-project
···11+(lang dune 3.0)
22+33+(name xff)
44+55+(generate_opam_files true)
66+77+(source
88+ (github gazagnaire/ocaml-xff))
99+1010+(authors "Thomas Gazagnaire")
1111+1212+(maintainers "thomas@gazagnaire.org")
1313+1414+(license ISC)
1515+1616+(package
1717+ (name xff)
1818+ (synopsis "X-Forwarded-For parsing and trusted proxy detection")
1919+ (description
2020+ "Parse X-Forwarded-For headers to extract client IP addresses behind proxies.
2121+Supports trusted proxy validation using CIDR ranges to prevent IP spoofing.
2222+Implements de-facto X-Forwarded-For standard and aligns with RFC 7239.")
2323+ (depends
2424+ (ocaml (>= 5.0.0))
2525+ dune
2626+ ipaddr
2727+ fmt
2828+ (alcotest :with-test)))
···11+(** X-Forwarded-For parsing and trusted proxy detection.
22+33+ This module provides utilities for extracting client IP addresses from
44+ X-Forwarded-For headers when running behind reverse proxies, with support
55+ for trusted proxy validation to prevent IP spoofing.
66+77+ {2 Background}
88+99+ When a web server runs behind a reverse proxy (nginx, HAProxy, cloud load
1010+ balancers), the direct TCP connection comes from the proxy, not the client.
1111+ Proxies add the original client IP to the X-Forwarded-For header.
1212+1313+ {2 Security}
1414+1515+ Without trusted proxy validation, malicious clients can spoof their IP by
1616+ sending a fake X-Forwarded-For header. This module only trusts XFF headers
1717+ from connections originating from configured trusted proxy CIDR ranges.
1818+1919+ {2 References}
2020+2121+ - {{:https://datatracker.ietf.org/doc/html/rfc7239} RFC 7239} - Forwarded
2222+ HTTP Extension (standardized header)
2323+ - X-Forwarded-For is a de-facto standard documented by MDN *)
2424+2525+(** {1 Types} *)
2626+2727+type ip = Ipaddr.t
2828+(** An IP address (IPv4 or IPv6). *)
2929+3030+type prefix = Ipaddr.Prefix.t
3131+(** A CIDR prefix for matching IP ranges. *)
3232+3333+(** {1 CIDR Parsing} *)
3434+3535+let parse_cidr s =
3636+ match Ipaddr.Prefix.of_string s with
3737+ | Ok prefix -> Ok prefix
3838+ | Error (`Msg msg) -> Error (`Msg (Fmt.str "Invalid CIDR '%s': %s" s msg))
3939+4040+let parse_cidr_exn s =
4141+ match parse_cidr s with
4242+ | Ok prefix -> prefix
4343+ | Error (`Msg msg) -> invalid_arg msg
4444+4545+(** {1 Trusted Proxy Detection} *)
4646+4747+let ip_in_prefix ip prefix = Ipaddr.Prefix.mem ip prefix
4848+4949+let is_trusted_proxy ip trusted_prefixes =
5050+ List.exists (fun prefix -> ip_in_prefix ip prefix) trusted_prefixes
5151+5252+(** {1 X-Forwarded-For Parsing} *)
5353+5454+let parse_xff xff_value =
5555+ String.split_on_char ',' xff_value
5656+ |> List.filter_map (fun s ->
5757+ let ip_str = String.trim s in
5858+ if ip_str = "" then None
5959+ else
6060+ (* Remove port if present (e.g., "1.2.3.4:5678") *)
6161+ let ip_str_no_port =
6262+ match String.rindex_opt ip_str ':' with
6363+ | Some idx ->
6464+ let before_colon = String.sub ip_str 0 idx in
6565+ (* Check if this looks like IPv6 (has multiple colons) *)
6666+ if String.contains before_colon ':' then ip_str else before_colon
6767+ | None -> ip_str
6868+ in
6969+ Ipaddr.of_string ip_str_no_port |> Result.to_option)
7070+7171+let client_ip xff_value =
7272+ match parse_xff xff_value with first :: _ -> Some first | [] -> None
7373+7474+(** {1 Client IP Extraction} *)
7575+7676+let get_client_ip ~socket_ip ~xff_header ~trusted_proxies =
7777+ match (socket_ip, trusted_proxies, xff_header) with
7878+ | Some socket_ip, Some prefixes, Some xff
7979+ when is_trusted_proxy socket_ip prefixes -> (
8080+ (* Connection from trusted proxy - extract from X-Forwarded-For *)
8181+ match client_ip xff with
8282+ | Some client_ip -> Some client_ip
8383+ | None ->
8484+ Some socket_ip (* Failed to parse XFF - fall back to socket IP *))
8585+ | Some socket_ip, _, _ ->
8686+ (* No trusted proxies, no XFF header, or not from trusted proxy *)
8787+ Some socket_ip
8888+ | None, _, _ -> None
8989+9090+let get_client_ip_string ~socket_ip ~xff_header ~trusted_proxies =
9191+ match get_client_ip ~socket_ip ~xff_header ~trusted_proxies with
9292+ | Some ip -> Fmt.str "%a" Ipaddr.pp ip
9393+ | None -> "unknown"
9494+9595+(** {1 Common Trusted Proxy Ranges} *)
9696+9797+let private_ranges =
9898+ List.filter_map
9999+ (fun s -> Result.to_option (parse_cidr s))
100100+ [
101101+ "10.0.0.0/8";
102102+ (* RFC 1918 private *)
103103+ "172.16.0.0/12";
104104+ (* RFC 1918 private *)
105105+ "192.168.0.0/16";
106106+ (* RFC 1918 private *)
107107+ "127.0.0.0/8";
108108+ (* Loopback *)
109109+ "::1/128";
110110+ (* IPv6 loopback *)
111111+ "fc00::/7";
112112+ (* IPv6 unique local *)
113113+ "fe80::/10" (* IPv6 link-local *);
114114+ ]
115115+116116+let cloudflare_ranges =
117117+ List.filter_map
118118+ (fun s -> Result.to_option (parse_cidr s))
119119+ [
120120+ (* Cloudflare IPv4 ranges - see https://www.cloudflare.com/ips-v4 *)
121121+ "173.245.48.0/20";
122122+ "103.21.244.0/22";
123123+ "103.22.200.0/22";
124124+ "103.31.4.0/22";
125125+ "141.101.64.0/18";
126126+ "108.162.192.0/18";
127127+ "190.93.240.0/20";
128128+ "188.114.96.0/20";
129129+ "197.234.240.0/22";
130130+ "198.41.128.0/17";
131131+ "162.158.0.0/15";
132132+ "104.16.0.0/13";
133133+ "104.24.0.0/14";
134134+ "172.64.0.0/13";
135135+ "131.0.72.0/22";
136136+ (* Cloudflare IPv6 ranges - see https://www.cloudflare.com/ips-v6 *)
137137+ "2400:cb00::/32";
138138+ "2606:4700::/32";
139139+ "2803:f800::/32";
140140+ "2405:b500::/32";
141141+ "2405:8100::/32";
142142+ "2a06:98c0::/29";
143143+ "2c0f:f248::/32";
144144+ ]
+102
lib/xff.mli
···11+(** X-Forwarded-For parsing and trusted proxy detection.
22+33+ This module provides utilities for extracting client IP addresses from
44+ X-Forwarded-For headers when running behind reverse proxies, with support
55+ for trusted proxy validation to prevent IP spoofing.
66+77+ {2 Example}
88+99+ {[
1010+ (* Configure trusted proxies *)
1111+ let trusted = Xff.private_ranges
1212+1313+ (* Extract client IP from request *)
1414+ let client_ip =
1515+ Xff.get_client_ip_string ~socket_ip:(Some socket_addr)
1616+ ~xff_header:(Some "203.0.113.50, 10.0.0.1")
1717+ ~trusted_proxies:(Some trusted)
1818+ (* Returns "203.0.113.50" if socket_ip is in trusted range *)
1919+ ]}
2020+2121+ {2 References}
2222+2323+ - {{:https://datatracker.ietf.org/doc/html/rfc7239} RFC 7239} - Forwarded
2424+ HTTP Extension
2525+ - X-Forwarded-For is a de-facto standard *)
2626+2727+(** {1 Types} *)
2828+2929+type ip = Ipaddr.t
3030+(** An IP address (IPv4 or IPv6). *)
3131+3232+type prefix = Ipaddr.Prefix.t
3333+(** A CIDR prefix for matching IP ranges. *)
3434+3535+(** {1 CIDR Parsing} *)
3636+3737+val parse_cidr : string -> (prefix, [ `Msg of string ]) result
3838+(** [parse_cidr s] parses a CIDR string like ["192.168.0.0/16"] or
3939+ ["2001:db8::/32"]. *)
4040+4141+val parse_cidr_exn : string -> prefix
4242+(** [parse_cidr_exn s] is like {!parse_cidr} but raises [Invalid_argument] on
4343+ error. *)
4444+4545+(** {1 Trusted Proxy Detection} *)
4646+4747+val ip_in_prefix : ip -> prefix -> bool
4848+(** [ip_in_prefix ip prefix] returns [true] if [ip] is within the CIDR [prefix].
4949+*)
5050+5151+val is_trusted_proxy : ip -> prefix list -> bool
5252+(** [is_trusted_proxy ip prefixes] returns [true] if [ip] matches any prefix in
5353+ the list. *)
5454+5555+(** {1 X-Forwarded-For Parsing} *)
5656+5757+val parse_xff : string -> ip list
5858+(** [parse_xff header] parses an X-Forwarded-For header value and returns all
5959+ valid IP addresses. The header format is ["client, proxy1, proxy2, ..."].
6060+ Invalid entries are silently skipped. Port suffixes are stripped. *)
6161+6262+val client_ip : string -> ip option
6363+(** [client_ip header] extracts the leftmost (original client) IP from an
6464+ X-Forwarded-For header. Returns [None] if no valid IP is found. *)
6565+6666+(** {1 Client IP Extraction} *)
6767+6868+val get_client_ip :
6969+ socket_ip:ip option ->
7070+ xff_header:string option ->
7171+ trusted_proxies:prefix list option ->
7272+ ip option
7373+(** [get_client_ip ~socket_ip ~xff_header ~trusted_proxies] extracts the real
7474+ client IP address considering trusted proxy configuration.
7575+7676+ - If [socket_ip] is from a trusted proxy and [xff_header] is present,
7777+ returns the client IP from X-Forwarded-For
7878+ - Otherwise returns [socket_ip]
7979+ - Returns [None] if [socket_ip] is [None]
8080+8181+ This prevents IP spoofing: only connections from trusted proxies can set the
8282+ client IP via X-Forwarded-For. *)
8383+8484+val get_client_ip_string :
8585+ socket_ip:ip option ->
8686+ xff_header:string option ->
8787+ trusted_proxies:prefix list option ->
8888+ string
8989+(** Like {!get_client_ip} but returns a string. Returns ["unknown"] if the IP
9090+ cannot be determined. *)
9191+9292+(** {1 Common Trusted Proxy Ranges} *)
9393+9494+val private_ranges : prefix list
9595+(** RFC 1918 private ranges, loopback, and IPv6 equivalents:
9696+ - [10.0.0.0/8], [172.16.0.0/12], [192.168.0.0/16]
9797+ - [127.0.0.0/8], [::1/128]
9898+ - [fc00::/7], [fe80::/10] *)
9999+100100+val cloudflare_ranges : prefix list
101101+(** Cloudflare proxy IP ranges (IPv4 and IPv6). See
102102+ {{:https://www.cloudflare.com/ips/} Cloudflare IPs}. *)