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/// If `connect_ip` is from a trusted proxy (or no trusted proxies configured),
55/// trust X-Forwarded-For / X-Real-IP headers. Otherwise use the socket IP.
56pub fn extract_client_ip(
57    headers: &HeaderMap,
58    trusted_proxies: &[String],
59    connect_ip: Option<IpAddr>,
60) -> String {
61    let parsed_nets: Vec<ipnet::IpNet> = trusted_proxies
62        .iter()
63        .filter_map(|s| s.parse().ok())
64        .collect();
65
66    // If we have a direct connection IP and trusted_proxies is configured,
67    // only trust proxy headers when connection is from a trusted proxy.
68    if let Some(ip) = connect_ip
69        && !parsed_nets.is_empty()
70        && !parsed_nets.iter().any(|net| net.contains(&ip))
71    {
72        return ip.to_string();
73    }
74
75    // Connection from trusted proxy (or no ConnectInfo / no trusted_proxies configured)
76    if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
77        && let Some(first) = forwarded.split(',').next()
78    {
79        let candidate = first.trim();
80        if candidate.parse::<IpAddr>().is_ok() {
81            return candidate.to_string();
82        }
83    }
84
85    if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
86        let candidate = real_ip.trim();
87        if candidate.parse::<IpAddr>().is_ok() {
88            return candidate.to_string();
89        }
90    }
91
92    connect_ip
93        .map(|ip| ip.to_string())
94        .unwrap_or_else(|| "unknown".to_string())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn extract_ip_from_xff() {
103        let mut headers = HeaderMap::new();
104        headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
105        assert_eq!(extract_client_ip(&headers, &[], None), "1.2.3.4");
106    }
107
108    #[test]
109    fn extract_ip_from_x_real_ip() {
110        let mut headers = HeaderMap::new();
111        headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
112        assert_eq!(extract_client_ip(&headers, &[], None), "9.8.7.6");
113    }
114
115    #[test]
116    fn extract_ip_prefers_xff() {
117        let mut headers = HeaderMap::new();
118        headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
119        headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
120        assert_eq!(extract_client_ip(&headers, &[], None), "1.2.3.4");
121    }
122
123    #[test]
124    fn extract_ip_falls_back_to_unknown() {
125        let headers = HeaderMap::new();
126        assert_eq!(extract_client_ip(&headers, &[], None), "unknown");
127    }
128
129    #[test]
130    fn extract_ip_falls_back_to_connect_ip() {
131        let headers = HeaderMap::new();
132        let ip: IpAddr = "192.168.1.1".parse().unwrap();
133        assert_eq!(extract_client_ip(&headers, &[], Some(ip)), "192.168.1.1");
134    }
135
136    #[test]
137    fn untrusted_source_ignores_xff() {
138        let mut headers = HeaderMap::new();
139        headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
140        let untrusted: IpAddr = "203.0.113.5".parse().unwrap();
141        let trusted = vec!["10.0.0.0/24".to_string()];
142        assert_eq!(
143            extract_client_ip(&headers, &trusted, Some(untrusted)),
144            "203.0.113.5"
145        );
146    }
147
148    #[test]
149    fn trusted_proxy_uses_xff() {
150        let mut headers = HeaderMap::new();
151        headers.insert("x-forwarded-for", "8.8.8.8".parse().unwrap());
152        let trusted_ip: IpAddr = "10.0.0.1".parse().unwrap();
153        let trusted = vec!["10.0.0.0/24".to_string()];
154        assert_eq!(
155            extract_client_ip(&headers, &trusted, Some(trusted_ip)),
156            "8.8.8.8"
157        );
158    }
159
160    #[test]
161    fn session_meta_from_headers() {
162        let meta = SessionMeta::from_headers(
163            "10.0.0.1".to_string(),
164            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0",
165            "en-US",
166            "gzip",
167        );
168        assert_eq!(meta.ip_address, "10.0.0.1");
169        assert_eq!(meta.device_name, "Chrome on macOS");
170        assert_eq!(meta.device_type, "desktop");
171        assert_eq!(meta.fingerprint.len(), 64);
172    }
173}