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}