Skip to main content

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}