Skip to main content

gateway_runtime/
metadata.rs

1//! # Metadata
2//!
3//! ## Purpose
4//! Utilities for translating HTTP headers into gRPC metadata. This allows context
5//! such as authentication tokens, tracing IDs, and custom headers to be propagated
6//! to the upstream gRPC service.
7//!
8//! ## Scope
9//! This module defines:
10//! -   `forward_metadata`: Propagates HTTP headers to `tonic::metadata::MetadataMap`.
11//! -   `grpc_timeout`: Parses `grpc-timeout` headers into `Duration`.
12//!
13//! ## Position in the Architecture
14//! Called by generated code before making the gRPC request. It populates the `tonic::Request`
15//! metadata from the incoming `http::Request` headers.
16//!
17//! ## Design Constraints
18//! -   **Filtering**: Certain headers (e.g., `Content-Type`, `Host`) are filtered out to prevent
19//!     interference with the gRPC transport.
20//! -   **Security**: Only forwards headers that match allowed prefixes or are explicitly permitted to prevent header injection attacks.
21
22use core::str::FromStr;
23use core::time::Duration;
24use http::Request;
25use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
26
27/// Configuration for metadata forwarding security.
28#[derive(Debug, Clone)]
29pub struct MetadataForwardingConfig {
30    /// Allowed prefixes for headers to be forwarded (e.g., "grpc-metadata-", "x-").
31    /// Defaults to `["grpc-metadata-"]` to match `grpc-gateway` behavior.
32    pub allowed_prefixes: alloc::vec::Vec<alloc::string::String>,
33    /// Explicitly allowed headers (e.g., "authorization").
34    pub allowed_headers: alloc::vec::Vec<alloc::string::String>,
35}
36
37impl Default for MetadataForwardingConfig {
38    fn default() -> Self {
39        Self {
40            allowed_prefixes: crate::alloc::vec![
41                "grpc-metadata-".into(),
42                // We typically also allow "x-" for custom headers if desired, but
43                // grpc-gateway defaults to strictly "Grpc-Metadata-".
44                // We'll stick to strict default but allow configuration.
45            ],
46            allowed_headers: crate::alloc::vec![
47                "authorization".into(),
48                "x-request-id".into(),
49                "x-b3-traceid".into(),
50                "x-b3-spanid".into(),
51                "x-b3-parentspanid".into(),
52                "x-b3-sampled".into(),
53                "x-b3-flags".into(),
54                "x-ot-span-context".into(),
55                "traceparent".into(),
56                "tracestate".into(),
57            ],
58        }
59    }
60}
61
62/// Propagates HTTP headers from the incoming request to the gRPC metadata map.
63///
64/// This function iterates over the HTTP headers and converts them into gRPC metadata entries.
65/// It automatically filters out transport-specific headers and enforces security rules
66/// based on the provided configuration (or defaults if not specified via `Gateway`).
67///
68/// It also renames headers to have an `x-` prefix if they are not standard authentication headers
69/// and do not already have the prefix, to indicate they originate from the gateway.
70///
71/// # Parameters
72/// *   `req`: The incoming HTTP request.
73/// *   `metadata`: The mutable gRPC metadata map to populate.
74/// *   `config`: Optional configuration for forwarding rules.
75pub fn forward_metadata<B>(req: &Request<B>, metadata: &mut MetadataMap) {
76    let default_config = MetadataForwardingConfig::default();
77    // Retrieve config from extensions if available, else use default.
78    let config = req
79        .extensions()
80        .get::<MetadataForwardingConfig>()
81        .unwrap_or(&default_config);
82
83    for (key, value) in req.headers() {
84        let key_str = key.as_str();
85
86        // 1. Filter restricted/transport headers
87        if key_str.eq_ignore_ascii_case("content-type")
88            || key_str.eq_ignore_ascii_case("content-length")
89            || key_str.eq_ignore_ascii_case("host")
90            || key_str.eq_ignore_ascii_case("connection")
91            || key_str.eq_ignore_ascii_case("keep-alive")
92            || key_str.eq_ignore_ascii_case("proxy-authenticate")
93            || key_str.eq_ignore_ascii_case("proxy-authorization")
94            || key_str.eq_ignore_ascii_case("te")
95            || key_str.eq_ignore_ascii_case("trailer")
96            || key_str.eq_ignore_ascii_case("transfer-encoding")
97            || key_str.eq_ignore_ascii_case("upgrade")
98        {
99            continue;
100        }
101
102        // 2. Security Check: Allowlist or Prefix match
103        let is_allowed = config
104            .allowed_headers
105            .iter()
106            .any(|h| key_str.eq_ignore_ascii_case(h))
107            || config
108                .allowed_prefixes
109                .iter()
110                .any(|p| key_str.to_lowercase().starts_with(&p.to_lowercase()));
111
112        if !is_allowed {
113            continue;
114        }
115
116        // 3. Renaming
117        // Automatically prefix non-standard headers with "x-" if not already present.
118        // This helps identify headers that originated from the gateway.
119
120        let mut final_key_str = key_str.to_string();
121        if !key_str.eq_ignore_ascii_case("authorization")
122            && !key_str.eq_ignore_ascii_case("grpc-timeout")
123            && !key_str.starts_with("x-")
124            && !key_str.starts_with("grpc-")
125        {
126            final_key_str = format!("x-{}", key_str);
127        }
128
129        if final_key_str.ends_with("-bin") {
130            if let Ok(key_parsed) =
131                MetadataKey::<tonic::metadata::Binary>::from_bytes(final_key_str.as_bytes())
132            {
133                let val = MetadataValue::from_bytes(value.as_bytes());
134                metadata.insert_bin(key_parsed, val);
135            }
136        } else if let Ok(key_parsed) =
137            MetadataKey::<tonic::metadata::Ascii>::from_str(&final_key_str)
138        {
139            if let Ok(val) = MetadataValue::try_from(value.as_bytes()) {
140                metadata.insert(key_parsed, val);
141            }
142        }
143    }
144
145    // Merge Metadata from Extensions (e.g. from MetadataLayer)
146    // This runs after header processing to ensure middleware-injected metadata takes precedence
147    // and is not subject to the same filtering rules (as it is trusted).
148    if let Some(ext_map) = req.extensions().get::<MetadataMap>() {
149        for item in ext_map.iter() {
150            match item {
151                tonic::metadata::KeyAndValueRef::Ascii(key, val) => {
152                    metadata.insert(key.clone(), val.clone());
153                }
154                tonic::metadata::KeyAndValueRef::Binary(key, val) => {
155                    metadata.insert_bin(key.clone(), val.clone());
156                }
157            }
158        }
159    }
160}
161
162/// Parses the `grpc-timeout` header value into a `Duration`.
163///
164/// The format is a positive integer followed by a unit suffix:
165/// -   `H`: Hours
166/// -   `M`: Minutes
167/// -   `S`: Seconds
168/// -   `m`: Milliseconds
169/// -   `u`: Microseconds
170/// -   `n`: Nanoseconds
171///
172/// # Parameters
173/// *   `val`: The header value string.
174///
175/// # Returns
176/// An `Option<Duration>` if parsing is successful, otherwise `None`.
177pub fn grpc_timeout(val: &str) -> Option<Duration> {
178    if val.is_empty() {
179        return None;
180    }
181    let (num, unit) = val.split_at(val.len() - 1);
182    let n: u64 = num.parse().ok()?;
183    match unit {
184        "H" => Some(Duration::from_secs(n * 3600)),
185        "M" => Some(Duration::from_secs(n * 60)),
186        "S" => Some(Duration::from_secs(n)),
187        "m" => Some(Duration::from_millis(n)),
188        "u" => Some(Duration::from_micros(n)),
189        "n" => Some(Duration::from_nanos(n)),
190        _ => None,
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_forward_metadata_allowed() {
200        let req = http::Request::builder()
201            .header("authorization", "token")
202            .header("grpc-metadata-custom", "val")
203            .body(())
204            .unwrap();
205        let mut md = MetadataMap::new();
206        forward_metadata(&req, &mut md);
207
208        assert_eq!(md.get("authorization").unwrap(), "token");
209        assert_eq!(md.get("grpc-metadata-custom").unwrap(), "val");
210    }
211
212    #[test]
213    fn test_forward_metadata_denied() {
214        let req = http::Request::builder()
215            .header("custom-header", "val") // Not in default allowed list/prefix
216            .body(())
217            .unwrap();
218        let mut md = MetadataMap::new();
219        forward_metadata(&req, &mut md);
220
221        assert!(md.is_empty());
222    }
223
224    #[test]
225    fn test_forward_metadata_custom_config() {
226        let config = MetadataForwardingConfig {
227            allowed_prefixes: crate::alloc::vec![],
228            allowed_headers: crate::alloc::vec!["x-custom-allowed".to_string()],
229        };
230        let mut req = http::Request::builder()
231            .header("x-custom-allowed", "val")
232            .header("other", "nope")
233            .body(())
234            .unwrap();
235        req.extensions_mut().insert(config);
236
237        let mut md = MetadataMap::new();
238        forward_metadata(&req, &mut md);
239
240        assert_eq!(md.get("x-custom-allowed").unwrap(), "val");
241        assert!(md.get("other").is_none());
242    }
243
244    #[test]
245    fn test_grpc_timeout_parsing() {
246        assert_eq!(grpc_timeout("1H"), Some(Duration::from_secs(3600)));
247    }
248
249    #[test]
250    fn test_forward_metadata_extension_map() {
251        let mut req = http::Request::builder().body(()).unwrap();
252        let mut ext_map = MetadataMap::new();
253        ext_map.insert("x-ctx-id", "123".parse().unwrap());
254        req.extensions_mut().insert(ext_map);
255
256        let mut md = MetadataMap::new();
257        forward_metadata(&req, &mut md);
258
259        assert_eq!(md.get("x-ctx-id").unwrap(), "123");
260    }
261}