Skip to main content

siderite_core/middleware/
proxy.rs

1//! Explicit socket-peer trust for forwarded request context.
2
3use crate::extract::Request;
4use std::collections::HashSet;
5use std::net::{IpAddr, SocketAddr};
6use std::sync::Arc;
7
8type ClientIp = Result<Option<IpAddr>, ()>;
9type Header<'a> = Result<Option<&'a str>, ()>;
10
11/// Validated client and transport context from one proxy policy.
12#[derive(Debug, Clone, Copy)]
13pub struct ProxyContext {
14    /// Normalized client address, or no address when the peer is unknown.
15    pub client_ip: Option<IpAddr>,
16    /// Effective request scheme (`http` or `https`).
17    pub scheme: &'static str,
18}
19
20/// Invalid or ambiguous headers supplied by a trusted proxy peer.
21#[derive(Debug, thiserror::Error)]
22#[error("invalid forwarded request context")]
23pub struct ProxyError;
24
25/// Proxy peers permitted to supply forwarded client and scheme headers.
26///
27/// Defaults to no trust. Exact IP addresses are supported; configure the
28/// immediate trusted peer to overwrite headers rather than append client
29/// input. This policy deliberately does not interpret arbitrary proxy chains.
30#[derive(Debug, Clone, Default)]
31pub struct TrustedProxies {
32    peers: Arc<HashSet<IpAddr>>,
33    all: bool,
34}
35
36impl TrustedProxies {
37    /// Trust only the supplied socket-peer IP addresses.
38    ///
39    /// Args:
40    ///     peers: Exact addresses of proxies that replace forwarded headers.
41    ///
42    /// Returns:
43    ///     A policy that ignores headers from other or unknown peers.
44    pub fn new(peers: impl IntoIterator<Item = IpAddr>) -> Self {
45        let peers = peers.into_iter().map(|ip| ip.to_canonical()).collect();
46        Self {
47            peers: Arc::new(peers),
48            all: false,
49        }
50    }
51
52    /// Explicit compatibility policy trusting all forwarded header sources.
53    ///
54    /// Args:
55    ///     None.
56    ///
57    /// Returns:
58    ///     Blanket trust, including requests without socket-peer metadata.
59    ///     Use only when ingress guarantees clients cannot forge these fields.
60    pub fn dangerously_trust_all() -> Self {
61        Self {
62            peers: Arc::default(),
63            all: true,
64        }
65    }
66
67    /// Resolve client and scheme consistently across middleware and caching.
68    ///
69    /// Args:
70    ///     req: Request with optional socket-peer metadata.
71    ///
72    /// Returns:
73    ///     Validated context, or a malformed trusted-header error.
74    pub fn resolve(&self, req: &Request) -> Result<ProxyContext, ProxyError> {
75        let client_ip = self.client_ip(req).map_err(|_| ProxyError)?;
76        let secure = self.is_https(req).map_err(|_| ProxyError)?;
77        Ok(ProxyContext {
78            client_ip,
79            scheme: if secure { "https" } else { "http" },
80        })
81    }
82
83    pub(crate) fn accepts(&self, req: &Request) -> bool {
84        self.all || peer_ip(req).is_some_and(|ip| self.peers.contains(&ip))
85    }
86
87    pub(crate) fn client_ip(&self, req: &Request) -> ClientIp {
88        if !self.accepts(req) {
89            return Ok(peer_ip(req));
90        }
91        let Some(value) = single_header(req, "x-forwarded-for")? else {
92            return Ok(peer_ip(req));
93        };
94        // Only one validated client IP is accepted under exact-peer trust.
95        // Blanket compatibility mode retains the documented first-hop rule.
96        let value = if self.all {
97            value.split(',').next().ok_or(())?
98        } else {
99            if value.contains(',') {
100                return Err(());
101            }
102            value
103        };
104        value
105            .trim()
106            .parse::<IpAddr>()
107            .map(|ip| Some(ip.to_canonical()))
108            .map_err(|_| ())
109    }
110
111    pub(crate) fn is_https(&self, req: &Request) -> Result<bool, ()> {
112        if req.uri().scheme_str() == Some("https") {
113            return Ok(true);
114        }
115        if !self.accepts(req) {
116            return Ok(false);
117        }
118        match single_header(req, "x-forwarded-proto")? {
119            None => Ok(false),
120            Some(value) if value.eq_ignore_ascii_case("https") => Ok(true),
121            Some(value) if value.eq_ignore_ascii_case("http") => Ok(false),
122            Some(_) => Err(()),
123        }
124    }
125}
126
127fn peer_ip(req: &Request) -> Option<IpAddr> {
128    req.extensions()
129        .get::<axum::extract::ConnectInfo<SocketAddr>>()
130        .map(|info| info.0.ip().to_canonical())
131}
132
133fn single_header<'a>(req: &'a Request, name: &str) -> Header<'a> {
134    let mut values = req.headers().get_all(name).iter();
135    let first = values
136        .next()
137        .map(|value| value.to_str().map_err(|_| ()))
138        .transpose()?;
139    if values.next().is_some() {
140        return Err(());
141    }
142    Ok(first)
143}