Skip to main content

cratefield_runtime_native/ports/
mod.rs

1//! Port adapters over native infrastructure (issue #19): the system
2//! clock on tokio, `tokio::spawn`, `reqwest` and Redis — plus client-IP
3//! resolution, the one place a native deployment must behave
4//! differently from Workers.
5
6mod blob;
7mod clock;
8mod defer;
9mod http;
10mod kv;
11mod rate_limit;
12
13pub use blob::DirBlob;
14pub use clock::TokioClock;
15pub use defer::SpawnDefer;
16pub use http::ReqwestClient;
17pub use kv::{RedisBundle, RedisKv, RedisPortError, redis_from_env};
18pub use rate_limit::RedisRateLimiter;
19
20use axum::http::{HeaderMap, HeaderName};
21use std::net::IpAddr;
22use std::str::FromStr as _;
23
24use cratefield_core::Config;
25
26/// The header the runtime normalizes the resolved client IP into. Core's
27/// `client_ip` reads it first on every target, so modules (which only
28/// ever call `cratefield_core::client_ip`) see the same header contract on
29/// Workers and native. The server middleware sets it **after** stripping
30/// every inbound forwarding header, so its value is always the one this
31/// runtime resolved — never what a client sent.
32pub const CLIENT_IP_HEADER: &str = "cf-connecting-ip";
33
34/// Forwarding headers stripped from every inbound request before the
35/// router sees it. `cratefield_core::client_ip` trusts
36/// `x-forwarded-for`'s first hop on native builds, so an unsanitized
37/// header would let any client pick its own rate-limit key; stripping
38/// here is what makes [`trusted_proxy_headers`] the single trust point.
39const STRIPPED: [&str; 4] = [
40    "cf-connecting-ip",
41    "x-forwarded-for",
42    "x-real-ip",
43    "forwarded",
44];
45
46/// The `TRUSTED_PROXY_HEADERS` list: the header names this deployment is
47/// willing to believe the client IP from (comma-separated, e.g.
48/// `TRUSTED_PROXY_HEADERS=x-forwarded-for` behind one nginx, or
49/// `cf-connecting-ip` behind Cloudflare). Default: **empty** — an
50/// unconfigured deployment must not trust any forwarding header, and the
51/// runtime resolves every address from the socket peer instead.
52///
53/// Trust is a property of the network path, not the header: only list a
54/// header when a proxy you control overwrites it on every request.
55/// Entries are lowercased; invalid header names are ignored; `forwarded`
56/// (RFC 7239) is accepted as a name but its `for=` value format is not
57/// parsed — use an `x-forwarded-for`-style list or `cf-connecting-ip`.
58#[must_use]
59pub fn trusted_proxy_headers(config: &dyn Config) -> Vec<String> {
60    config
61        .get("TRUSTED_PROXY_HEADERS")
62        .map(|raw| {
63            raw.split(',')
64                .map(str::trim)
65                .filter(|entry| !entry.is_empty())
66                .map(str::to_ascii_lowercase)
67                .filter(|name| HeaderName::from_str(name).is_ok())
68                .collect::<Vec<_>>()
69        })
70        .unwrap_or_default()
71}
72
73/// The client IP for one request, in trust order:
74///
75/// 1. the first [`trusted_proxy_headers`] header present with a value
76///    whose first comma-separated entry parses as an IP address;
77/// 2. else the TCP peer address the server observed.
78///
79/// `None` only when both are unavailable (no trusted header matched and
80/// the middleware ran without connection info, e.g. a test's `oneshot`).
81///
82/// This function decides; the server middleware enforces. It resolves
83/// the address here, strips every forwarding header, and writes the
84/// result into [`CLIENT_IP_HEADER`](crate::CLIENT_IP_HEADER) — so by the time a module calls
85/// `cratefield_core::client_ip(headers)`, the only forwarding header left
86/// carries exactly this runtime's verdict.
87#[must_use]
88pub fn client_ip(headers: &HeaderMap, trusted: &[String], peer: Option<IpAddr>) -> Option<IpAddr> {
89    for name in trusted {
90        let parsed = headers
91            .get(name.as_str())
92            .and_then(|value| value.to_str().ok())
93            .and_then(|list| list.split(',').next())
94            .map(str::trim)
95            .and_then(|first| first.parse::<IpAddr>().ok());
96        if let Some(ip) = parsed {
97            return Some(ip);
98        }
99    }
100    peer
101}
102
103/// Applies the resolution to a request's headers: strip
104/// [`STRIPPED`], then set [`CLIENT_IP_HEADER`] to `resolved`. This is
105/// what the server middleware calls; it is `pub(crate)` visible only
106/// through tests that assert the sanitized view modules actually see.
107pub(crate) fn normalize_headers(headers: &mut HeaderMap, resolved: Option<IpAddr>) {
108    for name in STRIPPED {
109        headers.remove(HeaderName::from_static(name));
110    }
111    if let Some(ip) = resolved
112        && let Ok(value) = axum::http::HeaderValue::from_str(&ip.to_string())
113    {
114        headers.insert(HeaderName::from_static(CLIENT_IP_HEADER), value);
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use axum::http::HeaderValue;
122    use cratefield_core::MapConfig;
123
124    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
125        use std::str::FromStr as _;
126        let mut map = HeaderMap::new();
127        for (name, value) in pairs {
128            map.insert(
129                HeaderName::from_str(name).expect("test header names are valid"),
130                HeaderValue::from_str(value).expect("valid header value"),
131            );
132        }
133        map
134    }
135
136    #[test]
137    fn trusted_list_defaults_to_empty() {
138        assert!(trusted_proxy_headers(&MapConfig::default()).is_empty());
139        let cfg = MapConfig::from_pairs([("TRUSTED_PROXY_HEADERS", "  ")]);
140        assert!(trusted_proxy_headers(&cfg).is_empty());
141    }
142
143    #[test]
144    fn trusted_list_parses_lowercased_and_drops_invalid() {
145        let cfg = MapConfig::from_pairs([(
146            "TRUSTED_PROXY_HEADERS",
147            "X-Forwarded-For, x-real-ip, bad name,,cf-connecting-ip",
148        )]);
149        assert_eq!(
150            trusted_proxy_headers(&cfg),
151            ["x-forwarded-for", "x-real-ip", "cf-connecting-ip"]
152        );
153    }
154
155    #[test]
156    fn unconfigured_trusts_no_header_only_the_peer() {
157        let headers = headers(&[
158            ("x-forwarded-for", "6.6.6.6"),
159            ("cf-connecting-ip", "7.7.7.7"),
160            ("x-real-ip", "8.8.8.8"),
161        ]);
162        let peer: IpAddr = "203.0.113.7".parse().unwrap();
163        assert_eq!(
164            client_ip(&headers, &[], Some(peer)),
165            Some(peer),
166            "with no trusted headers, forwarding headers are decoration"
167        );
168    }
169
170    #[test]
171    fn trusted_header_wins_in_config_order() {
172        let headers = headers(&[
173            ("x-real-ip", "198.51.100.2"),
174            ("x-forwarded-for", "198.51.100.1, 10.0.0.1"),
175        ]);
176        let trusted = vec!["x-forwarded-for".to_owned(), "x-real-ip".to_owned()];
177        let resolved = client_ip(&headers, &trusted, Some("203.0.113.7".parse().unwrap()));
178        assert_eq!(
179            resolved.map(|ip| ip.to_string()).as_deref(),
180            Some("198.51.100.1")
181        );
182    }
183
184    #[test]
185    fn garbage_in_trusted_header_falls_through_to_next_then_peer() {
186        let headers = headers(&[("x-real-ip", "not-an-ip")]);
187        let peer: IpAddr = "203.0.113.7".parse().unwrap();
188        let trusted = vec!["x-real-ip".to_owned()];
189        assert_eq!(client_ip(&headers, &trusted, Some(peer)), Some(peer));
190    }
191
192    #[test]
193    fn normalize_strips_everything_and_sets_the_verdict() {
194        let mut headers = headers(&[
195            ("x-forwarded-for", "6.6.6.6"),
196            ("forwarded", "for=7.7.7.7"),
197            ("x-real-ip", "8.8.8.8"),
198            ("cf-connecting-ip", "9.9.9.9"),
199        ]);
200        normalize_headers(&mut headers, Some("203.0.113.7".parse::<IpAddr>().unwrap()));
201        // The spoofed values are gone; the verdict is the only survivor.
202        assert_eq!(headers.get("x-forwarded-for"), None);
203        assert_eq!(headers.get("forwarded"), None);
204        assert_eq!(headers.get("x-real-ip"), None);
205        assert_eq!(
206            headers.get(CLIENT_IP_HEADER).and_then(|v| v.to_str().ok()),
207            Some("203.0.113.7")
208        );
209        // And core — what the modules call — reads exactly that verdict.
210        assert_eq!(
211            cratefield_core::client_ip(&headers).as_deref(),
212            Some("203.0.113.7")
213        );
214    }
215}