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}