Skip to main content

modo_session/
meta.rs

1use crate::device::{parse_device_name, parse_device_type};
2use crate::fingerprint::compute_fingerprint;
3use http::HeaderMap;
4use std::net::IpAddr;
5
6/// Request metadata used to create sessions.
7/// Built by the middleware from request headers.
8#[derive(Debug, Clone)]
9pub struct SessionMeta {
10    /// Client IP address (respects trusted-proxy configuration).
11    pub ip_address: String,
12    /// Raw `User-Agent` header value.
13    pub user_agent: String,
14    /// Human-readable device name (e.g. `"Chrome on macOS"`).
15    pub device_name: String,
16    /// Device category: `"desktop"`, `"mobile"`, or `"tablet"`.
17    pub device_type: String,
18    /// SHA-256 fingerprint used for hijack detection.
19    pub fingerprint: String,
20}
21
22impl SessionMeta {
23    /// Build `SessionMeta` from individual header values.
24    ///
25    /// `ip_address` should already be the resolved client IP (use
26    /// [`extract_client_ip`] to obtain it from raw headers).
27    pub fn from_headers(
28        ip_address: String,
29        user_agent: &str,
30        accept_language: &str,
31        accept_encoding: &str,
32    ) -> Self {
33        Self {
34            ip_address,
35            device_name: parse_device_name(user_agent),
36            device_type: parse_device_type(user_agent),
37            fingerprint: compute_fingerprint(user_agent, accept_language, accept_encoding),
38            user_agent: user_agent.to_string(),
39        }
40    }
41}
42
43/// Return the value of a named header as a `&str`, or `""` if absent or
44/// non-ASCII.
45pub fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> &'a str {
46    headers
47        .get(name)
48        .and_then(|v| v.to_str().ok())
49        .unwrap_or("")
50}
51
52/// Extract client IP with trusted proxy validation.
53///
54/// # Security
55///
56/// When `trusted_proxies` is empty (the default), proxy headers
57/// (`X-Forwarded-For`, `X-Real-IP`) are trusted unconditionally. Any client
58/// can spoof their IP address by setting these headers. In production behind a
59/// reverse proxy, **always** configure `trusted_proxies` to your proxy's CIDR
60/// range (e.g., `["10.0.0.0/8"]`). Without a reverse proxy, set a dummy value
61/// like `["127.0.0.1/32"]` to ignore proxy headers entirely.
62///
63/// When `trusted_proxies` is non-empty, proxy headers are only trusted when
64/// `connect_ip` originates from a listed CIDR range. Otherwise the raw
65/// `connect_ip` is returned.
66pub fn extract_client_ip(
67    headers: &HeaderMap,
68    trusted_proxies: &[String],
69    connect_ip: Option<IpAddr>,
70) -> String {
71    let parsed_nets: Vec<ipnet::IpNet> = trusted_proxies
72        .iter()
73        .filter_map(|s| s.parse().ok())
74        .collect();
75
76    // If we have a direct connection IP and trusted_proxies is configured,
77    // only trust proxy headers when connection is from a trusted proxy.
78    if let Some(ip) = connect_ip
79        && !parsed_nets.is_empty()
80        && !parsed_nets.iter().any(|net| net.contains(&ip))
81    {
82        return ip.to_string();
83    }
84
85    // Connection from trusted proxy (or no ConnectInfo / no trusted_proxies configured)
86    if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
87        && let Some(first) = forwarded.split(',').next()
88    {
89        let candidate = first.trim();
90        if candidate.parse::<IpAddr>().is_ok() {
91            return candidate.to_string();
92        }
93    }
94
95    if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
96        let candidate = real_ip.trim();
97        if candidate.parse::<IpAddr>().is_ok() {
98            return candidate.to_string();
99        }
100    }
101
102    connect_ip
103        .map(|ip| ip.to_string())
104        .unwrap_or_else(|| "unknown".to_string())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn extract_ip_from_xff() {
113        let mut headers = HeaderMap::new();
114        headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
115        assert_eq!(extract_client_ip(&headers, &[], None), "1.2.3.4");
116    }
117
118    #[test]
119    fn extract_ip_from_x_real_ip() {
120        let mut headers = HeaderMap::new();
121        headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
122        assert_eq!(extract_client_ip(&headers, &[], None), "9.8.7.6");
123    }
124
125    #[test]
126    fn extract_ip_prefers_xff() {
127        let mut headers = HeaderMap::new();
128        headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
129        headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
130        assert_eq!(extract_client_ip(&headers, &[], None), "1.2.3.4");
131    }
132
133    #[test]
134    fn extract_ip_falls_back_to_unknown() {
135        let headers = HeaderMap::new();
136        assert_eq!(extract_client_ip(&headers, &[], None), "unknown");
137    }
138
139    #[test]
140    fn extract_ip_falls_back_to_connect_ip() {
141        let headers = HeaderMap::new();
142        let ip: IpAddr = "192.168.1.1".parse().unwrap();
143        assert_eq!(extract_client_ip(&headers, &[], Some(ip)), "192.168.1.1");
144    }
145
146    #[test]
147    fn untrusted_source_ignores_xff() {
148        let mut headers = HeaderMap::new();
149        headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
150        let untrusted: IpAddr = "203.0.113.5".parse().unwrap();
151        let trusted = vec!["10.0.0.0/24".to_string()];
152        assert_eq!(
153            extract_client_ip(&headers, &trusted, Some(untrusted)),
154            "203.0.113.5"
155        );
156    }
157
158    #[test]
159    fn trusted_proxy_uses_xff() {
160        let mut headers = HeaderMap::new();
161        headers.insert("x-forwarded-for", "8.8.8.8".parse().unwrap());
162        let trusted_ip: IpAddr = "10.0.0.1".parse().unwrap();
163        let trusted = vec!["10.0.0.0/24".to_string()];
164        assert_eq!(
165            extract_client_ip(&headers, &trusted, Some(trusted_ip)),
166            "8.8.8.8"
167        );
168    }
169
170    #[test]
171    fn session_meta_from_headers() {
172        let meta = SessionMeta::from_headers(
173            "10.0.0.1".to_string(),
174            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0",
175            "en-US",
176            "gzip",
177        );
178        assert_eq!(meta.ip_address, "10.0.0.1");
179        assert_eq!(meta.device_name, "Chrome on macOS");
180        assert_eq!(meta.device_type, "desktop");
181        assert_eq!(meta.fingerprint.len(), 64);
182    }
183}