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 (Optional/Compatibility)
117        // If it's already "grpc-metadata-", it maps directly.
118        // If it's "x-", it maps directly.
119        // We preserve the logic requested previously: "prefix x- unless standard".
120        // But strict security implies we only forward what we TRUST or explicitly allow.
121        // If allowed, we forward as is? Or do we still rename?
122        // grpc-gateway behavior: `Grpc-Metadata-Foo` -> `Foo` in metadata? Or `grpc-metadata-foo`?
123        // Actually, grpc-gateway typically strips `Grpc-Metadata-`.
124        // Rust Tonic doesn't strip automatically.
125        // For this task, we'll keep the previous "x-" prefixing logic for non-standard headers
126        // that pass the filter, to maintain the requested behavior of "identifying from gateway".
127
128        let mut final_key_str = key_str.to_string();
129        if !key_str.eq_ignore_ascii_case("authorization")
130            && !key_str.eq_ignore_ascii_case("grpc-timeout")
131            && !key_str.starts_with("x-")
132            && !key_str.starts_with("grpc-")
133        {
134            final_key_str = format!("x-{}", key_str);
135        }
136
137        if final_key_str.ends_with("-bin") {
138            if let Ok(key_parsed) =
139                MetadataKey::<tonic::metadata::Binary>::from_bytes(final_key_str.as_bytes())
140            {
141                let val = MetadataValue::from_bytes(value.as_bytes());
142                metadata.insert_bin(key_parsed, val);
143            }
144        } else {
145            if let Ok(key_parsed) = MetadataKey::<tonic::metadata::Ascii>::from_str(&final_key_str)
146            {
147                if let Ok(val) = MetadataValue::try_from(value.as_bytes()) {
148                    metadata.insert(key_parsed, val);
149                }
150            }
151        }
152    }
153
154    // Merge Metadata from Extensions (e.g. from MetadataLayer)
155    // This runs after header processing to ensure middleware-injected metadata takes precedence
156    // and is not subject to the same filtering rules (as it is trusted).
157    if let Some(ext_map) = req.extensions().get::<MetadataMap>() {
158        for item in ext_map.iter() {
159            match item {
160                tonic::metadata::KeyAndValueRef::Ascii(key, val) => {
161                    metadata.insert(key.clone(), val.clone());
162                }
163                tonic::metadata::KeyAndValueRef::Binary(key, val) => {
164                    metadata.insert_bin(key.clone(), val.clone());
165                }
166            }
167        }
168    }
169}
170
171/// Parses the `grpc-timeout` header value into a `Duration`.
172///
173/// The format is a positive integer followed by a unit suffix:
174/// -   `H`: Hours
175/// -   `M`: Minutes
176/// -   `S`: Seconds
177/// -   `m`: Milliseconds
178/// -   `u`: Microseconds
179/// -   `n`: Nanoseconds
180///
181/// # Parameters
182/// *   `val`: The header value string.
183///
184/// # Returns
185/// An `Option<Duration>` if parsing is successful, otherwise `None`.
186pub fn grpc_timeout(val: &str) -> Option<Duration> {
187    if val.is_empty() {
188        return None;
189    }
190    let (num, unit) = val.split_at(val.len() - 1);
191    let n: u64 = num.parse().ok()?;
192    match unit {
193        "H" => Some(Duration::from_secs(n * 3600)),
194        "M" => Some(Duration::from_secs(n * 60)),
195        "S" => Some(Duration::from_secs(n)),
196        "m" => Some(Duration::from_millis(n)),
197        "u" => Some(Duration::from_micros(n)),
198        "n" => Some(Duration::from_nanos(n)),
199        _ => None,
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_forward_metadata_allowed() {
209        let req = http::Request::builder()
210            .header("authorization", "token")
211            .header("grpc-metadata-custom", "val")
212            .body(())
213            .unwrap();
214        let mut md = MetadataMap::new();
215        forward_metadata(&req, &mut md);
216
217        assert_eq!(md.get("authorization").unwrap(), "token");
218        assert_eq!(md.get("grpc-metadata-custom").unwrap(), "val");
219    }
220
221    #[test]
222    fn test_forward_metadata_denied() {
223        let req = http::Request::builder()
224            .header("custom-header", "val") // Not in default allowed list/prefix
225            .body(())
226            .unwrap();
227        let mut md = MetadataMap::new();
228        forward_metadata(&req, &mut md);
229
230        assert!(md.is_empty());
231    }
232
233    #[test]
234    fn test_forward_metadata_custom_config() {
235        let config = MetadataForwardingConfig {
236            allowed_prefixes: crate::alloc::vec![],
237            allowed_headers: crate::alloc::vec!["x-custom-allowed".to_string()],
238        };
239        let mut req = http::Request::builder()
240            .header("x-custom-allowed", "val")
241            .header("other", "nope")
242            .body(())
243            .unwrap();
244        req.extensions_mut().insert(config);
245
246        let mut md = MetadataMap::new();
247        forward_metadata(&req, &mut md);
248
249        assert_eq!(md.get("x-custom-allowed").unwrap(), "val");
250        assert!(md.get("other").is_none());
251    }
252
253    #[test]
254    fn test_grpc_timeout_parsing() {
255        assert_eq!(grpc_timeout("1H"), Some(Duration::from_secs(3600)));
256    }
257
258    #[test]
259    fn test_forward_metadata_extension_map() {
260        let mut req = http::Request::builder().body(()).unwrap();
261        let mut ext_map = MetadataMap::new();
262        ext_map.insert("x-ctx-id", "123".parse().unwrap());
263        req.extensions_mut().insert(ext_map);
264
265        let mut md = MetadataMap::new();
266        forward_metadata(&req, &mut md);
267
268        assert_eq!(md.get("x-ctx-id").unwrap(), "123");
269    }
270}