Skip to main content

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//! Copyright (c) systemprompt.io — Business Source License 1.1.
26//! See <https://systemprompt.io> for licensing details.
27
28use std::convert::Infallible;
29use std::future::{Future, ready};
30use std::net::{IpAddr, SocketAddr};
31
32use axum::extract::{ConnectInfo, FromRequestParts};
33use axum::http::HeaderMap;
34use axum::http::request::Parts;
35use ipnet::IpNet;
36
37fn is_trusted(addr: IpAddr, trusted: &[IpNet]) -> bool {
38    trusted.iter().any(|net| net.contains(&addr))
39}
40
41#[must_use]
42pub fn resolve_client_ip(
43    headers: &HeaderMap,
44    connect_info: Option<&ConnectInfo<SocketAddr>>,
45    trusted: &[IpNet],
46) -> Option<IpAddr> {
47    let peer_ip = connect_info.map(|c| c.0.ip())?;
48
49    if !is_trusted(peer_ip, trusted) {
50        return Some(peer_ip);
51    }
52
53    if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
54        let hops: Vec<&str> = xff
55            .split(',')
56            .map(str::trim)
57            .filter(|s| !s.is_empty())
58            .collect();
59        for hop in hops.iter().rev() {
60            if let Ok(addr) = hop.parse::<IpAddr>()
61                && !is_trusted(addr, trusted)
62            {
63                return Some(addr);
64            }
65        }
66    }
67
68    for header in ["x-real-ip", "fly-client-ip", "cf-connecting-ip"] {
69        if let Some(raw) = headers.get(header).and_then(|v| v.to_str().ok())
70            && let Ok(addr) = raw.trim().parse::<IpAddr>()
71            && !is_trusted(addr, trusted)
72        {
73            return Some(addr);
74        }
75    }
76
77    Some(peer_ip)
78}
79
80/// Detects a proxy missing from `trusted_proxies`.
81///
82/// An untrusted private-range peer presenting `X-Forwarded-For` near-certainly
83/// means the server is deployed behind an unlisted proxy — real clients never
84/// connect from those ranges in a cloud deployment.
85#[must_use]
86pub fn forwarded_headers_ignored(headers: &HeaderMap, peer_ip: IpAddr, trusted: &[IpNet]) -> bool {
87    !is_trusted(peer_ip, trusted)
88        && is_private_range(peer_ip)
89        && headers.contains_key("x-forwarded-for")
90}
91
92const fn is_private_range(ip: IpAddr) -> bool {
93    match ip {
94        IpAddr::V4(v4) => {
95            let is_cgnat = v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64;
96            v4.is_loopback() || v4.is_private() || v4.is_link_local() || is_cgnat
97        },
98        IpAddr::V6(v6) => {
99            let seg0 = v6.segments()[0];
100            let is_unique_local = (seg0 & 0xfe00) == 0xfc00;
101            let is_link_local = (seg0 & 0xffc0) == 0xfe80;
102            v6.is_loopback() || is_unique_local || is_link_local
103        },
104    }
105}
106
107#[must_use]
108pub fn resolve_client_ip_from_config(
109    headers: &HeaderMap,
110    connect_info: Option<&ConnectInfo<SocketAddr>>,
111) -> Option<IpAddr> {
112    let trusted = systemprompt_models::Config::get()
113        .map(|c| c.trusted_proxies.clone())
114        .unwrap_or_default();
115    resolve_client_ip(headers, connect_info, &trusted)
116}
117
118#[must_use]
119pub fn client_ip_from_request(request: &axum::extract::Request) -> Option<IpAddr> {
120    resolve_client_ip_from_config(
121        request.headers(),
122        request.extensions().get::<ConnectInfo<SocketAddr>>(),
123    )
124}
125
126#[derive(Debug, Clone, Copy)]
127pub struct ClientIp(pub Option<IpAddr>);
128
129impl<S: Sync> FromRequestParts<S> for ClientIp {
130    type Rejection = Infallible;
131
132    fn from_request_parts(
133        parts: &mut Parts,
134        _state: &S,
135    ) -> impl Future<Output = Result<Self, Infallible>> + Send {
136        let resolved = resolve_client_ip_from_config(
137            &parts.headers,
138            parts.extensions.get::<ConnectInfo<SocketAddr>>(),
139        );
140        ready(Ok(Self(resolved)))
141    }
142}