dwara_core/dataplane/
hardening.rs

1//! Protocol hardening knobs (DW-023, feature analysis 4.20).
2//!
3//! Two families of defenses, applied identically to EVERY serving surface
4//! (data-plane listeners and the admin listener):
5//!
6//! 1. **Parser/amplification bounds** on hyper's connection builders, so a
7//!    single hostile connection cannot pin unbounded memory or parse cost:
8//!
9//!    | Knob (env) | Default | Attack it bounds |
10//!    |---|---|---|
11//!    | `DWARA_HTTP1_MAX_HEADERS` | 100 | header-count bombs (N header lines per request) |
12//!    | `DWARA_HTTP1_MAX_BUF_KIB` | 64 KiB | single-header/line size bombs (hyper's read buffer cap; a header line that does not fit is a 431-class parse failure) |
13//!    | `DWARA_HTTP1_HEADER_TIMEOUT_MS` | 10 000 | SLOWLORIS: a connection that sends headers slower than this is closed before ever reaching a route |
14//!    | `DWARA_H2_MAX_CONCURRENT_STREAMS` | 128 | stream floods over one h2 connection (also advertised to the peer in SETTINGS) |
15//!    | `DWARA_H2_STREAM_WINDOW_KIB` | 1024 (1 MiB) | per-stream receive buffering a malicious h2 peer can force |
16//!    | `DWARA_H2_CONNECTION_WINDOW_KIB` | 4096 (4 MiB) | connection-wide h2 receive buffering |
17//!    | `DWARA_H2_MAX_SEND_BUF_KIB` | 1024 (1 MiB) | outbound h2 send buffer per connection (write-amplification / memory pinning by a non-reading peer) |
18//!
19//! Left deliberately at hyper's defaults: `max_frame_size` (16 KiB, the
20//! minimum legal value — hyper already refuses larger frames) and HTTP/2
21//! `max_headers` (hyper-util's h2 builder has no such knob; the h2 header
22//! list is bounded by the flow-control windows pinned above).
23//!
24//! A timer is installed on the HTTP/1 builder (`TokioTimer`) because
25//! hyper disables `header_read_timeout` when no timer is configured.
26//!
27//! 2. **Request-body inactivity gap** (`DWARA_REQUEST_BODY_TIMEOUT_MS`,
28//!    default 30 000, `0` disables): the INBOUND request body handed to the
29//!    dataplane is wrapped in [`InboundBody`], which errors when the gap
30//!    between two body frames exceeds the configured duration. Semantics
31//!    deliberately mirror the DW-014 response-side `write_ms` wrapper
32//!    ([`crate::dataplane::upstream::UpstreamBody`]): it is a GAP timeout, not a total
33//!    budget — a legitimate slow upload (large file over a slow link) that
34//!    keeps making progress never trips it; a client that sends headers and
35//!    then trickles body bytes forever to hold a concurrency slot and an
36//!    upstream connection is cut off after the gap. When the wrapper fires
37//!    the in-flight upstream attempt fails as a transport-class error (the
38//!    proxy answers 502 and closes), and the request's global concurrency
39//!    slot is released with the response.
40//!
41//! CL+TE request smuggling is NOT configured here because it needs no
42//! configuration: hyper 1.x's HTTP/1 parser rejects requests carrying both
43//! `Content-Length` and `Transfer-Encoding` (400/close) outright, and the
44//! gateway never passes raw bytes to an upstream — every forwarded request
45//! is rebuilt from hyper-parsed parts, so a smuggled "second request" would
46//! require hyper itself to mis-parse the first one. Both properties are
47//! pinned by the smuggling-corpus integration tests in dwara-bin.
48//!
49//! The pre-parse sniff's timeout is PER-READ, not a total head budget: a
50//! client that trickles one byte at a time, each inside the
51//! `DWARA_HTTP1_HEADER_TIMEOUT_MS` bound, keeps every individual sniff
52//! read successful, and the guard deliberately hands such connections to
53//! hyper instead of duplicating a head deadline (preferring handoff over
54//! racing hyper for the same bytes). The fallback defense for slow
55//! trickles is therefore hyper's PER-HEAD `header_read_timeout` — the
56//! same knob value, installed on the connection builder together with a
57//! `TokioTimer` — which bounds the total time one request head may take
58//! and closes the connection when it elapses. The two compose as: the
59//! sniff owns each individual read, hyper owns the head as a whole, so
60//! neither a stalled connection nor a slow trickle can hold a listener
61//! slot indefinitely.
62//!
63//! All knobs are read once from the environment at startup (invalid values
64//! fall back to the documented default) and are process-wide, not
65//! per-listener: hardening is a property of the parser, not of the route.
66
67use std::future::Future;
68use std::pin::Pin;
69use std::task::{Context, Poll};
70use std::time::Duration;
71
72use hyper::body::{Body, Bytes, Frame};
73use sha2::{Digest, Sha256};
74use subtle::ConstantTimeEq as _;
75use tokio::io::AsyncWriteExt as _;
76
77/// Default HTTP/1 max header COUNT (hyper's own default, made explicit).
78pub const DEFAULT_HTTP1_MAX_HEADERS: usize = 100;
79/// Default HTTP/1 read-buffer cap, in KiB (hyper's is ~8 KiB growable;
80/// pinned to 64 KiB explicitly so the bound is a documented constant).
81pub const DEFAULT_HTTP1_MAX_BUF_KIB: usize = 64;
82/// Default slowloris header-read timeout.
83pub const DEFAULT_HTTP1_HEADER_TIMEOUT_MS: u64 = 10_000;
84/// Default HTTP/2 concurrent-stream cap (also advertised in SETTINGS).
85pub const DEFAULT_H2_MAX_CONCURRENT_STREAMS: u32 = 128;
86/// Default HTTP/2 initial per-stream window, in KiB (1 MiB).
87pub const DEFAULT_H2_STREAM_WINDOW_KIB: u32 = 1024;
88/// Default HTTP/2 initial connection window, in KiB (4 MiB).
89pub const DEFAULT_H2_CONNECTION_WINDOW_KIB: u32 = 4096;
90/// Default HTTP/2 per-connection send-buffer cap, in KiB (1 MiB).
91pub const DEFAULT_H2_MAX_SEND_BUF_KIB: usize = 1024;
92/// Default inbound request-body inactivity gap.
93pub const DEFAULT_REQUEST_BODY_TIMEOUT_MS: u64 = 30_000;
94
95fn env_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
96    std::env::var(name)
97        .ok()
98        .and_then(|v| v.parse().ok())
99        .unwrap_or(default)
100}
101
102/// Resolved hardening configuration (see the module docs for the knob
103/// table, defaults, and the attack each bound addresses).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct HttpHardening {
106    /// HTTP/1 max header count per request/section.
107    pub http1_max_headers: usize,
108    /// HTTP/1 read-buffer cap, in BYTES.
109    pub http1_max_buf_size: usize,
110    /// HTTP/1 header-read (slowloris) timeout.
111    pub http1_header_read_timeout: Duration,
112    /// HTTP/2 max concurrent streams (enforced and advertised).
113    pub h2_max_concurrent_streams: u32,
114    /// HTTP/2 initial per-stream flow-control window, in BYTES.
115    pub h2_initial_stream_window: u32,
116    /// HTTP/2 initial connection flow-control window, in BYTES.
117    pub h2_initial_connection_window: u32,
118    /// HTTP/2 per-connection send-buffer cap, in BYTES.
119    pub h2_max_send_buf_size: usize,
120    /// Inbound request-body inactivity gap; None disables the wrapper
121    /// (the body then streams through untouched).
122    pub request_body_gap: Option<Duration>,
123}
124
125impl Default for HttpHardening {
126    fn default() -> Self {
127        Self {
128            http1_max_headers: DEFAULT_HTTP1_MAX_HEADERS,
129            http1_max_buf_size: DEFAULT_HTTP1_MAX_BUF_KIB * 1024,
130            http1_header_read_timeout: Duration::from_millis(DEFAULT_HTTP1_HEADER_TIMEOUT_MS),
131            h2_max_concurrent_streams: DEFAULT_H2_MAX_CONCURRENT_STREAMS,
132            h2_initial_stream_window: DEFAULT_H2_STREAM_WINDOW_KIB * 1024,
133            h2_initial_connection_window: DEFAULT_H2_CONNECTION_WINDOW_KIB * 1024,
134            h2_max_send_buf_size: DEFAULT_H2_MAX_SEND_BUF_KIB * 1024,
135            request_body_gap: Some(Duration::from_millis(DEFAULT_REQUEST_BODY_TIMEOUT_MS)),
136        }
137    }
138}
139
140impl HttpHardening {
141    /// Read the knob set from the environment; invalid or absent values
142    /// fall back to the documented defaults.
143    pub fn from_env() -> Self {
144        let mut h = Self::default();
145        h.http1_max_headers = env_parse("DWARA_HTTP1_MAX_HEADERS", h.http1_max_headers);
146        h.http1_max_buf_size =
147            env_parse("DWARA_HTTP1_MAX_BUF_KIB", DEFAULT_HTTP1_MAX_BUF_KIB) * 1024;
148        h.http1_header_read_timeout = Duration::from_millis(env_parse(
149            "DWARA_HTTP1_HEADER_TIMEOUT_MS",
150            DEFAULT_HTTP1_HEADER_TIMEOUT_MS,
151        ));
152        h.h2_max_concurrent_streams = env_parse(
153            "DWARA_H2_MAX_CONCURRENT_STREAMS",
154            h.h2_max_concurrent_streams,
155        );
156        h.h2_initial_stream_window =
157            env_parse("DWARA_H2_STREAM_WINDOW_KIB", DEFAULT_H2_STREAM_WINDOW_KIB) * 1024;
158        h.h2_initial_connection_window = env_parse(
159            "DWARA_H2_CONNECTION_WINDOW_KIB",
160            DEFAULT_H2_CONNECTION_WINDOW_KIB,
161        ) * 1024;
162        h.h2_max_send_buf_size =
163            env_parse("DWARA_H2_MAX_SEND_BUF_KIB", DEFAULT_H2_MAX_SEND_BUF_KIB) * 1024;
164        h.request_body_gap = match env_parse(
165            "DWARA_REQUEST_BODY_TIMEOUT_MS",
166            DEFAULT_REQUEST_BODY_TIMEOUT_MS,
167        ) {
168            0 => None,
169            ms => Some(Duration::from_millis(ms)),
170        };
171        h
172    }
173
174    /// Apply the connection-level bounds to a hyper-util auto builder
175    /// (h1 + h2). Used by every serving surface: data-plane listeners and
176    /// the admin listener share one hardening posture.
177    pub fn apply<E>(&self, builder: &mut hyper_util::server::conn::auto::Builder<E>) {
178        builder
179            .http1()
180            .max_headers(self.http1_max_headers)
181            .max_buf_size(self.http1_max_buf_size)
182            .header_read_timeout(self.http1_header_read_timeout)
183            // header_read_timeout is inert without a timer; hyper's default
184            // is a never-firing no-op.
185            .timer(hyper_util::rt::TokioTimer::new());
186        builder
187            .http2()
188            .max_concurrent_streams(self.h2_max_concurrent_streams)
189            .initial_stream_window_size(self.h2_initial_stream_window)
190            .initial_connection_window_size(self.h2_initial_connection_window)
191            .max_send_buf_size(self.h2_max_send_buf_size);
192    }
193
194    /// Apply per-listener HTTP/2 overrides (PERF-11, #244). Called
195    /// after `apply` to override the process-wide h2 defaults with
196    /// listener-specific values. Only the fields present in the
197    /// config are overridden; absent fields keep the process default.
198    pub fn apply_listener_h2<E>(
199        &self,
200        builder: &mut hyper_util::server::conn::auto::Builder<E>,
201        config: &crate::config::Http2ListenerConfig,
202    ) {
203        let mut h2 = builder.http2();
204        if let Some(stream_window) = config.initial_stream_window_size {
205            h2.initial_stream_window_size(stream_window);
206        }
207        if let Some(conn_window) = config.initial_connection_window_size {
208            h2.initial_connection_window_size(conn_window);
209        }
210        if config.adaptive_window {
211            h2.adaptive_window(true);
212        }
213        if let Some(max_streams) = config.max_concurrent_streams {
214            h2.max_concurrent_streams(max_streams);
215        }
216    }
217
218    /// Wrap an inbound request body with the inactivity-gap timeout. A thin
219    /// passthrough when the knob is disabled.
220    pub fn wrap_request_body<B>(&self, body: B) -> InboundBody<B>
221    where
222        B: Body<Data = Bytes>,
223        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
224    {
225        InboundBody {
226            inner: Box::pin(body),
227            gap: self.request_body_gap,
228            sleep: None,
229        }
230    }
231
232    /// Pre-parse smuggling guard for one connection (DW-023). Reads ahead
233    /// just enough of the first request head to apply the CL+TE rejection
234    /// policy BEFORE hyper normalizes the head away (hyper's HTTP/1 parser
235    /// gives Transfer-Encoding precedence and DROPS the Content-Length
236    /// header, which is desync-safe behind this gateway but erases the
237    /// evidence an explicit rejection needs). Returns the stream to hand
238    /// to the connection builder (with the sniffed bytes replayed in
239    /// front), or None when the connection was rejected and answered.
240    ///
241    /// Behavior:
242    /// - Bytes beginning with `PRI` (the h2c preface) pass through
243    ///   untouched the moment the first bytes identify it — h2 has its own
244    ///   framing rules (Transfer-Encoding is forbidden there; hyper
245    ///   rejects it) and needs no h1 head sniff.
246    /// - A first HTTP/1 head carrying BOTH a Content-Length and a
247    ///   Transfer-Encoding header (any order) is answered with a bare
248    ///   `400 Bad Request` + close and never reaches the dataplane.
249    /// - A head containing an obs-fold (RFC 7230 3.2.4 obsolete line
250    ///   folding: any header line starting with SP/HTAB) is likewise
251    ///   answered `400` + close. Folding can split a header NAME across
252    ///   lines (`Transfer-\r\n Encoding: chunked`), which the
253    ///   name-anchored CL+TE scan below cannot see — and obs-fold is
254    ///   illegal in HTTP/1.1 requests, which hyper also rejects, so
255    ///   refusing it at the sniff is strictly aligned with the parser,
256    ///   one layer earlier.
257    /// - The head terminator is the FIRST blank line under either
258    ///   line-ending convention hyper tolerates (`\r\n\r\n` for RFC-strict
259    ///   clients, `\n\n` and the mixed forms for bare-LF clients): a
260    ///   legal LF-only client's body must not be over-buffered into the
261    ///   sniff (spurious 431).
262    /// - A head exceeding the HTTP/1 read-buffer cap is likewise refused.
263    /// - The sniff's read bound is per-read (`http1_header_read_timeout`
264    ///   applied to each individual read, see the module docs): a slow
265    ///   starter simply falls through to hyper, whose per-head slowloris
266    ///   timeout governs from there.
267    /// - Documented limitation: only the FIRST request head on a
268    ///   connection is inspected; later keep-alive requests rely on
269    ///   hyper's framing itself, which cannot desync behind this gateway
270    ///   (every forwarded request is rebuilt from parsed parts — there is
271    ///   no raw-passthrough path).
272    pub async fn guard_connection<S>(&self, mut stream: S) -> Option<PrefixedStream<S>>
273    where
274        S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
275    {
276        use tokio::io::AsyncReadExt as _;
277
278        let mut sniffed: Vec<u8> = Vec::with_capacity(1024);
279        let mut chunk = [0u8; 4096];
280        loop {
281            if sniffed.len() >= 3 && &sniffed[..3] == b"PRI" {
282                // h2c prior-knowledge connection: not an HTTP/1 head.
283                return Some(PrefixedStream {
284                    inner: stream,
285                    prefix: sniffed,
286                    pos: 0,
287                });
288            }
289            if head_end(&sniffed).is_some() {
290                if head_has_obs_fold(&sniffed) {
291                    let _ = stream
292                        .write_all(
293                            b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
294                        )
295                        .await;
296                    tracing::warn!(
297                        code = "request_head_obs_fold",
298                        "first request head uses obsolete line folding; connection rejected"
299                    );
300                    return None;
301                }
302                if head_is_ambiguous(&sniffed) {
303                    let _ = stream
304                        .write_all(
305                            b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
306                        )
307                        .await;
308                    tracing::warn!(
309                        code = "request_framing_ambiguous",
310                        "first request head carries both Content-Length and Transfer-Encoding; connection rejected"
311                    );
312                    return None;
313                }
314                return Some(PrefixedStream {
315                    inner: stream,
316                    prefix: sniffed,
317                    pos: 0,
318                });
319            }
320            if sniffed.len() > self.http1_max_buf_size {
321                let _ = stream
322                    .write_all(
323                        b"HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
324                    )
325                    .await;
326                return None;
327            }
328            let read = stream.read(&mut chunk);
329            let read = match tokio::time::timeout(self.http1_header_read_timeout, read).await {
330                Ok(r) => r,
331                Err(_elapsed) => {
332                    // Slow starter: hand what we have to hyper; its own
333                    // header timeout governs from here.
334                    return Some(PrefixedStream {
335                        inner: stream,
336                        prefix: sniffed,
337                        pos: 0,
338                    });
339                }
340            };
341            match read {
342                Ok(0) | Err(_) => {
343                    // EOF or error: nothing to guard; hyper reports it.
344                    return Some(PrefixedStream {
345                        inner: stream,
346                        prefix: sniffed,
347                        pos: 0,
348                    });
349                }
350                Ok(n) => sniffed.extend_from_slice(&chunk[..n]),
351            }
352        }
353    }
354}
355
356/// Length of the request head INCLUDING its terminating blank line, or
357/// None while the head is still incomplete. The terminator is the FIRST
358/// blank line under either line-ending convention hyper's tolerant h1
359/// parsing accepts: RFC-strict CRLF heads end with `\r\n\r\n`, bare-LF
360/// heads with `\n\n`, and mixed heads with `\r\n\n` or `\n\r\n` (a line
361/// break followed by a blank line — equivalently: the first `\n` that is
362/// itself followed by `\n` or `\r\n`; in any legal head such a byte can
363/// only be the last line break before the blank line, since a header
364/// name can never begin with CR or LF). Detecting the first of these
365/// matters twice: the sniff loop must STOP there, so a bare-LF client's
366/// body is never over-buffered into the sniff (spurious 431), and the
367/// CL+TE scan below must not look PAST it, since body bytes are not
368/// headers.
369#[doc(hidden)]
370pub fn head_end(head: &[u8]) -> Option<usize> {
371    for (i, b) in head.iter().enumerate() {
372        if *b != b'\n' {
373            continue;
374        }
375        match (head.get(i + 1), head.get(i + 2)) {
376            (Some(b'\n'), _) => return Some(i + 2),
377            (Some(b'\r'), Some(b'\n')) => return Some(i + 3),
378            _ => {}
379        }
380    }
381    None
382}
383
384/// Does this (sniffed) HTTP/1 request head carry BOTH a Content-Length and
385/// a Transfer-Encoding header? Names compare case-insensitively; the head
386/// ends at the first blank line ([`head_end`], either line-ending
387/// convention) — bytes AFTER it are BODY (a payload that legitimately
388/// contains the strings "Content-Length"/"Transfer-Encoding" must not
389/// trip the guard) and are not inspected.
390#[doc(hidden)]
391pub fn head_is_ambiguous(head: &[u8]) -> bool {
392    let head = match head_end(head) {
393        Some(end) => &head[..end],
394        None => head,
395    };
396    let mut has_cl = false;
397    let mut has_te = false;
398    for line in head.split(|b| *b == b'\n') {
399        let line = line.strip_suffix(b"\r").unwrap_or(line);
400        let Some(colon) = line.iter().position(|b| *b == b':') else {
401            continue;
402        };
403        let name = &line[..colon];
404        if name.eq_ignore_ascii_case(b"content-length") {
405            has_cl = true;
406        } else if name.eq_ignore_ascii_case(b"transfer-encoding") {
407            has_te = true;
408        }
409    }
410    has_cl && has_te
411}
412
413/// Does the (sniffed) HTTP/1 request head contain an obs-fold
414/// continuation line — any line AFTER the request line starting with SP
415/// or HTAB (RFC 7230 3.2.4 obsolete line folding)? A folded head can
416/// split a header NAME across lines (`Transfer-\r\n Encoding: chunked`),
417/// slipping the CL+TE pair past [`head_is_ambiguous`]'s name-anchored
418/// scan. Obs-fold is illegal in HTTP/1.1 requests and hyper rejects it
419/// too, so the sniff rejects it early with a 400 — strictly aligned with
420/// the parser, one layer earlier. The request line itself is skipped:
421/// leading whitespace there is a parse error hyper already owns.
422#[doc(hidden)]
423pub fn head_has_obs_fold(head: &[u8]) -> bool {
424    let end = head_end(head).unwrap_or(head.len());
425    head[..end]
426        .split(|b| *b == b'\n')
427        .skip(1)
428        .any(|line| matches!(line.first(), Some(b' ') | Some(b'\t')))
429}
430
431/// A stream that replays `prefix` (bytes already sniffed off the wire)
432/// before forwarding to the inner stream. IO traits only delegate.
433pub struct PrefixedStream<S> {
434    inner: S,
435    prefix: Vec<u8>,
436    pos: usize,
437}
438
439impl<S> PrefixedStream<S> {
440    /// Wrap `inner`, replaying `prefix` first. Used by the smuggling
441    /// sniff (bytes read ahead of the parser) and the PROXY protocol
442    /// phase (bytes read past the header, DW-030 — a sender pipelining
443    /// TLS records or the HTTP head behind the PROXY line).
444    pub fn new(inner: S, prefix: Vec<u8>) -> Self {
445        PrefixedStream {
446            inner,
447            prefix,
448            pos: 0,
449        }
450    }
451}
452
453impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PrefixedStream<S> {
454    fn poll_read(
455        self: Pin<&mut Self>,
456        cx: &mut Context<'_>,
457        buf: &mut tokio::io::ReadBuf<'_>,
458    ) -> Poll<std::io::Result<()>> {
459        let this = self.get_mut();
460        if this.pos < this.prefix.len() {
461            let remaining = &this.prefix[this.pos..];
462            let n = remaining.len().min(buf.remaining());
463            buf.put_slice(&remaining[..n]);
464            this.pos += n;
465            return Poll::Ready(Ok(()));
466        }
467        Pin::new(&mut this.inner).poll_read(cx, buf)
468    }
469}
470
471impl<S: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for PrefixedStream<S> {
472    fn poll_write(
473        self: Pin<&mut Self>,
474        cx: &mut Context<'_>,
475        buf: &[u8],
476    ) -> Poll<std::io::Result<usize>> {
477        Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
478    }
479
480    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
481        Pin::new(&mut self.get_mut().inner).poll_flush(cx)
482    }
483
484    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
485        Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
486    }
487}
488
489/// Error of the inbound request-body wrapper.
490#[derive(Debug)]
491pub enum InboundBodyError {
492    /// The gap between two body frames exceeded the configured duration
493    /// (slow-body defense; see the module docs).
494    Timeout { after: Duration },
495    /// The underlying body stream errored.
496    Inner(Box<dyn std::error::Error + Send + Sync>),
497}
498
499impl std::fmt::Display for InboundBodyError {
500    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501        match self {
502            InboundBodyError::Timeout { after } => {
503                write!(f, "request body stalled for more than {after:?}")
504            }
505            InboundBodyError::Inner(e) => write!(f, "request body failed: {e}"),
506        }
507    }
508}
509
510impl std::error::Error for InboundBodyError {}
511
512/// Inbound request body with an inactivity-gap timeout (the request-side
513/// mirror of [`crate::dataplane::upstream::UpstreamBody`]). A `tokio::time::Sleep` is
514/// armed whenever the stream is idle-Pending and cleared on every frame,
515/// so the timeout bounds GAPS, not total streaming time. When it fires the
516/// error propagates through the dataplane's attempt machinery: the in-flight
517/// upstream send fails as a transport-class error, the client receives a
518/// classified 5xx, and the request's concurrency slot is released.
519pub struct InboundBody<B> {
520    inner: Pin<Box<B>>,
521    gap: Option<Duration>,
522    sleep: Option<Pin<Box<tokio::time::Sleep>>>,
523}
524
525impl<B> Body for InboundBody<B>
526where
527    B: Body<Data = Bytes>,
528    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
529{
530    type Data = Bytes;
531    type Error = InboundBodyError;
532
533    fn poll_frame(
534        self: Pin<&mut Self>,
535        cx: &mut Context<'_>,
536    ) -> Poll<Option<Result<Frame<Bytes>, InboundBodyError>>> {
537        let this = self.get_mut();
538        loop {
539            if let Some(sleep) = &mut this.sleep {
540                if sleep.as_mut().poll(cx).is_ready() {
541                    return Poll::Ready(Some(Err(InboundBodyError::Timeout {
542                        after: this.gap.unwrap_or_default(),
543                    })));
544                }
545            }
546            match this.inner.as_mut().poll_frame(cx) {
547                Poll::Ready(Some(Ok(frame))) => {
548                    this.sleep = None;
549                    return Poll::Ready(Some(Ok(frame)));
550                }
551                Poll::Ready(Some(Err(e))) => {
552                    return Poll::Ready(Some(Err(InboundBodyError::Inner(e.into()))))
553                }
554                Poll::Ready(None) => return Poll::Ready(None),
555                Poll::Pending => {
556                    if this.gap.is_some() && this.sleep.is_none() {
557                        this.sleep =
558                            Some(Box::pin(tokio::time::sleep(this.gap.unwrap_or_default())));
559                        continue;
560                    }
561                    return Poll::Pending;
562                }
563            }
564        }
565    }
566
567    fn size_hint(&self) -> hyper::body::SizeHint {
568        self.inner.size_hint()
569    }
570
571    fn is_end_stream(&self) -> bool {
572        self.inner.is_end_stream()
573    }
574}
575
576impl<B> std::fmt::Debug for InboundBody<B> {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        f.debug_struct("InboundBody")
579            .field("gap_timeout", &self.gap)
580            .finish_non_exhaustive()
581    }
582}
583
584/// Merge one token into a response's `Vary` header (DW-027): appended to
585/// the existing comma-separated list when not already present
586/// (case-insensitive), created otherwise. RFC 9110 permits MULTIPLE
587/// `Vary` field lines; every line is folded into the membership check
588/// and the merged value, and the result replaces the set with one line —
589/// reading only the first line would drop the later lines' tokens and
590/// corrupt cache keys for upstreams that split their `Vary`. Correct
591/// `Vary` is what keeps a shared cache from serving a compressed (or
592/// origin-specific) response to a client that could not have received
593/// it.
594pub fn merge_vary(headers: &mut hyper::HeaderMap, token: &str) {
595    let mut present = false;
596    let mut merged = String::new();
597    for value in headers.get_all(hyper::header::VARY) {
598        let Ok(existing) = value.to_str() else {
599            continue;
600        };
601        if existing
602            .split(',')
603            .any(|t| t.trim().eq_ignore_ascii_case(token))
604        {
605            present = true;
606        }
607        if !merged.is_empty() {
608            merged.push_str(", ");
609        }
610        merged.push_str(existing);
611    }
612    if present {
613        return;
614    }
615    if merged.is_empty() {
616        merged.push_str(token);
617    } else {
618        merged.push_str(", ");
619        merged.push_str(token);
620    }
621    if let Ok(v) = hyper::header::HeaderValue::from_str(&merged) {
622        headers.insert(hyper::header::VARY, v);
623    }
624}
625
626/// One route-scoped request-limit violation (DW-027): which cap was
627/// crossed, by what observed value. The proxy turns these into the 431
628/// (headers) / 413 (body) error-envelope responses.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum RouteLimitViolation {
631    /// More header fields than `max_header_count`.
632    HeaderCount { count: usize, max: u32 },
633    /// Header name+value bytes sum above `max_header_bytes`.
634    HeaderBytes { bytes: u64, max: u64 },
635    /// A `Content-Length` body declared above `max_body_bytes` (rejected
636    /// before any upstream contact).
637    BodyBytes { declared: u64, max: u64 },
638}
639
640/// Check a request against a route's [`crate::config::RequestLimits`] (DW-027), right
641/// after route resolution. `content_length` is the request body's exact
642/// size when declared (`size_hint().exact()` — Content-Length for h1,
643/// the content-length pseudo-header for h2), `None` when the length is
644/// unknown (streaming). Header checks are header-count first (cheapest
645/// to report), then header bytes, then the declared body size.
646pub fn check_route_limits(
647    limits: &crate::config::RequestLimits,
648    headers: &hyper::HeaderMap,
649    content_length: Option<u64>,
650) -> Option<RouteLimitViolation> {
651    if let Some(max) = limits.max_header_count {
652        // FIELD LINES, not distinct names (the same reading as hyper's
653        // own max-headers parser bound): a header sent twice counts
654        // twice.
655        let count = headers.iter().count();
656        if count > max as usize {
657            return Some(RouteLimitViolation::HeaderCount { count, max });
658        }
659    }
660    if let Some(max) = limits.max_header_bytes {
661        let bytes: u64 = headers
662            .iter()
663            .map(|(name, value)| (name.as_str().len() + value.len()) as u64)
664            .sum();
665        if bytes > max {
666            return Some(RouteLimitViolation::HeaderBytes { bytes, max });
667        }
668    }
669    if let (Some(max), Some(declared)) = (limits.max_body_bytes, content_length) {
670        if declared > max {
671            return Some(RouteLimitViolation::BodyBytes { declared, max });
672        }
673    }
674    None
675}
676
677/// Error of the route-limited request body (DW-027).
678#[derive(Debug)]
679pub enum LimitedBodyError {
680    /// The streamed body crossed the route's `max_body_bytes` cap.
681    OverLimit { cap: u64 },
682    /// The underlying body stream errored.
683    Inner(Box<dyn std::error::Error + Send + Sync>),
684}
685
686impl std::fmt::Display for LimitedBodyError {
687    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
688        match self {
689            LimitedBodyError::OverLimit { cap } => {
690                write!(f, "request body crossed the route limit of {cap} bytes")
691            }
692            LimitedBodyError::Inner(e) => write!(f, "request body failed: {e}"),
693        }
694    }
695}
696
697impl std::error::Error for LimitedBodyError {}
698
699/// Request body wrapped with a route's `max_body_bytes` cap (DW-027):
700/// counts streamed data frames and errors with
701/// [`LimitedBodyError::OverLimit`] the moment the running total crosses
702/// the cap — the streaming half of the body limit, for requests whose
703/// length is not declared up front (chunked, h2 without content-length).
704/// The error propagates through the dataplane's attempt machinery; the
705/// proxy recognizes it in the failure's source chain and answers 413
706/// (see `proxy::request_limit_exceeded`). `None` cap = thin passthrough
707/// (the same shape [`InboundBody`] takes with no gap configured), so the
708/// proxy can wrap unconditionally for proxied actions.
709pub struct LimitedBody<B> {
710    inner: Pin<Box<B>>,
711    seen: u64,
712    cap: Option<u64>,
713}
714
715impl<B> LimitedBody<B> {
716    pub fn new(inner: B, cap: Option<u64>) -> Self {
717        LimitedBody {
718            inner: Box::pin(inner),
719            seen: 0,
720            cap,
721        }
722    }
723}
724
725impl<B> Body for LimitedBody<B>
726where
727    B: Body<Data = Bytes>,
728    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
729{
730    type Data = Bytes;
731    type Error = LimitedBodyError;
732
733    fn poll_frame(
734        self: Pin<&mut Self>,
735        cx: &mut Context<'_>,
736    ) -> Poll<Option<Result<Frame<Bytes>, LimitedBodyError>>> {
737        let this = self.get_mut();
738        match this.inner.as_mut().poll_frame(cx) {
739            Poll::Ready(Some(Ok(frame))) => {
740                if let (Some(cap), Some(data)) = (this.cap, frame.data_ref()) {
741                    this.seen += data.len() as u64;
742                    if this.seen > cap {
743                        return Poll::Ready(Some(Err(LimitedBodyError::OverLimit { cap })));
744                    }
745                }
746                Poll::Ready(Some(Ok(frame)))
747            }
748            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(LimitedBodyError::Inner(e.into())))),
749            Poll::Ready(None) => Poll::Ready(None),
750            Poll::Pending => Poll::Pending,
751        }
752    }
753
754    fn size_hint(&self) -> hyper::body::SizeHint {
755        self.inner.size_hint()
756    }
757
758    fn is_end_stream(&self) -> bool {
759        self.inner.is_end_stream()
760    }
761}
762
763impl<B> std::fmt::Debug for LimitedBody<B> {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        f.debug_struct("LimitedBody")
766            .field("cap", &self.cap)
767            .field("seen", &self.seen)
768            .finish_non_exhaustive()
769    }
770}
771
772/// Error of the signed-request digesting body (DW-036).
773#[derive(Debug)]
774pub enum SignatureBodyError {
775    /// The streamed body's SHA-256 disagrees with the digest the
776    /// verified signature bound (`X-Dwara-Body-Sha256`): the request
777    /// body was tampered with (or truncated) in flight. The upstream
778    /// saw a truncated request — the wrapper aborts the stream at the
779    /// final frame — and the client receives 401 (the proxy recognizes
780    /// this marker in the failure's source chain, the
781    /// `request_limit_exceeded` precedent).
782    DigestMismatch,
783    /// The underlying body stream errored.
784    Inner(Box<dyn std::error::Error + Send + Sync>),
785}
786
787impl std::fmt::Display for SignatureBodyError {
788    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        match self {
790            SignatureBodyError::DigestMismatch => {
791                write!(f, "request body does not match the signed digest")
792            }
793            SignatureBodyError::Inner(e) => write!(f, "request body failed: {e}"),
794        }
795    }
796}
797
798impl std::error::Error for SignatureBodyError {}
799
800/// Request body that enforces an HMAC-signed body digest while
801/// STREAMING (DW-036): every data frame is folded into an incremental
802/// SHA-256 — nothing is buffered, so a signed body of any size costs
803/// O(1) memory beyond the frame in flight — and the final digest is
804/// compared (constant-time, `subtle`) against the digest the verified
805/// signature bound at authn time (`Identity::body_digest`,
806/// `security::authn`). A mismatch surfaces as
807/// [`SignatureBodyError::DigestMismatch`], aborting the upstream send
808/// WITH the last frame withheld on declared-length bodies (the digest
809/// is final the moment the declared byte count is hashed) or at the
810/// terminating frame on unknown-length bodies: the tampered request
811/// never completes upstream and the proxy answers 401.
812///
813/// The hashed read is therefore exactly the forwarded read — and when
814/// the route caps bodies (`max_body_bytes`, DW-027), this wrapper sits
815/// INSIDE the route's [`LimitedBody`], so the cap still rejects
816/// oversized bodies first. Unsigned requests construct the wrapper
817/// with `None` (one uniform type on the proxy path): the `None` form
818/// is a strict passthrough — no hashing, and
819/// `is_end_stream`/`size_hint` delegate untouched. The `Some` form
820/// reports `is_end_stream: false` so the consumer always polls to the
821/// terminating frame (the last resort of the digest check — an
822/// end-stream short-circuit would skip verification for empty and
823/// chunked bodies; declared-length bodies additionally check at
824/// saturation, see `exact_hint`). The one shape the forwarding
825/// encoder never polls is a declared size of EXACTLY zero — hyper
826/// writes `Content-Length: 0` straight from the size hint and drops
827/// the stream without a single `poll_frame` — so `new` decides that
828/// case eagerly and the proxy consults
829/// [`DigestingBody::eager_digest_mismatch`] before forwarding: an
830/// empty signed body is verified against its signed digest like any
831/// other.
832pub struct DigestingBody<B> {
833    inner: Pin<Box<B>>,
834    hasher: Sha256,
835    expected: Option<[u8; 32]>,
836    /// Outcome of the digest check once computed, so a body polled
837    /// past its decision answers consistently.
838    finished: Option<bool>,
839    /// Data bytes hashed so far, and the inner body's EXACT size when
840    /// it declares one. A declared size lets the digest check fire the
841    /// moment the last declared byte is hashed — hyper's h1 encoder
842    /// stops polling a Content-Length body once the declared count is
843    /// written, so the end-of-stream poll below is NOT guaranteed to
844    /// run for length-delimited bodies (it is for chunked/unknown
845    /// length, where the terminating frame is the only end signal).
846    /// A declared size of zero never sees even one frame — `new`
847    /// decides that case outright.
848    seen: u64,
849    exact_hint: Option<u64>,
850}
851
852impl<B> DigestingBody<B>
853where
854    B: Body<Data = Bytes>,
855{
856    /// `expected` is the SIGNED digest (decoded from the presented
857    /// `X-Dwara-Body-Sha256` header) — public material, already
858    /// verified as MAC-covered at authn time. `None` wraps without
859    /// enforcing (see the struct docs).
860    pub fn new(inner: B, expected: Option<[u8; 32]>) -> Self {
861        let exact_hint = match expected {
862            Some(_) => inner.size_hint().exact(),
863            None => None,
864        };
865        let mut body = DigestingBody {
866            inner: Box::pin(inner),
867            hasher: Sha256::new(),
868            expected,
869            finished: None,
870            seen: 0,
871            exact_hint,
872        };
873        // Exact-zero saturation hoisted out of `poll_frame`: a body
874        // declaring EXACTLY zero bytes is never polled on the h1
875        // forwarding path (hyper writes `Content-Length: 0` from the
876        // size hint and drops the stream without a single poll), so
877        // the end-of-stream decision below would never run for it.
878        // The digest of zero bytes is final NOW — decide here, and
879        // any poll that does happen answers from the memoized
880        // verdict. The proxy consults `eager_digest_mismatch` to
881        // surface a mismatch as a 401 before forwarding.
882        if body.exact_hint == Some(0) {
883            body.digest_ok();
884        }
885        body
886    }
887
888    /// The digest verdict `new` could already decide without seeing a
889    /// frame (the wrapped body declared exact size 0, the one shape
890    /// the forwarding encoder never polls). The proxy MUST consult
891    /// this before forwarding an enforced body: a mismatch on a
892    /// zero-length body has no poll to surface through, so this is
893    /// its only signal — the same decision the streaming path
894    /// enforces in `poll_frame`.
895    pub fn eager_digest_mismatch(&self) -> bool {
896        self.finished == Some(false)
897    }
898
899    /// Run the digest decision: constant-time compare of the computed
900    /// hash against the signed digest, memoized into `finished`.
901    /// Returns false on mismatch.
902    fn digest_ok(&mut self) -> bool {
903        if let Some(verdict) = self.finished {
904            return verdict;
905        }
906        let Some(expected) = self.expected else {
907            self.finished = Some(true);
908            return true;
909        };
910        // `finalize` consumes the hasher; the decision is memoized, so
911        // the hasher is dead by construction — swap in a fresh one.
912        let hasher = std::mem::replace(&mut self.hasher, Sha256::new());
913        let computed: [u8; 32] = Digest::finalize(hasher).into();
914        let ok = bool::from(computed.ct_eq(&expected));
915        self.finished = Some(ok);
916        ok
917    }
918}
919
920impl<B> Body for DigestingBody<B>
921where
922    B: Body<Data = Bytes>,
923    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
924{
925    type Data = Bytes;
926    type Error = SignatureBodyError;
927
928    fn poll_frame(
929        self: Pin<&mut Self>,
930        cx: &mut Context<'_>,
931    ) -> Poll<Option<Result<Frame<Bytes>, SignatureBodyError>>> {
932        let this = self.get_mut();
933        match this.inner.as_mut().poll_frame(cx) {
934            Poll::Ready(Some(Ok(frame))) => {
935                if let (Some(_), Some(data)) = (&this.expected, frame.data_ref()) {
936                    Digest::update(&mut this.hasher, data);
937                    this.seen += data.len() as u64;
938                    // Declared-size saturation: this frame carries the
939                    // last declared byte, so the digest is final NOW.
940                    // On mismatch the frame is WITHHELD (never reaches
941                    // the upstream) and the stream aborts.
942                    if this.exact_hint.is_some_and(|n| this.seen >= n) && !this.digest_ok() {
943                        return Poll::Ready(Some(Err(SignatureBodyError::DigestMismatch)));
944                    }
945                }
946                Poll::Ready(Some(Ok(frame)))
947            }
948            Poll::Ready(Some(Err(e))) => {
949                Poll::Ready(Some(Err(SignatureBodyError::Inner(e.into()))))
950            }
951            Poll::Ready(None) => {
952                if this.digest_ok() {
953                    Poll::Ready(None)
954                } else {
955                    Poll::Ready(Some(Err(SignatureBodyError::DigestMismatch)))
956                }
957            }
958            Poll::Pending => Poll::Pending,
959        }
960    }
961
962    fn size_hint(&self) -> hyper::body::SizeHint {
963        self.inner.size_hint()
964    }
965
966    fn is_end_stream(&self) -> bool {
967        // Passthrough when unsigned; when a digest must be enforced the
968        // terminating poll_frame carries the check, so never short-
969        // circuit (empty bodies included).
970        self.expected.is_none() && self.inner.is_end_stream()
971    }
972}
973
974impl<B> std::fmt::Debug for DigestingBody<B> {
975    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
976        f.debug_struct("DigestingBody")
977            .field("expected", &"[signed digest]")
978            .finish_non_exhaustive()
979    }
980}