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(
53        headers,
54        Forwarded::Listed(forwarded_headers),
55        Source::Client,
56    )
57}
58
59/// The headers a request forwards: as configured, or already parsed.
60#[derive(Clone, Copy)]
61pub(crate) enum Forwarded<'a> {
62    Listed(&'a [String]),
63    Named(&'a [HeaderName]),
64}
65
66/// [`try_http_headers_to_grpc_metadata`] of the `forwarded` headers, for a
67/// request whose client-address headers the proxy rewrote (`rewritten`):
68/// `forwarded` names them too, and they are forwarded as the rewrite left
69/// them.
70pub(crate) fn forwarded_metadata(
71    headers: &HeaderMap,
72    forwarded: Forwarded<'_>,
73    rewritten: bool,
74) -> Result<MetadataMap, InvalidForwardedHeader> {
75    let source = if rewritten {
76        Source::Rewritten
77    } else {
78        Source::Client
79    };
80    checked(headers, forwarded, source)
81}
82
83/// Who last wrote a request's client-address headers.
84#[derive(Clone, Copy, PartialEq, Eq)]
85enum Source {
86    /// The client: they carry its own assertions and are not forwarded.
87    Client,
88    /// The proxy's client-address resolution.
89    Rewritten,
90}
91
92fn checked(
93    headers: &HeaderMap,
94    forwarded: Forwarded<'_>,
95    source: Source,
96) -> Result<MetadataMap, InvalidForwardedHeader> {
97    forward(headers, forwarded, source, |name, value| {
98        if carries(name, value.as_bytes()) {
99            Ok(())
100        } else {
101            Err(InvalidForwardedHeader {
102                header: name.clone(),
103            })
104        }
105    })
106}
107
108/// Extract HTTP headers into a gRPC `MetadataMap`, forwarding every value as
109/// it arrived, including one gRPC metadata cannot carry.
110#[deprecated(
111    note = "forwards values gRPC metadata cannot carry, which a receiver may drop; \
112            use try_http_headers_to_grpc_metadata"
113)]
114pub fn http_headers_to_grpc_metadata(
115    headers: &HeaderMap,
116    forwarded_headers: &[String],
117) -> MetadataMap {
118    let forwarded = Forwarded::Listed(forwarded_headers);
119    match forward::<Infallible>(headers, forwarded, Source::Client, |_, _| Ok(())) {
120        Ok(metadata) => metadata,
121        Err(never) => match never {},
122    }
123}
124
125/// The metadata for `headers`, with `check` deciding on each forwarded value.
126fn forward<E>(
127    headers: &HeaderMap,
128    forwarded: Forwarded<'_>,
129    source: Source,
130    mut check: impl FnMut(&HeaderName, &HeaderValue) -> Result<(), E>,
131) -> Result<MetadataMap, E> {
132    let mut metadata = HeaderMap::new();
133    match forwarded {
134        Forwarded::Listed(names) => {
135            for name in names {
136                // A name a request cannot carry a header under forwards
137                // nothing.
138                if let Ok(name) = HeaderName::from_bytes(name.as_bytes()) {
139                    forward_one(&mut metadata, headers, &name, source, &mut check)?;
140                }
141            }
142        }
143        Forwarded::Named(names) => {
144            for name in names {
145                forward_one(&mut metadata, headers, name, source, &mut check)?;
146            }
147        }
148    }
149    let mut metadata = MetadataMap::from_headers(metadata);
150
151    inject_trace_context(&mut metadata, headers);
152
153    Ok(metadata)
154}
155
156/// Forward every value of the header `name` from `headers` to `forwarded`,
157/// with `check` deciding on each.
158fn forward_one<E>(
159    forwarded: &mut HeaderMap,
160    headers: &HeaderMap,
161    name: &HeaderName,
162    source: Source,
163    check: &mut impl FnMut(&HeaderName, &HeaderValue) -> Result<(), E>,
164) -> Result<(), E> {
165    let text = name.as_str();
166    // Trace-context propagation owns these, listed or not, and client-address
167    // resolution owns the address headers a client sent.
168    if is_trace_context(text) || (source == Source::Client && crate::client_address::owns(text)) {
169        return Ok(());
170    }
171    let values = headers.get_all(name);
172    if values.iter().next().is_none() {
173        return Ok(());
174    }
175    // A name gRPC metadata cannot carry is refused when the proxy is built;
176    // a direct caller that lists one gets nothing forwarded under it.
177    if !is_grpc_key(text) {
178        return Ok(());
179    }
180    // A name listed twice is forwarded once, so its values are not doubled.
181    let Entry::Vacant(vacant) = forwarded.entry(name.clone()) else {
182        return Ok(());
183    };
184    let binary = text.ends_with("-bin");
185    let mut vacant = Some(vacant);
186    let mut entry = None;
187    let mut push = |value: HeaderValue| match entry.as_mut() {
188        Some(entry) => {
189            OccupiedEntry::append(entry, value);
190        }
191        None => {
192            let vacant = vacant
193                .take()
194                .expect("only the first value finds the entry vacant");
195            entry = Some(vacant.insert_entry(value));
196        }
197    };
198    for value in values {
199        check(name, value)?;
200        // gRPC PROTOCOL-HTTP2 lets binary values be joined by commas, and
201        // tonic does not split them before decoding: each part travels as its
202        // own value.
203        if binary && value.as_bytes().contains(&b',') {
204            for part in value.as_bytes().split(|&b| b == b',') {
205                push(
206                    HeaderValue::from_bytes(part.trim_ascii())
207                        .expect("a trimmed part of a header value is a header value"),
208                );
209            }
210        } else {
211            push(value.clone());
212        }
213    }
214    Ok(())
215}
216
217/// Whether gRPC metadata can carry `value` under `name` (gRPC PROTOCOL-HTTP2,
218/// "Custom-Metadata"): base64 for a `-bin` key, visible ASCII and space for a
219/// text key.
220fn carries(name: &HeaderName, value: &[u8]) -> bool {
221    if name.as_str().ends_with("-bin") {
222        is_base64_value(value)
223    } else {
224        is_ascii_value(value)
225    }
226}
227
228/// Whether `name` is a gRPC metadata key (gRPC PROTOCOL-HTTP2,
229/// "Header-Name → 1*( %x30-39 / %x61-7A / "_" / "-" / "." )"), compared the
230/// way HTTP compares field names, without case.
231pub(crate) fn is_grpc_key(name: &str) -> bool {
232    !name.is_empty()
233        && name.bytes().all(|b| {
234            let b = b.to_ascii_lowercase();
235            b.is_ascii_digit() || b.is_ascii_lowercase() || matches!(b, b'_' | b'-' | b'.')
236        })
237}
238
239/// Whether `name` is a W3C trace-context header, which propagation owns.
240fn is_trace_context(name: &str) -> bool {
241    name.eq_ignore_ascii_case("traceparent") || name.eq_ignore_ascii_case("tracestate")
242}
243
244/// `ASCII-Value → 1*( %x20-%x7E )`. An empty value is valid HTTP but not a
245/// gRPC value, so a receiver may drop it and change the count.
246fn is_ascii_value(value: &[u8]) -> bool {
247    !value.is_empty() && value.iter().all(|b| (0x20..=0x7e).contains(b))
248}
249
250/// A `-bin` value: base64 (RFC 4648 §4), padded or not, possibly several
251/// comma-separated values, which a receiver must split before decoding.
252fn is_base64_value(value: &[u8]) -> bool {
253    value.split(|&b| b == b',').all(|part| {
254        let part = part.trim_ascii();
255        let data = part.strip_suffix(b"==").or_else(|| part.strip_suffix(b"="));
256        let alphabet = |data: &[u8]| {
257            data.iter()
258                .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
259        };
260        let (data, well_formed) = match data {
261            // Padding closes a four-digit group.
262            Some(data) => (data, part.len() % 4 == 0),
263            // A lone last digit cannot encode a byte (RFC 4648 §4).
264            None => (part, part.len() % 4 != 1),
265        };
266        well_formed && alphabet(data) && canonical_tail(data)
267    })
268}
269
270/// RFC 4648 §3.5: the bits of the last digit past the final byte are zero, as
271/// strict decoders (tonic's among them) require. `data` is unpadded base64.
272fn canonical_tail(data: &[u8]) -> bool {
273    let unused_bits_mask = match data.len() % 4 {
274        // Two digits carry one byte and four spare bits; three carry two bytes
275        // and two spare bits.
276        2 => 0b1111,
277        3 => 0b0011,
278        _ => return true,
279    };
280    data.last()
281        .is_some_and(|&digit| base64_digit(digit) & unused_bits_mask == 0)
282}
283
284/// The value of a base64 digit (RFC 4648 §4, Table 1); only called on digits
285/// already checked against the alphabet.
286fn base64_digit(digit: u8) -> u8 {
287    match digit {
288        b'A'..=b'Z' => digit - b'A',
289        b'a'..=b'z' => digit - b'a' + 26,
290        b'0'..=b'9' => digit - b'0' + 52,
291        b'+' => 62,
292        _ => 63,
293    }
294}
295
296/// Insert an ASCII metadata entry, silently skipping a key or value gRPC
297/// metadata cannot carry.
298fn insert_ascii(metadata: &mut MetadataMap, key: &str, value: &[u8]) {
299    if !is_ascii_value(value) {
300        return;
301    }
302    if let (Ok(k), Ok(v)) = (
303        key.parse::<tonic::metadata::MetadataKey<tonic::metadata::Ascii>>(),
304        tonic::metadata::AsciiMetadataValue::try_from(value),
305    ) {
306        metadata.insert(k, v);
307    }
308}
309
310/// Append an ASCII metadata entry, silently skipping a value gRPC metadata
311/// cannot carry; W3C Trace Context lets a vendor discard such a `tracestate`.
312fn append_ascii(metadata: &mut MetadataMap, key: &'static str, value: &[u8]) {
313    if !is_ascii_value(value) {
314        return;
315    }
316    if let Ok(v) = tonic::metadata::AsciiMetadataValue::try_from(value) {
317        metadata.append(key, v);
318    }
319}
320
321/// Propagate W3C trace-context into gRPC metadata.
322///
323/// Forwards an incoming `traceparent` (and `tracestate`) only when it is
324/// well-formed per W3C §3.2.2; otherwise (missing or malformed) synthesizes a
325/// fresh one so the upstream always receives a single valid, joinable trace.
326fn inject_trace_context(metadata: &mut MetadataMap, headers: &HeaderMap) {
327    static TRACEPARENT: HeaderName = HeaderName::from_static("traceparent");
328    static TRACESTATE: HeaderName = HeaderName::from_static("tracestate");
329    // `metadata` holds no trace-context header of its own: forwarding skips
330    // them, so tracestate travels only here, with the trace it annotates.
331    if let Some(tp) = headers.get(&TRACEPARENT).and_then(|v| v.to_str().ok()) {
332        if is_valid_traceparent(tp) {
333            insert_ascii(metadata, "traceparent", tp.as_bytes());
334            // Every line: W3C Trace Context §3.3 lets tracestate be split
335            // over several header lines that together form one list.
336            for ts in headers.get_all(&TRACESTATE) {
337                append_ascii(metadata, "tracestate", ts.as_bytes());
338            }
339            return;
340        }
341    }
342    if let Some(tp) = new_traceparent() {
343        let value = tonic::metadata::AsciiMetadataValue::try_from(&tp[..])
344            .expect("a traceparent is visible ASCII");
345        metadata.insert(
346            tonic::metadata::MetadataKey::from_static("traceparent"),
347            value,
348        );
349    }
350}
351
352/// Validate a W3C `traceparent`: `<version>-<32 hex>-<16 hex>-<2 hex>` with a
353/// non-zero trace-id and parent-id (all-zero IDs are forbidden by W3C §3.2.2).
354///
355/// Per W3C §3.2.1 any 2-hex version except `ff` is accepted; future versions may
356/// append extra `-`-delimited fields, while the baseline `00` must be exactly
357/// the four fields.
358fn is_valid_traceparent(tp: &str) -> bool {
359    let parts: Vec<&str> = tp.split('-').collect();
360    if parts.len() < 4 {
361        return false;
362    }
363    let (version, trace_id, parent_id, flags) = (parts[0], parts[1], parts[2], parts[3]);
364    if version == "00" && parts.len() != 4 {
365        return false;
366    }
367    let is_hex = |s: &str, len: usize| s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit());
368    is_hex(version, 2)
369        && !version.eq_ignore_ascii_case("ff")
370        && is_hex(trace_id, 32)
371        && is_hex(parent_id, 16)
372        && is_hex(flags, 2)
373        && trace_id.bytes().any(|b| b != b'0')
374        && parent_id.bytes().any(|b| b != b'0')
375}
376
377/// Build a fresh W3C `traceparent`: `00-<16-byte trace-id>-<8-byte span-id>-01`
378/// (sampled). Returns `None` only if the system RNG is unavailable.
379fn new_traceparent() -> Option<[u8; 55]> {
380    let (high, low, span) = (random_id()?, random_id()?, random_id()?);
381    // All-zero ids are invalid (W3C Trace Context §3.2.2.3, §3.2.2.4).
382    let low = if high == 0 && low == 0 { 1 } else { low };
383    let span = if span == 0 { 1 } else { span };
384    let mut tp = *b"00-00000000000000000000000000000000-0000000000000000-01";
385    write_hex(&mut tp[3..19], high);
386    write_hex(&mut tp[19..35], low);
387    write_hex(&mut tp[36..52], span);
388    Some(tp)
389}
390
391/// `value` as 16 lower-case hex digits into `digits`.
392fn write_hex(digits: &mut [u8], value: u64) {
393    const DIGITS: &[u8; 16] = b"0123456789abcdef";
394    for (at, digit) in digits.iter_mut().enumerate() {
395        let shift = 60 - 4 * at;
396        *digit = DIGITS[usize::try_from((value >> shift) & 0xf).expect("a nibble")];
397    }
398}
399
400/// 64 random bits for a trace id. W3C Trace Context asks for random ids, not
401/// unpredictable ones (§3.2.2.3; an id is no secret), so they come from a
402/// generator (wyrand) each thread seeds once from the system's, as
403/// OpenTelemetry's SDKs make them, rather than a system call per request.
404/// `None` only when the system cannot seed it.
405fn random_id() -> Option<u64> {
406    thread_local! {
407        static STATE: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
408    }
409    STATE.with(|state| {
410        let seed = match state.get() {
411            Some(seed) => seed,
412            None => {
413                let mut bytes = [0u8; 8];
414                getrandom::fill(&mut bytes).ok()?;
415                u64::from_ne_bytes(bytes)
416            }
417        };
418        let next = seed.wrapping_add(0xa076_1d64_78bd_642f);
419        state.set(Some(next));
420        let wide = u128::from(next) * u128::from(next ^ 0xe703_7ed1_a0b4_28db);
421        // The two halves of the product, folded.
422        Some((wide >> 64) as u64 ^ wide as u64)
423    })
424}
425
426/// Apply a client-supplied deadline to the upstream gRPC call.
427///
428/// Reads the gRPC-standard `grpc-timeout` header (`<int><unit>`, unit one of
429/// `H`/`M`/`S`/`m`/`u`/`n`) and sets it as the request timeout, which travels
430/// to the upstream as its `grpc-timeout`. Absent or malformed values set
431/// nothing, leaving the proxy's own
432/// [`UPSTREAM_DEADLINE`](super::UPSTREAM_DEADLINE). Returns the deadline that
433/// was applied, if any.
434pub fn apply_request_deadline<T>(
435    request: &mut tonic::Request<T>,
436    headers: &HeaderMap,
437) -> Option<Duration> {
438    let timeout = headers
439        .get("grpc-timeout")
440        .and_then(|v| v.to_str().ok())
441        .and_then(parse_grpc_timeout)?;
442    request.set_timeout(timeout);
443    Some(timeout)
444}
445
446/// Parse a gRPC `grpc-timeout` value (`<int><unit>`) into a [`Duration`].
447///
448/// Units: `H` hours, `M` minutes, `S` seconds, `m` milliseconds, `u`
449/// microseconds, `n` nanoseconds. Per the gRPC wire spec the value is at most 8
450/// digits. Returns `None` on a malformed value, an over-long digit run, or a
451/// zero duration (which would expire the call immediately, so the proxy's
452/// default is used instead).
453fn parse_grpc_timeout(value: &str) -> Option<Duration> {
454    let value = value.trim();
455    let (digits, unit) = value.split_at(value.len().checked_sub(1)?);
456    // The gRPC spec caps TimeoutValue at 8 ASCII digits.
457    if digits.is_empty() || digits.len() > 8 {
458        return None;
459    }
460    let n: u64 = digits.parse().ok()?;
461    // With at most 8 digits, n <= 99_999_999, so n * 3600 < 4e11 << u64::MAX:
462    // the multiplications cannot overflow.
463    let dur = match unit {
464        "H" => Duration::from_secs(n * 3600),
465        "M" => Duration::from_secs(n * 60),
466        "S" => Duration::from_secs(n),
467        "m" => Duration::from_millis(n),
468        "u" => Duration::from_micros(n),
469        "n" => Duration::from_nanos(n),
470        _ => return None,
471    };
472    if dur.is_zero() {
473        return None;
474    }
475    Some(dur)
476}
477
478#[cfg(test)]
479mod tests;