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