Skip to main content

osproxy_server/
forward_headers.rs

1//! The client-to-upstream header forwarding policy.
2//!
3//! When the proxy forwards a request to a cluster it rebuilds the request from
4//! scratch, so by default only the headers it manages (content type, trace) reach
5//! the upstream. For a sidecar/transparent deployment that is too lossy: the
6//! client's own headers (custom routing hints, `Authorization`, vendor tracing
7//! like B3, …) should travel through. This module computes the **forwarded set**:
8//! every client header except the ones that are unsafe to relay verbatim.
9//!
10//! Two strip lists:
11//!
12//! - **Mandatory** (never forwarded, not configurable): hop-by-hop headers
13//!   (RFC 9110 §7.6.1) plus `host` and `content-length`, because the proxy
14//!   re-frames the request to a different upstream and may rewrite the body, so
15//!   the client's framing headers would be wrong.
16//! - **Configured deny** (`header_forwarding.deny`): an operator's case-insensitive
17//!   list, e.g. add `authorization` to keep the client credential from reaching
18//!   the cluster. Empty by default (pass-all, the sidecar-trust default).
19//!
20//! Trace headers (`traceparent`/`tracestate`) ride through here like any other
21//! client header; whether the proxy *overrides* them with its own span is decided
22//! separately at dispatch (only when span export is on), so a transparent proxy
23//! passes the client's tracing through untouched.
24
25/// Hop-by-hop and framing headers that are never forwarded to the upstream,
26/// regardless of policy. Lowercase for case-insensitive comparison.
27const NEVER_FORWARD: &[&str] = &[
28    // Hop-by-hop (RFC 9110 §7.6.1): meaningful only for a single transport hop.
29    "connection",
30    "keep-alive",
31    "proxy-authenticate",
32    "proxy-authorization",
33    "te",
34    "trailer",
35    "transfer-encoding",
36    "upgrade",
37    // Framing: the proxy targets a different host and may rewrite the body, so
38    // the client's values do not apply. The upstream request builder sets these.
39    "host",
40    "content-length",
41    // Content negotiation the proxy does not manage: if the client's
42    // `accept-encoding` reached the upstream, the cluster could gzip a response
43    // the proxy then relays without round-tripping `content-encoding`, handing the
44    // client compressed bytes labeled as identity. The proxy is not a
45    // compression-transparent hop, so it never negotiates a transfer-coding it
46    // would have to decode (full compression passthrough is a separate opt-in).
47    "accept-encoding",
48];
49
50/// Whether `name` is a header the proxy must never relay verbatim to the upstream.
51fn is_never_forwarded(name: &str) -> bool {
52    NEVER_FORWARD.iter().any(|h| name.eq_ignore_ascii_case(h))
53}
54
55/// The forwarding policy: whether to forward client headers at all, and which to
56/// drop on top of the mandatory hop-by-hop/framing set.
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct ForwardPolicy {
59    /// Forward client headers to the upstream at all. `false` restores the
60    /// minimal behavior (only proxy-managed headers reach the cluster).
61    pub enabled: bool,
62    /// Extra headers to drop (case-insensitive), on top of the mandatory set.
63    pub deny: Vec<String>,
64}
65
66impl ForwardPolicy {
67    /// The default sidecar policy: forward every client header (minus the
68    /// mandatory hop-by-hop/framing set), nothing extra denied.
69    #[must_use]
70    pub fn pass_all() -> Self {
71        Self {
72            enabled: true,
73            deny: Vec::new(),
74        }
75    }
76
77    /// Computes the headers to forward upstream from the raw client headers.
78    /// Returns an empty vec when forwarding is disabled. Hop-by-hop/framing
79    /// headers and any in the configured `deny` list are dropped.
80    #[must_use]
81    pub fn forward_set(&self, client: &[(String, String)]) -> Vec<(String, String)> {
82        if !self.enabled {
83            return Vec::new();
84        }
85        client
86            .iter()
87            .filter(|(name, _)| !is_never_forwarded(name))
88            .filter(|(name, _)| !self.deny.iter().any(|d| d.eq_ignore_ascii_case(name)))
89            .cloned()
90            .collect()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn raw() -> Vec<(String, String)> {
99        vec![
100            ("Authorization".to_owned(), "Bearer s3cret".to_owned()),
101            ("X-Tenant".to_owned(), "acme".to_owned()),
102            ("traceparent".to_owned(), "00-abc-def-01".to_owned()),
103            ("b3".to_owned(), "abc-def-1".to_owned()),
104            ("Connection".to_owned(), "keep-alive".to_owned()),
105            ("Host".to_owned(), "client.local".to_owned()),
106            ("Content-Length".to_owned(), "42".to_owned()),
107            ("Accept-Encoding".to_owned(), "gzip".to_owned()),
108        ]
109    }
110
111    fn names(set: &[(String, String)]) -> Vec<String> {
112        set.iter().map(|(k, _)| k.to_ascii_lowercase()).collect()
113    }
114
115    #[test]
116    fn pass_all_forwards_client_headers_minus_hop_by_hop_and_framing() {
117        let set = ForwardPolicy::pass_all().forward_set(&raw());
118        let n = names(&set);
119        // Application headers (including the client's auth and vendor tracing)
120        // pass through by default (sidecar trust).
121        assert!(n.contains(&"authorization".to_owned()));
122        assert!(n.contains(&"x-tenant".to_owned()));
123        assert!(n.contains(&"traceparent".to_owned()));
124        assert!(n.contains(&"b3".to_owned()));
125        // Hop-by-hop and framing never forwarded.
126        assert!(!n.contains(&"connection".to_owned()));
127        assert!(!n.contains(&"host".to_owned()));
128        assert!(!n.contains(&"content-length".to_owned()));
129        // The proxy never lets the client negotiate a transfer-coding it cannot
130        // round-trip, so a response is never gzipped-but-mislabeled.
131        assert!(!n.contains(&"accept-encoding".to_owned()));
132    }
133
134    #[test]
135    fn the_deny_list_drops_named_headers_case_insensitively() {
136        let policy = ForwardPolicy {
137            enabled: true,
138            deny: vec!["AUTHORIZATION".to_owned()],
139        };
140        let n = names(&policy.forward_set(&raw()));
141        assert!(!n.contains(&"authorization".to_owned()), "denied: {n:?}");
142        assert!(n.contains(&"x-tenant".to_owned()), "others still pass");
143    }
144
145    #[test]
146    fn disabled_forwards_nothing() {
147        let policy = ForwardPolicy {
148            enabled: false,
149            deny: Vec::new(),
150        };
151        assert!(policy.forward_set(&raw()).is_empty());
152    }
153}