Skip to main content

structured_proxy/transcode/
metadata.rs

1//! HTTP → gRPC metadata, trace-context, and deadline propagation.
2//!
3//! Converts relevant HTTP headers into gRPC `MetadataMap` entries for upstream
4//! calls (forwarded headers are configurable via YAML), propagates W3C
5//! trace-context across the boundary, and carries a client deadline through as
6//! the upstream call timeout.
7
8use std::convert::Infallible;
9use std::time::Duration;
10
11use axum::http::header::{Entry, OccupiedEntry};
12use axum::http::{HeaderMap, HeaderName, HeaderValue};
13use tonic::metadata::MetadataMap;
14
15/// A forwarded request header whose value gRPC metadata cannot carry.
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17#[error("header `{header}` has a value gRPC metadata cannot carry")]
18pub struct InvalidForwardedHeader {
19    /// The header, as the request named it.
20    pub header: HeaderName,
21}
22
23/// Extract HTTP headers into a gRPC `MetadataMap`, or refuse the request.
24///
25/// Forwards every value of each header listed in `forwarded_headers`, in
26/// order and byte for byte, as Envoy's gRPC-JSON transcoder and grpc-gateway
27/// do: the upstream sees how many times the client sent a header, which some
28/// checks depend on (RFC 9449 §4.3 rejects a request with more than one
29/// `DPoP`). Then always propagates W3C trace-context (forwarding an incoming
30/// `traceparent` or synthesizing one so the upstream joins a single trace
31/// across the REST↔gRPC boundary). That step owns `traceparent` and
32/// `tracestate` even when they are listed: the upstream gets exactly one
33/// valid `traceparent`, and every `tracestate` line only with the trace it
34/// belongs to.
35///
36/// # Errors
37/// A forwarded value gRPC metadata cannot carry: empty or outside visible
38/// ASCII and space for a text key, not base64 for a `-bin` key (gRPC PROTOCOL-HTTP2,
39/// "Custom-Metadata"). gRPC lets a receiver drop such a value, which would
40/// change how many values the upstream sees, so the request is refused
41/// rather than forwarded altered.
42pub fn try_http_headers_to_grpc_metadata(
43    headers: &HeaderMap,
44    forwarded_headers: &[String],
45) -> Result<MetadataMap, InvalidForwardedHeader> {
46    forward(headers, forwarded_headers, |name, value| {
47        if carries(name, value.as_bytes()) {
48            Ok(())
49        } else {
50            Err(InvalidForwardedHeader {
51                header: name.clone(),
52            })
53        }
54    })
55}
56
57/// Extract HTTP headers into a gRPC `MetadataMap`, forwarding every value as
58/// it arrived, including one gRPC metadata cannot carry.
59#[deprecated(
60    note = "forwards values gRPC metadata cannot carry, which a receiver may drop; \
61            use try_http_headers_to_grpc_metadata"
62)]
63pub fn http_headers_to_grpc_metadata(
64    headers: &HeaderMap,
65    forwarded_headers: &[String],
66) -> MetadataMap {
67    match forward::<Infallible>(headers, forwarded_headers, |_, _| Ok(())) {
68        Ok(metadata) => metadata,
69        Err(never) => match never {},
70    }
71}
72
73/// The metadata for `headers`, with `check` deciding on each forwarded value.
74fn forward<E>(
75    headers: &HeaderMap,
76    forwarded_headers: &[String],
77    mut check: impl FnMut(&HeaderName, &HeaderValue) -> Result<(), E>,
78) -> Result<MetadataMap, E> {
79    let mut forwarded = HeaderMap::new();
80    for name in forwarded_headers {
81        // Trace-context propagation owns these, listed or not.
82        if is_trace_context(name) {
83            continue;
84        }
85        let values = headers.get_all(name.as_str());
86        if values.iter().next().is_none() {
87            continue;
88        }
89        let name = HeaderName::from_bytes(name.as_bytes())
90            .expect("a name the request carries a header under is a valid header name");
91        // A name gRPC metadata cannot carry is refused when the proxy is built;
92        // a direct caller that lists one gets nothing forwarded under it.
93        if !is_grpc_key(name.as_str()) {
94            continue;
95        }
96        // A name listed twice is forwarded once, so its values are not doubled.
97        let Entry::Vacant(vacant) = forwarded.entry(name) else {
98            continue;
99        };
100        let key = vacant.key().clone();
101        let binary = key.as_str().ends_with("-bin");
102        let mut vacant = Some(vacant);
103        let mut entry = None;
104        let mut push = |value: HeaderValue| match entry.as_mut() {
105            Some(entry) => {
106                OccupiedEntry::append(entry, value);
107            }
108            None => {
109                let vacant = vacant
110                    .take()
111                    .expect("only the first value finds the entry vacant");
112                entry = Some(vacant.insert_entry(value));
113            }
114        };
115        for value in values {
116            check(&key, value)?;
117            // gRPC PROTOCOL-HTTP2 lets binary values be joined by commas, and
118            // tonic does not split them before decoding: each part travels as
119            // its own value.
120            if binary && value.as_bytes().contains(&b',') {
121                for part in value.as_bytes().split(|&b| b == b',') {
122                    push(
123                        HeaderValue::from_bytes(part.trim_ascii())
124                            .expect("a trimmed part of a header value is a header value"),
125                    );
126                }
127            } else {
128                push(value.clone());
129            }
130        }
131    }
132    let mut metadata = MetadataMap::from_headers(forwarded);
133
134    inject_trace_context(&mut metadata, headers);
135
136    Ok(metadata)
137}
138
139/// Whether gRPC metadata can carry `value` under `name` (gRPC PROTOCOL-HTTP2,
140/// "Custom-Metadata"): base64 for a `-bin` key, visible ASCII and space for a
141/// text key.
142fn carries(name: &HeaderName, value: &[u8]) -> bool {
143    if name.as_str().ends_with("-bin") {
144        is_base64_value(value)
145    } else {
146        is_ascii_value(value)
147    }
148}
149
150/// Whether `name` is a gRPC metadata key (gRPC PROTOCOL-HTTP2,
151/// "Header-Name → 1*( %x30-39 / %x61-7A / "_" / "-" / "." )"), compared the
152/// way HTTP compares field names, without case.
153pub(crate) fn is_grpc_key(name: &str) -> bool {
154    !name.is_empty()
155        && name.bytes().all(|b| {
156            let b = b.to_ascii_lowercase();
157            b.is_ascii_digit() || b.is_ascii_lowercase() || matches!(b, b'_' | b'-' | b'.')
158        })
159}
160
161/// Whether `name` is a W3C trace-context header, which propagation owns.
162fn is_trace_context(name: &str) -> bool {
163    name.eq_ignore_ascii_case("traceparent") || name.eq_ignore_ascii_case("tracestate")
164}
165
166/// `ASCII-Value → 1*( %x20-%x7E )`. An empty value is valid HTTP but not a
167/// gRPC value, so a receiver may drop it and change the count.
168fn is_ascii_value(value: &[u8]) -> bool {
169    !value.is_empty() && value.iter().all(|b| (0x20..=0x7e).contains(b))
170}
171
172/// A `-bin` value: base64 (RFC 4648 §4), padded or not, possibly several
173/// comma-separated values, which a receiver must split before decoding.
174fn is_base64_value(value: &[u8]) -> bool {
175    value.split(|&b| b == b',').all(|part| {
176        let part = part.trim_ascii();
177        let data = part.strip_suffix(b"==").or_else(|| part.strip_suffix(b"="));
178        let alphabet = |data: &[u8]| {
179            data.iter()
180                .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
181        };
182        let (data, well_formed) = match data {
183            // Padding closes a four-digit group.
184            Some(data) => (data, part.len() % 4 == 0),
185            // A lone last digit cannot encode a byte (RFC 4648 §4).
186            None => (part, part.len() % 4 != 1),
187        };
188        well_formed && alphabet(data) && canonical_tail(data)
189    })
190}
191
192/// RFC 4648 §3.5: the bits of the last digit past the final byte are zero, as
193/// strict decoders (tonic's among them) require. `data` is unpadded base64.
194fn canonical_tail(data: &[u8]) -> bool {
195    let unused_bits_mask = match data.len() % 4 {
196        // Two digits carry one byte and four spare bits; three carry two bytes
197        // and two spare bits.
198        2 => 0b1111,
199        3 => 0b0011,
200        _ => return true,
201    };
202    data.last()
203        .is_some_and(|&digit| base64_digit(digit) & unused_bits_mask == 0)
204}
205
206/// The value of a base64 digit (RFC 4648 §4, Table 1); only called on digits
207/// already checked against the alphabet.
208fn base64_digit(digit: u8) -> u8 {
209    match digit {
210        b'A'..=b'Z' => digit - b'A',
211        b'a'..=b'z' => digit - b'a' + 26,
212        b'0'..=b'9' => digit - b'0' + 52,
213        b'+' => 62,
214        _ => 63,
215    }
216}
217
218/// Insert an ASCII metadata entry, silently skipping a key or value gRPC
219/// metadata cannot carry.
220fn insert_ascii(metadata: &mut MetadataMap, key: &str, value: &[u8]) {
221    if !is_ascii_value(value) {
222        return;
223    }
224    if let (Ok(k), Ok(v)) = (
225        key.parse::<tonic::metadata::MetadataKey<tonic::metadata::Ascii>>(),
226        tonic::metadata::AsciiMetadataValue::try_from(value),
227    ) {
228        metadata.insert(k, v);
229    }
230}
231
232/// Append an ASCII metadata entry, silently skipping a value gRPC metadata
233/// cannot carry; W3C Trace Context lets a vendor discard such a `tracestate`.
234fn append_ascii(metadata: &mut MetadataMap, key: &'static str, value: &[u8]) {
235    if !is_ascii_value(value) {
236        return;
237    }
238    if let Ok(v) = tonic::metadata::AsciiMetadataValue::try_from(value) {
239        metadata.append(key, v);
240    }
241}
242
243/// Propagate W3C trace-context into gRPC metadata.
244///
245/// Forwards an incoming `traceparent` (and `tracestate`) only when it is
246/// well-formed per W3C §3.2.2; otherwise (missing or malformed) synthesizes a
247/// fresh one so the upstream always receives a single valid, joinable trace.
248fn inject_trace_context(metadata: &mut MetadataMap, headers: &HeaderMap) {
249    // `metadata` holds no trace-context header of its own: forwarding skips
250    // them, so tracestate travels only here, with the trace it annotates.
251    if let Some(tp) = headers.get("traceparent").and_then(|v| v.to_str().ok()) {
252        if is_valid_traceparent(tp) {
253            insert_ascii(metadata, "traceparent", tp.as_bytes());
254            // Every line: W3C Trace Context §3.3 lets tracestate be split
255            // over several header lines that together form one list.
256            for ts in headers.get_all("tracestate") {
257                append_ascii(metadata, "tracestate", ts.as_bytes());
258            }
259            return;
260        }
261    }
262    if let Some(tp) = new_traceparent() {
263        insert_ascii(metadata, "traceparent", tp.as_bytes());
264    }
265}
266
267/// Validate a W3C `traceparent`: `<version>-<32 hex>-<16 hex>-<2 hex>` with a
268/// non-zero trace-id and parent-id (all-zero IDs are forbidden by W3C §3.2.2).
269///
270/// Per W3C §3.2.1 any 2-hex version except `ff` is accepted; future versions may
271/// append extra `-`-delimited fields, while the baseline `00` must be exactly
272/// the four fields.
273fn is_valid_traceparent(tp: &str) -> bool {
274    let parts: Vec<&str> = tp.split('-').collect();
275    if parts.len() < 4 {
276        return false;
277    }
278    let (version, trace_id, parent_id, flags) = (parts[0], parts[1], parts[2], parts[3]);
279    if version == "00" && parts.len() != 4 {
280        return false;
281    }
282    let is_hex = |s: &str, len: usize| s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit());
283    is_hex(version, 2)
284        && !version.eq_ignore_ascii_case("ff")
285        && is_hex(trace_id, 32)
286        && is_hex(parent_id, 16)
287        && is_hex(flags, 2)
288        && trace_id.bytes().any(|b| b != b'0')
289        && parent_id.bytes().any(|b| b != b'0')
290}
291
292/// Build a fresh W3C `traceparent`: `00-<16-byte trace-id>-<8-byte span-id>-01`
293/// (sampled). Returns `None` only if the system RNG is unavailable.
294fn new_traceparent() -> Option<String> {
295    let mut buf = [0u8; 24];
296    getrandom::fill(&mut buf).ok()?;
297    let trace_id = hex(&buf[..16]);
298    let span_id = hex(&buf[16..]);
299    Some(format!("00-{trace_id}-{span_id}-01"))
300}
301
302/// Lowercase-hex encode a byte slice.
303fn hex(bytes: &[u8]) -> String {
304    use std::fmt::Write;
305    let mut s = String::with_capacity(bytes.len() * 2);
306    for b in bytes {
307        let _ = write!(s, "{b:02x}");
308    }
309    s
310}
311
312/// Apply a client-supplied deadline to the upstream gRPC call.
313///
314/// Reads the gRPC-standard `grpc-timeout` header (`<int><unit>`, unit one of
315/// `H`/`M`/`S`/`m`/`u`/`n`) and sets it as the request timeout. Absent or
316/// malformed values leave the channel default in place. Returns the deadline
317/// that was applied, if any.
318pub fn apply_request_deadline<T>(
319    request: &mut tonic::Request<T>,
320    headers: &HeaderMap,
321) -> Option<Duration> {
322    let timeout = headers
323        .get("grpc-timeout")
324        .and_then(|v| v.to_str().ok())
325        .and_then(parse_grpc_timeout)?;
326    request.set_timeout(timeout);
327    Some(timeout)
328}
329
330/// Parse a gRPC `grpc-timeout` value (`<int><unit>`) into a [`Duration`].
331///
332/// Units: `H` hours, `M` minutes, `S` seconds, `m` milliseconds, `u`
333/// microseconds, `n` nanoseconds. Per the gRPC wire spec the value is at most 8
334/// digits. Returns `None` on a malformed value, an over-long digit run, or a
335/// zero duration (which would expire the call immediately, so the channel
336/// default is used instead).
337fn parse_grpc_timeout(value: &str) -> Option<Duration> {
338    let value = value.trim();
339    let (digits, unit) = value.split_at(value.len().checked_sub(1)?);
340    // The gRPC spec caps TimeoutValue at 8 ASCII digits.
341    if digits.is_empty() || digits.len() > 8 {
342        return None;
343    }
344    let n: u64 = digits.parse().ok()?;
345    // With at most 8 digits, n <= 99_999_999, so n * 3600 < 4e11 << u64::MAX:
346    // the multiplications cannot overflow.
347    let dur = match unit {
348        "H" => Duration::from_secs(n * 3600),
349        "M" => Duration::from_secs(n * 60),
350        "S" => Duration::from_secs(n),
351        "m" => Duration::from_millis(n),
352        "u" => Duration::from_micros(n),
353        "n" => Duration::from_nanos(n),
354        _ => return None,
355    };
356    if dur.is_zero() {
357        return None;
358    }
359    Some(dur)
360}
361
362#[cfg(test)]
363mod tests;