cratestack_axum/headers/enrich.rs
1use std::net::SocketAddr;
2use std::sync::Once;
3
4use axum::http::HeaderMap;
5
6use crate::trusted_proxy::TrustedProxyConfig;
7
8use super::forwarded::{parse_client_ip, parse_hop_ip};
9use super::traceparent::parse_traceparent;
10
11/// Enrich a `CratestackContext` with the request id (from `traceparent`) and the
12/// client IP recorded on audit events. Malformed `traceparent` headers are
13/// silently ignored here — the auth/header-validation layer is the right
14/// place to reject them, not the enrichment seam.
15///
16/// `client_ip` resolution (#415 — see `docs/design/trusted-proxy-client-ip.md`
17/// for the decided design):
18///
19/// - `trusted_proxy` is `Some` and the request's socket `peer` is in its
20/// allowlist: honor whichever single header
21/// [`TrustedProxyConfig::forwarded_header`] selects, walking `max_hops`
22/// entries in from the right end of the chain (right-to-left — see
23/// [`TrustedProxyConfig::max_hops`]). The selected hop is then parsed as
24/// an [`std::net::IpAddr`] (Finding 2 remediation) — a value that isn't a
25/// real IP address (a spoofed string, a placeholder like `unknown`, a
26/// malformed entry) is never recorded; if the header is absent, doesn't
27/// parse at that hop depth, or doesn't parse as an IP, this falls back
28/// to the socket peer address rather than recording nothing.
29/// - Otherwise (no `trusted_proxy` configured, or the peer isn't in its
30/// allowlist): headers are never consulted. `client_ip` is the socket
31/// peer address if one is available, or omitted entirely if it isn't.
32///
33/// The unconfigured default — no `Extension<TrustedProxyConfig>` applied
34/// and/or no `ConnectInfo<SocketAddr>` available — is the safe one: headers
35/// are never trusted, and nothing is guessed. `client_ip` is simply absent
36/// from the audit record.
37pub fn enrich_context_from_headers(
38 ctx: cratestack_core::CratestackContext,
39 headers: &HeaderMap,
40 trusted_proxy: Option<&TrustedProxyConfig>,
41 peer: Option<SocketAddr>,
42) -> cratestack_core::CratestackContext {
43 let mut ctx = ctx;
44 if let Ok(Some(trace_id)) = parse_traceparent(headers) {
45 ctx = ctx.with_request_id(trace_id);
46 }
47 if let Some(ip) = resolve_client_ip(headers, trusted_proxy, peer) {
48 ctx = ctx.with_client_ip(ip);
49 }
50 ctx
51}
52
53/// Logged once per process (not per request — see [`resolve_client_ip`]'s
54/// call site) when a `TrustedProxyConfig` is applied but no `ConnectInfo`
55/// peer ever arrived. That combination is always a misconfiguration: the
56/// consumer applied the `Extension` but never wired
57/// `into_make_service_with_connect_info::<SocketAddr>()` (or applied it to
58/// a *different* router than the one actually serving traffic), so
59/// `Forwarded`/`X-Forwarded-For` can never be honored no matter how the
60/// allowlist is configured — `client_ip` silently degrades to `None` on
61/// every single request. `Once`, not per-request: this is a boot-time
62/// wiring defect, not a per-request condition worth re-reporting under
63/// load (a busy misconfigured deployment could otherwise emit this warning
64/// thousands of times a second, itself becoming an operational problem).
65static MISSING_CONNECT_INFO_WARNING: Once = Once::new();
66
67/// Whether this request's `(trusted_proxy, peer)` combination is the
68/// always-a-misconfiguration case the warning above exists to catch.
69/// Split out as a pure, `Once`-independent predicate so it can be unit
70/// tested directly — the `Once` firing itself is inherently order-
71/// dependent process-wide state (see [`resolve_client_ip`]'s doc), not
72/// something a test can assert on in isolation without coupling to
73/// whichever other test in the same binary happens to run first.
74pub(super) fn is_missing_connect_info_misconfiguration(
75 trusted_proxy: Option<&TrustedProxyConfig>,
76 peer: Option<SocketAddr>,
77) -> bool {
78 trusted_proxy.is_some() && peer.is_none()
79}
80
81fn resolve_client_ip(
82 headers: &HeaderMap,
83 trusted_proxy: Option<&TrustedProxyConfig>,
84 peer: Option<SocketAddr>,
85) -> Option<String> {
86 if is_missing_connect_info_misconfiguration(trusted_proxy, peer) {
87 MISSING_CONNECT_INFO_WARNING.call_once(|| {
88 tracing::warn!(
89 target: "cratestack",
90 "a TrustedProxyConfig is applied to this router but no ConnectInfo<SocketAddr> \
91 peer was available on this request — Forwarded/X-Forwarded-For can never be \
92 honored until the router is served via \
93 into_make_service_with_connect_info::<SocketAddr>(). client_ip is silently \
94 None on every request until this is fixed. Logged once per process."
95 );
96 });
97 }
98
99 let peer_ip = peer.map(|addr| addr.ip().to_string());
100
101 if let (Some(config), Some(addr)) = (trusted_proxy, peer)
102 && config.is_trusted(addr.ip())
103 {
104 // Trusted peer: honor the selected header if it parses at the
105 // configured hop depth AND resolves to a genuine IP address
106 // (Finding 2 — never record an unparseable/spoofed string),
107 // otherwise fall back to the peer address rather than recording
108 // nothing.
109 let header_ip = parse_client_ip(headers, config.hop_count(), config.header())
110 .as_deref()
111 .and_then(parse_hop_ip)
112 .map(|ip| ip.to_string());
113 return header_ip.or(peer_ip);
114 }
115
116 // No trusted-proxy config, or an untrusted peer: never consult
117 // headers. Peer address if available, `None` otherwise.
118 peer_ip
119}