systemprompt_api/services/middleware/client_addr.rs
1//! Client-address resolution that does not blindly trust hop headers.
2//!
3//! [`resolve_client_ip`] is the single helper every middleware that
4//! cares about the originating client (rate-limiter, IP banlist,
5//! bot-scoring, abuse heuristics) must use. The contract:
6//!
7//! 1. If the immediate socket peer (`ConnectInfo<SocketAddr>`) is not contained
8//! in `trusted_proxies`, return the peer address. Hop headers are ignored
9//! entirely — they are untrusted in this case.
10//! 2. If the peer is trusted, walk `X-Forwarded-For` right-to-left and take the
11//! first hop that is itself outside `trusted_proxies`. That hop is the
12//! closest entity our proxy chain still sees, and the earliest one a client
13//! could have spoofed.
14//! 3. If the chain is empty or every hop is trusted, fall back to the peer
15//! address.
16//!
17//! `X-Real-IP`, `Fly-Client-IP`, and `CF-Connecting-IP` are honoured only
18//! under rule 2's trust gate; otherwise they are ignored.
19//!
20//! The trusted-proxy CIDR set is parsed and validated when the profile
21//! loads (`ServerConfig::trusted_proxies` deserialises to `Vec<IpNet>`);
22//! an invalid entry fails boot rather than being silently dropped, so this
23//! resolver only ever sees a validated set.
24//!
25//! A request arriving through a proxy the server does not trust has its
26//! forwarded client-IP headers discarded and the peer's own address used
27//! instead. That is almost always a misconfiguration: something is adding
28//! `X-Forwarded-For`, so a proxy is in the path, but its address is absent
29//! from `server.trusted_proxies`, and every client behind it then resolves to
30//! one address and shares one rate-limit bucket, one ban entry and one set of
31//! abuse heuristics. Until 2026-09-22 this was additionally gated on the peer
32//! being in a private range, on the assumption that a misconfigured proxy is
33//! always a local one. A reverse proxy on a host network has a public address,
34//! so the one deployment that most needed the warning never got it: a
35//! production instance refused every bridge sign-in for weeks with a saturated
36//! shared bucket and logged nothing. Whether the peer is public or private has
37//! no bearing on whether the operator wants to know.
38//!
39//! Copyright (c) systemprompt.io — Business Source License 1.1.
40//! See <https://systemprompt.io> for licensing details.
41
42use std::convert::Infallible;
43use std::future::{Future, ready};
44use std::net::{IpAddr, SocketAddr};
45
46use axum::extract::{ConnectInfo, FromRequestParts};
47use axum::http::HeaderMap;
48use axum::http::request::Parts;
49use ipnet::IpNet;
50
51fn is_trusted(addr: IpAddr, trusted: &[IpNet]) -> bool {
52 trusted.iter().any(|net| net.contains(&addr))
53}
54
55#[must_use]
56pub fn resolve_client_ip(
57 headers: &HeaderMap,
58 connect_info: Option<&ConnectInfo<SocketAddr>>,
59 trusted: &[IpNet],
60) -> Option<IpAddr> {
61 let peer_ip = connect_info.map(|c| c.0.ip())?;
62
63 if !is_trusted(peer_ip, trusted) {
64 return Some(peer_ip);
65 }
66
67 if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
68 let hops: Vec<&str> = xff
69 .split(',')
70 .map(str::trim)
71 .filter(|s| !s.is_empty())
72 .collect();
73 for hop in hops.iter().rev() {
74 if let Ok(addr) = hop.parse::<IpAddr>()
75 && !is_trusted(addr, trusted)
76 {
77 return Some(addr);
78 }
79 }
80 }
81
82 for header in ["x-real-ip", "fly-client-ip", "cf-connecting-ip"] {
83 if let Some(raw) = headers.get(header).and_then(|v| v.to_str().ok())
84 && let Ok(addr) = raw.trim().parse::<IpAddr>()
85 && !is_trusted(addr, trusted)
86 {
87 return Some(addr);
88 }
89 }
90
91 Some(peer_ip)
92}
93
94#[must_use]
95pub fn forwarded_headers_ignored(headers: &HeaderMap, peer_ip: IpAddr, trusted: &[IpNet]) -> bool {
96 !is_trusted(peer_ip, trusted) && headers.contains_key("x-forwarded-for")
97}
98
99#[must_use]
100pub fn resolve_client_ip_from_config(
101 headers: &HeaderMap,
102 connect_info: Option<&ConnectInfo<SocketAddr>>,
103) -> Option<IpAddr> {
104 let trusted = systemprompt_models::Config::get()
105 .map(|c| c.trusted_proxies.clone())
106 .unwrap_or_default();
107 resolve_client_ip(headers, connect_info, &trusted)
108}
109
110#[must_use]
111pub fn client_ip_from_request(request: &axum::extract::Request) -> Option<IpAddr> {
112 resolve_client_ip_from_config(
113 request.headers(),
114 request.extensions().get::<ConnectInfo<SocketAddr>>(),
115 )
116}
117
118#[derive(Debug, Clone, Copy)]
119pub struct ClientIp(pub Option<IpAddr>);
120
121impl<S: Sync> FromRequestParts<S> for ClientIp {
122 type Rejection = Infallible;
123
124 fn from_request_parts(
125 parts: &mut Parts,
126 _state: &S,
127 ) -> impl Future<Output = Result<Self, Infallible>> + Send {
128 let resolved = resolve_client_ip_from_config(
129 &parts.headers,
130 parts.extensions.get::<ConnectInfo<SocketAddr>>(),
131 );
132 ready(Ok(Self(resolved)))
133 }
134}