sozu_lib/protocol/mux/h2_flood_detector.rs
1//! H2 flood/abuse detection for [`super::h2::ConnectionH2`] — the CVE-2023-44487
2//! (Rapid Reset), CVE-2024-27316 (CONTINUATION flood) and CVE-2025-8671
3//! ("MadeYouReset") mitigations, plus the PING/SETTINGS/empty-DATA/stream-0
4//! WINDOW_UPDATE rate limits and the general glitch counter.
5//!
6//! Groups [`H2FloodConfig`] (the CVE-tagged thresholds), [`H2FloodViolation`]
7//! (what a tripped threshold reports back) and [`H2FloodDetector`] (the
8//! counters themselves) behind the same closed-API shape
9//! [`super::hpack_state::HpackState`], [`super::h2_flow_control::H2FlowControl`]
10//! and [`super::h2_stream_table::H2StreamTable`] established in the three
11//! prior extraction steps. Every `H2FloodDetector` field is private to this
12//! module; `ConnectionH2` reaches them only through the accessor/mutator
13//! methods declared below — `check_flood_or_return!` and the per-frame
14//! `record_*` calls in `h2.rs` are the only way counters move.
15//!
16//! `H2FloodConfig` follows the same "closed API" shape: its fields are
17//! private and [`H2FloodConfig::new`] is the only way to build one, even
18//! though the type itself is `pub` because `lib/src/http.rs` and
19//! `lib/src/https.rs` hand it their listener configuration.
20//! `MAX_HEADER_LIST_SIZE` (the compile-time default for
21//! `H2FloodConfig::max_header_list_size`) stays declared in `h2.rs` for the
22//! same reason in reverse: `converter.rs` and `pkawa.rs` reference it
23//! directly as a general HPACK encode/decode safety ceiling, independent of
24//! any one connection's configured value, so moving it would widen this
25//! extraction's blast radius into two unrelated modules for no benefit. This
26//! module reaches it via `super::h2::MAX_HEADER_LIST_SIZE`, same as
27//! `converter.rs`/`pkawa.rs` do.
28//!
29//! The fields were `pub` until sozu-proxy/sozu#1418, and that is exactly what
30//! went wrong: `lib/src/http.rs`/`lib/src/https.rs` built `H2FloodConfig` with
31//! a raw struct literal and never called `H2FloodConfig::new`, so the `.max(1)`
32//! clamp below was bypassed on the only production path that carries
33//! operator-configured thresholds — only `Default::default()`'s compile-time
34//! constants, which never needed validation, went through it. An operator
35//! setting a threshold to zero got it unclamped, and since `check_flood`
36//! compares `count > threshold`, the first event that counter saw was already
37//! a violation — for `max_header_list_size`/`max_header_fields`, which are
38//! also the HPACK decode budget, that is every request on the listener; for a
39//! per-window or lifetime frame counter, the first client that sends the frame
40//! it counts. Private fields make that literal a compile error instead of a
41//! comment, which is what the three sibling extractions
42//! (`hpack_state`, `h2_flow_control`, `h2_stream_table`) already did for their
43//! own invariants.
44//!
45//! This module owns exactly what `check_flood` polices:
46//!
47//! - Per-window rate counters (`rst_stream_count`, `ping_count`,
48//! `settings_count`, `empty_data_count`, `window_update_stream0_count`,
49//! `glitch_count`), half-decayed by [`H2FloodDetector::check_flood`] every
50//! [`FLOOD_WINDOW_DURATION`] rather than reset to zero, so a
51//! burst-then-wait attacker cannot escape by timing a pause exactly at the
52//! window edge.
53//! - Connection-lifetime RST counters (`total_rst_received_lifetime`,
54//! `total_abusive_rst_received_lifetime`, `total_rst_streams_emitted_lifetime`).
55//! Each trips only once it exceeds both its configured floor and a share of
56//! `streams_opened` — the streams a backend answered, see that field — so a
57//! long-lived connection never accumulates benign cancels toward a fixed
58//! ceiling. The two pre-response counters share one ratio — more resets
59//! before a response than `streams_opened`, i.e. more than half of all
60//! backend-routed streams reset before their response — and cap a peer that stays
61//! under the per-window threshold forever. The received-RST counter's ratio
62//! counts the resets outside the pre-response count — after a response
63//! started, or of a stream already gone — against `streams_opened`; a
64//! stream reset after its response is counted in `streams_opened` before
65//! the check, so cancelling answered streams never exceeds it.
66//! - Never-decaying PING and SETTINGS lifetime ceilings
67//! (`total_ping_received_lifetime`, `total_settings_received_lifetime`).
68//! - The stream-0 WINDOW_UPDATE credit (`window_update_stream0_credit`): each
69//! DATA frame the proxy sends entitles the peer to two connection-level
70//! WINDOW_UPDATEs that are not counted, so only unsolicited ones reach the
71//! per-window threshold.
72//! - The per-header-block CONTINUATION accounting (`continuation_count`,
73//! `accumulated_header_size`), reset by [`H2FloodDetector::reset_continuation`]
74//! once a block completes.
75//! - The soft stream-refusal state ([`H2FloodDetector::refuses_new_streams`]):
76//! before the pre-response cap trips, the connection refuses new client
77//! streams with `REFUSED_STREAM` (RFC 9113 §8.7) and keeps serving the open
78//! ones. It reads the counters above and moves none of them.
79//!
80//! ## Clock: the last self-sampling exception is gone
81//!
82//! Before this extraction, `impl Default for H2FloodDetector` called
83//! `Instant::now()` — documented as test-only (LIFECYCLE.md invariant 20) and
84//! verified so here too: the sole production constructor is
85//! `ConnectionH2::new`, which calls `H2FloodDetector::new(flood_config, now)`
86//! with its own single accept-time sample, and `Default` was reachable from
87//! nowhere else (`grep -rn 'H2FloodDetector::default\|H2FloodDetector::new'`
88//! outside `h2.rs`/`h2_flood_detector.rs` returns nothing; every `default()`
89//! call site was inside `h2.rs`'s `#[cfg(test)] mod tests`, `h2.rs:6946`
90//! onward at the pre-extraction revision).
91//!
92//! A test can pass a clock as easily as inherit one, and every test that used
93//! `H2FloodDetector::default()` only needed *a* instant, never a specific
94//! one — so this extraction removes `impl Default` entirely rather than
95//! moving it. Every former `H2FloodDetector::default()` call site below now
96//! reads `H2FloodDetector::new(H2FloodConfig::default(), Instant::now())`,
97//! and the old test comparing default-constructed vs. explicitly-constructed
98//! detectors — whose entire premise was comparing `default()` against
99//! `new()` — is deleted along with it, rather than adapted, because there is
100//! no longer a second constructor for it to compare against. This closes
101//! LIFECYCLE.md invariant 20's one
102//! remaining exception: the only code under `ConnectionH2` that ever samples
103//! the clock is now `ConnectionH2::new` itself.
104//!
105//! ## Determinism — there is none to fix, because there is no collection
106//!
107//! Enumerated by reading every field, not by grepping `.iter()/.keys()/
108//! .values()/.drain()` (which is what missed the two leaks the prior two
109//! extractions each found — both were a bare `for (&k, &v) in &map` loop).
110//! `H2FloodDetector` holds no map, set, or `Vec` at all: every field is a
111//! scalar counter (`u32`/`u64`), a `Copy` config struct, or a single
112//! `Instant`. `check_flood`'s `.or_else()` chain — which counter is checked,
113//! and therefore which violation is reported first when several are
114//! simultaneously over threshold — is a fixed sequence written directly into
115//! the source text, not an iteration over anything whose order could vary
116//! between two runs of the same binary. There is nothing here for a
117//! `BTreeMap` conversion to fix, unlike `h2_flow_control::H2FlowControl`'s
118//! `pending_window_updates` or `h2_stream_table::H2StreamTable`'s
119//! `stream_last_activity_at`/`stream_fc_stalled_since`.
120//!
121//! ## Security counters — every increment is an unmodified relocation
122//!
123//! This module implements the CVE-2023-44487 (Rapid Reset), CVE-2024-27316
124//! (CONTINUATION flood) and CVE-2025-8671 (MadeYouReset) mitigations, plus
125//! the PING/SETTINGS/empty-DATA/stream-0-WINDOW_UPDATE rate limits. Every
126//! counter's increment operator moved verbatim — same `+=` vs
127//! `.saturating_add(...)` choice per field as before this extraction, not
128//! normalized to one or the other — and every threshold comparison in
129//! `check_flood` is character-for-character the same `count > threshold`
130//! check against the same field and the same [`H2FloodConfig`] value. (Two
131//! later, deliberate changes depart from that relocation: the three RST caps
132//! compare against the streams a backend answered as well as their floor, and stream-0
133//! WINDOW_UPDATEs answering sent DATA spend credit instead of counting.) The
134//! `record_*` methods below (`record_glitch`, `record_continuation_frame`,
135//! `record_empty_data_frame`, `record_rst_stream_window`,
136//! `record_settings_frame`, `record_ping_frame`,
137//! `record_window_update_stream0`) are new only in the sense that they give
138//! the previously-inline `self.flood_detector.<field> += 1` (or
139//! `.saturating_add`) sequences a name; the arithmetic inside each is the
140//! same statement that used to sit at the `h2.rs` call site, now paired with
141//! the same before/after `debug_assert!` that call site carried (or, for
142//! `record_glitch`'s five call sites, a new symmetric one — those five
143//! previously had no before/after assert at all, unlike every sibling
144//! counter; adding it only makes `record_glitch` consistent with its
145//! siblings and cannot change release behaviour, since `debug_assert!`
146//! compiles to nothing outside debug/test builds).
147//! `H2FloodConfig::new`'s `.max(1)` clamp (every threshold is at least 1, so
148//! a zero-threshold misconfiguration cannot make the detector trip on the
149//! very first frame) is unchanged in what it computes; sozu-proxy/sozu#1418
150//! only changed who reaches it, by making the constructor the sole entry.
151
152use std::time::Instant;
153
154use super::{h2::MAX_HEADER_LIST_SIZE, parser::H2Error};
155
156// ── Flood Detection Thresholds (CVE mitigations) ────────────────────────────
157
158/// Default maximum RST_STREAM frames per window (CVE-2023-44487 Rapid Reset +
159/// CVE-2019-9514).
160///
161/// The window half-decays, so a threshold `T` admits a burst of `T` and a
162/// sustained rate of about `T/2` per second. 2000 is above nghttp2's burst of
163/// 1000 (`NGHTTP2_DEFAULT_STREAM_RESET_BURST`, `lib/nghttp2_session.h`, rate
164/// 33/s): one browser tab closing cancels up to `h2_max_concurrent_streams`
165/// (100) streams at once, and several tabs share one coalesced connection.
166/// The Rapid Reset signature itself — resets before the response starts — is
167/// bounded much earlier by [`DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME`].
168const DEFAULT_MAX_RST_STREAM_PER_WINDOW: u32 = 2000;
169/// Floor of the connection-lifetime cap on RST_STREAM frames received
170/// (CVE-2023-44487 Rapid Reset).
171///
172/// The cap trips once the count exceeds this floor AND the resets received
173/// after a response started or on a stream already gone exceed the streams a
174/// backend answered on the connection (`H2FloodDetector`'s `streams_opened`).
175/// Pre-response resets are left to
176/// [`DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME`], and a stream reset after its
177/// response started counts as answered before the check, so a client that
178/// cancels answered streams never trips it.
179const DEFAULT_MAX_RST_STREAM_LIFETIME: u64 = 200_000;
180/// Floor of the cap on RST_STREAM frames received BEFORE the backend
181/// response started — the cheap-for-client / expensive-for-us resets that
182/// characterise Rapid Reset (CVE-2023-44487).
183///
184/// The cap trips once the count exceeds this floor AND pre-response resets
185/// outnumber the streams a backend answered on the connection — more than
186/// half of the backend-routed streams reset before their response: the shape of Envoy's premature-reset
187/// guard (`ConnectionManagerImpl::maybeDrainDueToPrematureResets`,
188/// `source/common/http/conn_manager_impl.cc`, which closes a connection once
189/// at least half of its streams, and at least 250 of 500, were reset
190/// prematurely). A browser that cancels prefetches or an SPA that aborts
191/// requests while navigating stays far below half; a Rapid Reset client,
192/// which resets every stream it opens, trips just past the floor.
193const DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME: u64 = 1000;
194/// Floor of the cap on **peer-provoked** RST_STREAM frames the server emits
195/// (CVE-2025-8671 — "MadeYouReset"). Distinct from
196/// [`DEFAULT_MAX_RST_STREAM_LIFETIME`] which caps *received* RSTs
197/// (CVE-2023-44487 Rapid Reset).
198///
199/// MadeYouReset has the server talk itself into flooding: the attacker sends
200/// legitimate-looking frames that force the server to emit RST_STREAM (content
201/// -length mismatch, header parse error, rejected priority, zero-increment
202/// `WINDOW_UPDATE` on an open stream, …). Each forced RST costs the server a
203/// header-decode, kawa buffer setup and frame serialisation; uncapped, it
204/// becomes the same class of DoS as Rapid Reset but with a flipped emission
205/// direction.
206///
207/// Only resets the peer provoked count — never one Sōzu decides on its own
208/// (idle reaper `CANCEL`, `REFUSED_STREAM` from its own concurrency limit or
209/// buffer pool, `STREAM_CLOSED` for DATA on a closed stream, converter
210/// `INTERNAL_ERROR` on a backend failure). The cap trips once the count
211/// exceeds this floor AND pre-response resets outnumber the streams a backend
212/// answered, the same shape as
213/// [`DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME`]. hyper's `h2` caps the same
214/// class at a flat 1024 per connection (`DEFAULT_LOCAL_RESET_COUNT_MAX`,
215/// `src/proto/mod.rs`); a MadeYouReset client provokes one reset per stream
216/// and trips here just past the floor.
217const DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME: u64 = 10_000;
218/// Default maximum PING frames per window (CVE-2019-9512 Ping Flood)
219const DEFAULT_MAX_PING_PER_WINDOW: u32 = 2000;
220/// Absolute lifetime cap on PING frames received on a single connection.
221/// Mirrors DEFAULT_MAX_RST_STREAM_LIFETIME — generous for legitimate
222/// keep-alives but trips on sustained low-rate abuse (CVE-2019-9512).
223const DEFAULT_MAX_PING_LIFETIME: u32 = 200_000;
224/// Default maximum SETTINGS frames per window (CVE-2019-9515 Settings Flood)
225const DEFAULT_MAX_SETTINGS_PER_WINDOW: u32 = 1000;
226/// Absolute lifetime cap on SETTINGS frames received on a single connection.
227/// Mirrors DEFAULT_MAX_RST_STREAM_LIFETIME — generous for legitimate
228/// renegotiations but trips on sustained low-rate abuse (CVE-2019-9515).
229const DEFAULT_MAX_SETTINGS_LIFETIME: u32 = 200_000;
230/// Default maximum empty DATA frames per window (CVE-2019-9518 Empty Frames)
231const DEFAULT_MAX_EMPTY_DATA_PER_WINDOW: u32 = 2000;
232/// Default maximum **unsolicited** connection-level (stream 0) WINDOW_UPDATE
233/// frames per sliding window — those not answering DATA the proxy sent.
234/// Non-zero stream-0 WINDOW_UPDATE frames are otherwise uncounted by the
235/// generic glitch detector — a peer could burn proxy CPU by sending millions
236/// of legal-looking stream-0 WINDOW_UPDATEs.
237///
238/// A peer returns connection credit as it consumes DATA, at whatever cadence
239/// it likes (RFC 9113 §6.9): one per DATA frame, one per half window, … A
240/// flat per-second count therefore trips on any download fast enough —
241/// a peer with a 64 KiB window acking every 32 KiB at a few MB/s, or a Sōzu
242/// in front acking every 8 MiB of its 16 MiB window on a fast link. Each DATA
243/// frame the proxy sends instead earns
244/// [`WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME`] stream-0 updates that are
245/// not counted; only the excess reaches this threshold. Envoy bounds the same
246/// frame the same way (`max_inbound_window_update_frames_per_data_frame_sent`,
247/// `api/envoy/config/core/v3/protocol.proto`).
248const DEFAULT_MAX_WINDOW_UPDATE_STREAM0_PER_WINDOW: u32 = 2000;
249/// Stream-0 WINDOW_UPDATE frames each DATA frame the proxy sends entitles the
250/// peer to without counting toward
251/// [`DEFAULT_MAX_WINDOW_UPDATE_STREAM0_PER_WINDOW`].
252///
253/// One is enough for a peer that acknowledges every frame; Envoy notes that
254/// implementations estimating bandwidth need at least two
255/// (`max_inbound_window_update_frames_per_data_frame_sent`,
256/// `api/envoy/config/core/v3/protocol.proto`).
257const WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME: u64 = 2;
258/// Default maximum CONTINUATION frames per header block (CVE-2024-27316)
259const DEFAULT_MAX_CONTINUATION_FRAMES: u32 = 20;
260/// Default maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE)
261/// accepted from the peer. 64 KB is well above the RFC default of 4 KB
262/// while preventing a malicious peer from advertising up to 4 GB.
263const DEFAULT_MAX_HEADER_TABLE_SIZE: u32 = 65536;
264/// Default maximum number of materialized header fields per request/response —
265/// HPACK fields plus expanded cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK
266/// indexed-reference "header bomb": each 1-byte indexed reference materializes a
267/// `Pair` of per-entry bookkeeping, so an attacker amplifies wire bytes into
268/// allocation. RFC 9113 §6.5.2's +32-octet/field accounting alone caps this at
269/// ~2048 fields for a 64 KB list; this explicit count cap is the tighter,
270/// upstream-matching defense (cf. nginx `max_headers`, Apache `LimitRequestFields`).
271const DEFAULT_MAX_HEADER_FIELDS: u32 = 128;
272/// Default share, in percent, of the pre-response RST_STREAM cap's floor
273/// ([`DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME`]) at which a connection starts
274/// refusing new client streams with `REFUSED_STREAM` instead of waiting for
275/// the cap itself, which ends the connection with
276/// `GOAWAY(ENHANCE_YOUR_CALM)` and every stream on it.
277///
278/// A refused stream did no work, so RFC 9113 §8.7 lets the client retry it.
279/// `0` disables the soft state; `100` or more has the same effect, since the
280/// cap itself trips first.
281///
282/// The per-window RST_STREAM rate ([`DEFAULT_MAX_RST_STREAM_PER_WINDOW`]) has
283/// no soft threshold. It counts every reset, answered streams included, and
284/// with its half-decay a client sustaining half the rate never reaches the
285/// limit: a soft threshold there would refuse streams of clients the limit
286/// never cuts off.
287const DEFAULT_STREAM_REFUSAL_PERCENT: u32 = 50;
288/// Duration of the sliding window for rate-based flood counters
289const FLOOD_WINDOW_DURATION: std::time::Duration = std::time::Duration::from_secs(1);
290/// Default maximum general anomaly count before triggering ENHANCE_YOUR_CALM.
291///
292/// Half-decaying like the other rate counters: a burst of 2000 or about 1000
293/// per second sustained. Stream-close races are routine on a busy
294/// connection — a `WINDOW_UPDATE` or `RST_STREAM` crossing the response's
295/// `END_STREAM`, the DATA a client had in flight on a stream Sōzu reset —
296/// and one cancelled upload with its full 64 KiB stream window in flight
297/// is about four of them in 16 KiB frames, more in smaller ones. nghttp2
298/// budgets glitches at a 10 000 burst and 330 per second
299/// (`NGHTTP2_DEFAULT_GLITCH_BURST` / `_RATE`, `lib/nghttp2_session.h`).
300const DEFAULT_MAX_GLITCH_COUNT: u32 = 2000;
301
302/// Configurable thresholds for H2 flood detection.
303///
304/// All values have safe defaults matching the compile-time constants.
305/// When configured via listener config, `None` values fall back to these defaults.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub struct H2FloodConfig {
308 /// Maximum RST_STREAM frames per second window (CVE-2023-44487, CVE-2019-9514)
309 max_rst_stream_per_window: u32,
310 /// Maximum PING frames per second window (CVE-2019-9512)
311 max_ping_per_window: u32,
312 /// Maximum SETTINGS frames per second window (CVE-2019-9515)
313 max_settings_per_window: u32,
314 /// Maximum empty DATA frames per second window (CVE-2019-9518)
315 max_empty_data_per_window: u32,
316 /// Maximum unsolicited connection-level (stream 0) WINDOW_UPDATE frames
317 /// per sliding window — those beyond the credit DATA frames sent earn
318 /// ([`WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME`] each). Caps the CPU
319 /// cost of a peer sending a flood of non-zero stream-0 WINDOW_UPDATEs —
320 /// each is individually legal so the generic glitch counter does not trip,
321 /// yet millions per connection still burn server CPU parsing and updating
322 /// the flow window.
323 max_window_update_stream0_per_window: u32,
324 /// Maximum CONTINUATION frames per header block (CVE-2024-27316)
325 max_continuation_frames: u32,
326 /// Maximum accumulated protocol anomalies before ENHANCE_YOUR_CALM
327 max_glitch_count: u32,
328 /// Floor of the connection-lifetime cap on RST_STREAM frames received
329 /// (CVE-2023-44487): trips when the count exceeds this floor and the
330 /// resets received after a response started or on a stream already gone
331 /// exceed the streams a backend answered on the connection.
332 max_rst_stream_lifetime: u64,
333 /// Floor of the cap on "abusive" (pre-response-start) RST_STREAM frames —
334 /// the Rapid Reset signature (CVE-2023-44487): trips when the count
335 /// exceeds this floor and pre-response resets outnumber the streams a
336 /// backend answered.
337 max_rst_stream_abusive_lifetime: u64,
338 /// Floor of the cap on **peer-provoked** RST_STREAM frames the server
339 /// emits (CVE-2025-8671 "MadeYouReset"): trips when the count exceeds
340 /// this floor and pre-response resets outnumber the streams a backend
341 /// answered. Resets Sōzu decides on
342 /// its own and graceful `NoError` cancels are not counted.
343 max_rst_stream_emitted_lifetime: u64,
344 /// Maximum accumulated HPACK-decoded header list size per request
345 /// (SETTINGS_MAX_HEADER_LIST_SIZE, RFC 9113 §6.5.2).
346 max_header_list_size: u32,
347 /// Maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE) accepted
348 /// from the peer. Caps the value the peer advertises in SETTINGS frames to
349 /// prevent unbounded HPACK encoder memory growth.
350 max_header_table_size: u32,
351 /// Maximum number of materialized header fields, enforced per HEADERS block
352 /// and (independently) per trailers block — HPACK fields plus expanded
353 /// cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK indexed-reference
354 /// header bomb, where many 1-byte indexed references each materialize a
355 /// `Pair` of per-entry bookkeeping.
356 max_header_fields: u32,
357 /// Share, in percent, of the pre-response cap's floor at which new client
358 /// streams are refused with `REFUSED_STREAM` (see
359 /// [`H2FloodDetector::refuses_new_streams`]). Not clamped: `0`
360 /// disables the soft state, and `100` or more never engages it before
361 /// the limit itself trips.
362 stream_refusal_percent: u32,
363}
364
365impl Default for H2FloodConfig {
366 fn default() -> Self {
367 Self {
368 max_rst_stream_per_window: DEFAULT_MAX_RST_STREAM_PER_WINDOW,
369 max_ping_per_window: DEFAULT_MAX_PING_PER_WINDOW,
370 max_settings_per_window: DEFAULT_MAX_SETTINGS_PER_WINDOW,
371 max_empty_data_per_window: DEFAULT_MAX_EMPTY_DATA_PER_WINDOW,
372 max_window_update_stream0_per_window: DEFAULT_MAX_WINDOW_UPDATE_STREAM0_PER_WINDOW,
373 max_continuation_frames: DEFAULT_MAX_CONTINUATION_FRAMES,
374 max_glitch_count: DEFAULT_MAX_GLITCH_COUNT,
375 max_rst_stream_lifetime: DEFAULT_MAX_RST_STREAM_LIFETIME,
376 max_rst_stream_abusive_lifetime: DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME,
377 max_rst_stream_emitted_lifetime: DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME,
378 max_header_list_size: MAX_HEADER_LIST_SIZE as u32,
379 max_header_table_size: DEFAULT_MAX_HEADER_TABLE_SIZE,
380 max_header_fields: DEFAULT_MAX_HEADER_FIELDS,
381 stream_refusal_percent: DEFAULT_STREAM_REFUSAL_PERCENT,
382 }
383 }
384}
385
386impl H2FloodConfig {
387 /// Create a validated config, clamping all thresholds to at least 1.
388 /// Zero thresholds would cause immediate flood detection on any frame.
389 ///
390 /// With [`Self::from_optional`], this is the **only** way to build an
391 /// `H2FloodConfig`: the fields above are private, so the clamp below cannot
392 /// be routed around by a raw struct literal the way `lib/src/http.rs` and
393 /// `lib/src/https.rs` used to route around it (sozu-proxy/sozu#1418).
394 #[allow(clippy::too_many_arguments)]
395 pub fn new(
396 max_rst_stream_per_window: u32,
397 max_ping_per_window: u32,
398 max_settings_per_window: u32,
399 max_empty_data_per_window: u32,
400 max_window_update_stream0_per_window: u32,
401 max_continuation_frames: u32,
402 max_glitch_count: u32,
403 max_rst_stream_lifetime: u64,
404 max_rst_stream_abusive_lifetime: u64,
405 max_rst_stream_emitted_lifetime: u64,
406 max_header_list_size: u32,
407 max_header_table_size: u32,
408 max_header_fields: u32,
409 stream_refusal_percent: u32,
410 ) -> Self {
411 let config = Self {
412 max_rst_stream_per_window: max_rst_stream_per_window.max(1),
413 max_ping_per_window: max_ping_per_window.max(1),
414 max_settings_per_window: max_settings_per_window.max(1),
415 max_empty_data_per_window: max_empty_data_per_window.max(1),
416 max_window_update_stream0_per_window: max_window_update_stream0_per_window.max(1),
417 max_continuation_frames: max_continuation_frames.max(1),
418 max_glitch_count: max_glitch_count.max(1),
419 max_rst_stream_lifetime: max_rst_stream_lifetime.max(1),
420 max_rst_stream_abusive_lifetime: max_rst_stream_abusive_lifetime.max(1),
421 max_rst_stream_emitted_lifetime: max_rst_stream_emitted_lifetime.max(1),
422 max_header_list_size: max_header_list_size.max(1),
423 max_header_table_size: max_header_table_size.max(1),
424 max_header_fields: max_header_fields.max(1),
425 // Not clamped: every value has a meaning, `0` included.
426 stream_refusal_percent,
427 };
428 // Post-condition: every threshold is clamped to at least 1. A zero
429 // threshold would make `check_flood`/`record_rst_*` trip on the very
430 // first frame (count > 0 > threshold), turning a legitimate connection
431 // into an immediate GOAWAY. This is the central invariant the clamps
432 // above exist to enforce — assert it rather than trusting the `.max(1)`
433 // chain stays correct under future edits.
434 debug_assert!(
435 config.max_rst_stream_per_window >= 1
436 && config.max_ping_per_window >= 1
437 && config.max_settings_per_window >= 1
438 && config.max_empty_data_per_window >= 1
439 && config.max_window_update_stream0_per_window >= 1
440 && config.max_continuation_frames >= 1
441 && config.max_glitch_count >= 1,
442 "every u32 flood threshold must be clamped to >= 1"
443 );
444 debug_assert!(
445 config.max_rst_stream_lifetime >= 1
446 && config.max_rst_stream_abusive_lifetime >= 1
447 && config.max_rst_stream_emitted_lifetime >= 1
448 && config.max_header_list_size >= 1
449 && config.max_header_table_size >= 1
450 && config.max_header_fields >= 1,
451 "every lifetime/size flood threshold must be clamped to >= 1"
452 );
453 config
454 }
455
456 /// Build a validated config from a listener's per-knob overrides: each
457 /// `None` takes the compile-time default, then [`Self::new`] clamps.
458 /// Mirrors [`super::h2::H2ConnectionConfig::from_optional`], which the
459 /// adjacent `get_h2_connection_config` already uses for the connection
460 /// knobs read out of the very same listener config.
461 ///
462 /// This is what `lib/src/http.rs` and `lib/src/https.rs` call. They used to
463 /// spell the same `unwrap_or(default)` table into a raw struct literal,
464 /// which skipped `new` and therefore skipped the clamp on the one
465 /// production path that carries operator-set thresholds
466 /// (sozu-proxy/sozu#1418). A zero that survives to here does not disable a
467 /// check, it makes the first event its counter sees a violation, and how
468 /// far that reaches depends on the knob: `max_header_list_size` and
469 /// `max_header_fields` are also the HPACK decode budget
470 /// `pkawa::decode_headers_with_budget` enforces, so `0` refuses every
471 /// request on the listener with `ENHANCE_YOUR_CALM` — per stream for a
472 /// header block that fits one HEADERS frame, and as a connection
473 /// `GOAWAY` for one that spans CONTINUATION frames, where
474 /// `H2FloodDetector::record_continuation_frame` lifts `accumulated_header_size`
475 /// above the zero threshold and `h2.rs`'s `check_flood_or_return!` fires
476 /// before the explicit size test. `max_rst_stream_per_window = 0` (and
477 /// every other per-window or lifetime frame counter) is inert until a
478 /// client actually sends the frame it counts — `check_flood` compares
479 /// `count > threshold`, and `0 > 0` is false.
480 ///
481 /// Every door that STATES a listener configuration now rejects an
482 /// out-of-range knob outright — the configuration file and
483 /// `sozu ctl add listener` via `ConfigError::H2ThresholdBelowMinimum`
484 /// (`command/src/config.rs`), `UpdateHttp(s)Listener` and a raw protobuf
485 /// `Add{Http,Https}Listener` via `command/src/state.rs`'s four
486 /// `validate_h2_flood_knobs_*` — so the operator is told which key to fix
487 /// rather than having their stated value silently rewritten. The clamp
488 /// reached through here is the backstop for the one path that deliberately
489 /// does NOT reject: replay of a state file saved before those gates
490 /// existed, where dropping the listener would be a worse outcome than
491 /// clamping one threshold (`bin/src/command/requests.rs`'s
492 /// `RequestOrigin::Replayed`).
493 #[allow(clippy::too_many_arguments)]
494 pub fn from_optional(
495 max_rst_stream_per_window: Option<u32>,
496 max_ping_per_window: Option<u32>,
497 max_settings_per_window: Option<u32>,
498 max_empty_data_per_window: Option<u32>,
499 max_window_update_stream0_per_window: Option<u32>,
500 max_continuation_frames: Option<u32>,
501 max_glitch_count: Option<u32>,
502 max_rst_stream_lifetime: Option<u64>,
503 max_rst_stream_abusive_lifetime: Option<u64>,
504 max_rst_stream_emitted_lifetime: Option<u64>,
505 max_header_list_size: Option<u32>,
506 max_header_table_size: Option<u32>,
507 max_header_fields: Option<u32>,
508 stream_refusal_percent: Option<u32>,
509 ) -> Self {
510 let defaults = Self::default();
511 Self::new(
512 max_rst_stream_per_window.unwrap_or(defaults.max_rst_stream_per_window),
513 max_ping_per_window.unwrap_or(defaults.max_ping_per_window),
514 max_settings_per_window.unwrap_or(defaults.max_settings_per_window),
515 max_empty_data_per_window.unwrap_or(defaults.max_empty_data_per_window),
516 max_window_update_stream0_per_window
517 .unwrap_or(defaults.max_window_update_stream0_per_window),
518 max_continuation_frames.unwrap_or(defaults.max_continuation_frames),
519 max_glitch_count.unwrap_or(defaults.max_glitch_count),
520 max_rst_stream_lifetime.unwrap_or(defaults.max_rst_stream_lifetime),
521 max_rst_stream_abusive_lifetime.unwrap_or(defaults.max_rst_stream_abusive_lifetime),
522 max_rst_stream_emitted_lifetime.unwrap_or(defaults.max_rst_stream_emitted_lifetime),
523 max_header_list_size.unwrap_or(defaults.max_header_list_size),
524 max_header_table_size.unwrap_or(defaults.max_header_table_size),
525 max_header_fields.unwrap_or(defaults.max_header_fields),
526 stream_refusal_percent.unwrap_or(defaults.stream_refusal_percent),
527 )
528 }
529
530 /// Maximum RST_STREAM frames per rate window (CVE-2023-44487, CVE-2019-9514).
531 pub fn max_rst_stream_per_window(&self) -> u32 {
532 self.max_rst_stream_per_window
533 }
534
535 /// Maximum PING frames per rate window (CVE-2019-9512).
536 pub fn max_ping_per_window(&self) -> u32 {
537 self.max_ping_per_window
538 }
539
540 /// Maximum SETTINGS frames per rate window (CVE-2019-9515).
541 pub fn max_settings_per_window(&self) -> u32 {
542 self.max_settings_per_window
543 }
544
545 /// Maximum empty DATA frames per rate window (CVE-2019-9518).
546 pub fn max_empty_data_per_window(&self) -> u32 {
547 self.max_empty_data_per_window
548 }
549
550 /// Maximum unsolicited connection-level (stream 0) WINDOW_UPDATE frames
551 /// per rate window.
552 pub fn max_window_update_stream0_per_window(&self) -> u32 {
553 self.max_window_update_stream0_per_window
554 }
555
556 /// Maximum CONTINUATION frames per header block (CVE-2024-27316).
557 pub fn max_continuation_frames(&self) -> u32 {
558 self.max_continuation_frames
559 }
560
561 /// Maximum accumulated protocol anomalies before ENHANCE_YOUR_CALM.
562 pub fn max_glitch_count(&self) -> u32 {
563 self.max_glitch_count
564 }
565
566 /// Floor of the lifetime cap on RST_STREAM frames received (CVE-2023-44487).
567 pub fn max_rst_stream_lifetime(&self) -> u64 {
568 self.max_rst_stream_lifetime
569 }
570
571 /// Floor of the cap on pre-response-start RST_STREAM frames (CVE-2023-44487).
572 pub fn max_rst_stream_abusive_lifetime(&self) -> u64 {
573 self.max_rst_stream_abusive_lifetime
574 }
575
576 /// Floor of the cap on peer-provoked server-emitted RST_STREAM frames
577 /// (CVE-2025-8671).
578 pub fn max_rst_stream_emitted_lifetime(&self) -> u64 {
579 self.max_rst_stream_emitted_lifetime
580 }
581
582 /// Maximum accumulated HPACK-decoded header list size (RFC 9113 §6.5.2).
583 pub fn max_header_list_size(&self) -> u32 {
584 self.max_header_list_size
585 }
586
587 /// Maximum HPACK dynamic table size accepted from the peer.
588 pub fn max_header_table_size(&self) -> u32 {
589 self.max_header_table_size
590 }
591
592 /// Maximum number of materialized header fields per HEADERS/trailers block.
593 pub fn max_header_fields(&self) -> u32 {
594 self.max_header_fields
595 }
596
597 /// Share, in percent, of the pre-response cap's floor at which new client
598 /// streams are refused; `0` disables the soft state.
599 pub fn stream_refusal_percent(&self) -> u32 {
600 self.stream_refusal_percent
601 }
602}
603
604/// Detail of a flood-threshold violation returned by
605/// [`H2FloodDetector::check_flood`] and [`H2FloodDetector::record_rst_lifetime`].
606///
607/// Carrying `(reason, count, threshold)` lets the caller emit a session-scoped
608/// log line with full context — the detector itself is connection-agnostic and
609/// never logs.
610#[derive(Debug, Clone, PartialEq)]
611pub struct H2FloodViolation {
612 /// HTTP/2 error code to emit on the GOAWAY.
613 pub error: H2Error,
614 /// Human-readable name of the counter that tripped (e.g. `"RST_STREAM"`).
615 pub reason: &'static str,
616 /// Statsd metric key emitted by [`super::h2::ConnectionH2::handle_flood_violation`].
617 /// Carried alongside `reason` so a single field maps to both the log line
618 /// and the dashboard counter — adding a new violation kind requires
619 /// choosing both at the construction site, preventing drift.
620 pub metric_key: &'static str,
621 /// Observed counter value at the moment of detection.
622 pub count: u64,
623 /// Configured ceiling that was crossed.
624 pub threshold: u64,
625}
626
627/// Tracks per-connection frame rates to detect and mitigate H2 flood attacks.
628///
629/// Monitors RST_STREAM (CVE-2023-44487), PING (CVE-2019-9512), SETTINGS (CVE-2019-9515),
630/// empty DATA (CVE-2019-9518), and CONTINUATION (CVE-2024-27316) flood patterns.
631/// When any counter exceeds its threshold, `check_flood()` returns the violation
632/// detail so callers can log with connection context before sending GOAWAY.
633///
634/// Thresholds are configurable via [`H2FloodConfig`], with safe defaults matching
635/// the original compile-time constants.
636#[derive(Debug)]
637pub(super) struct H2FloodDetector {
638 /// RST_STREAM frames received in current window (CVE-2023-44487 + CVE-2019-9514)
639 rst_stream_count: u32,
640 /// Lifetime RST_STREAM frames received on this connection.
641 ///
642 /// Never decays, but only trips while the resets outside
643 /// [`Self::total_abusive_rst_received_lifetime`] — after a response
644 /// started, or of a stream already gone — also exceed
645 /// [`Self::streams_opened`].
646 total_rst_received_lifetime: u64,
647 /// Lifetime RST_STREAM frames received that targeted a stream whose
648 /// backend response had not yet started, on a frontend connection. These
649 /// are the "Rapid Reset" signature — cheap for the attacker, expensive
650 /// for the proxy — and trip once they exceed the configured floor while,
651 /// together with [`Self::total_rst_streams_emitted_lifetime`], they exceed
652 /// [`Self::streams_opened`].
653 total_abusive_rst_received_lifetime: u64,
654 /// Lifetime RST_STREAM frames **emitted by the server** that the peer
655 /// provoked (CVE-2025-8671 "MadeYouReset" mitigation): content-length
656 /// mismatch, header parse error, oversized header block, priority
657 /// rejection, zero-increment or overflowing WINDOW_UPDATE on an open
658 /// stream. Resets Sōzu decides on its own are never recorded here. Trips
659 /// once it exceeds the configured floor while, together with
660 /// [`Self::total_abusive_rst_received_lifetime`], it exceeds
661 /// [`Self::streams_opened`].
662 total_rst_streams_emitted_lifetime: u64,
663 /// The denominator the three RST caps above compare against. On a
664 /// frontend connection, the answered streams that were routed to a
665 /// backend — answered by it, or answered 502/503/504 by Sōzu after a
666 /// backend was selected and failed or while every backend of the cluster
667 /// was failing — counted when the stream completes,
668 /// or when the client resets it after the response started (before the
669 /// reset is checked against the caps); on a backend connection, the
670 /// streams Sōzu opened (see `routed_to_a_backend` in
671 /// `lib/src/protocol/mux/h2.rs`).
672 ///
673 /// A frontend stream Sōzu refuses, answers without routing it, or sees
674 /// reset before any response does not count: the peer opens those for
675 /// free, so counting them would let filler streams dilute the ratio a
676 /// Rapid Reset or MadeYouReset client must stay under. Excluding the
677 /// streams reset before a response is also what makes
678 /// `pre-response resets > streams_opened` the 50 % line.
679 streams_opened: u64,
680 /// Stream-0 WINDOW_UPDATE frames the peer may still send without counting
681 /// toward [`Self::window_update_stream0_count`]: each DATA frame the proxy
682 /// sends adds [`WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME`], each
683 /// stream-0 WINDOW_UPDATE received spends one. Clamped to
684 /// [`Self::window_update_stream0_credit_cap`].
685 window_update_stream0_credit: u64,
686 /// DATA frames sent recently: incremented per DATA frame, halved at each
687 /// window roll like the rate counters. Sizes the credit cap.
688 data_frames_recent: u64,
689 /// PING frames received in current window (CVE-2019-9512)
690 ping_count: u32,
691 /// Lifetime PING frames received on this connection.
692 ///
693 /// Never decays — provides an absolute ceiling that the half-decaying
694 /// per-window counter cannot, preventing sustained low-rate PING abuse.
695 total_ping_received_lifetime: u32,
696 /// SETTINGS frames received in current window (CVE-2019-9515)
697 settings_count: u32,
698 /// Lifetime SETTINGS frames received on this connection.
699 ///
700 /// Never decays — provides an absolute ceiling that the half-decaying
701 /// per-window counter cannot, preventing sustained low-rate SETTINGS abuse.
702 total_settings_received_lifetime: u32,
703 /// Empty DATA frames received in current window (CVE-2019-9518)
704 empty_data_count: u32,
705 /// Unsolicited connection-level (stream 0) WINDOW_UPDATE frames received
706 /// in current sliding window — those [`Self::window_update_stream0_credit`]
707 /// did not cover. Half-decays with [`Self::maybe_reset_window`] like other
708 /// rate counters. Increments on non-zero stream-0 WINDOW_UPDATEs only —
709 /// zero-increment frames short-circuit into GOAWAY(PROTOCOL_ERROR) per
710 /// RFC 9113 §6.9 before reaching this counter.
711 window_update_stream0_count: u32,
712 /// CONTINUATION frames received for current header block (CVE-2024-27316)
713 continuation_count: u32,
714 /// Total accumulated header block size across CONTINUATION frames
715 accumulated_header_size: u32,
716 /// General anomaly counter
717 glitch_count: u32,
718 /// When the peer last reset a stream before its response — the
719 /// caller's clock, as handed to [`Self::record_rst_received`]. The
720 /// pre-response branch of [`Self::refuses_new_streams`] holds only within
721 /// one [`FLOOD_WINDOW_DURATION`] of it: the pre-response count never
722 /// decays, and the answered streams that would rebalance it cannot grow
723 /// while every new stream is refused, so without this the refusal would
724 /// outlive the resets that caused it.
725 last_pre_response_rst_at: Option<Instant>,
726 /// Window start for rate-based counters.
727 ///
728 /// Private: the detector never samples the clock itself, so this field is
729 /// only ever advanced from a `now` the caller supplies to
730 /// [`Self::check_flood`]. Exposing it would let a caller reset the window
731 /// against a clock the connection is not reading, which is exactly the
732 /// dual-clock hazard the injected `now` removes.
733 window_start: Instant,
734 /// Configurable thresholds for flood detection
735 config: H2FloodConfig,
736}
737
738impl H2FloodDetector {
739 /// `now` is the caller's clock snapshot — the detector never samples the
740 /// clock itself. It seeds the first rate window, which
741 /// [`Self::check_flood`] then advances from the `now` it is handed.
742 pub(super) fn new(config: H2FloodConfig, now: Instant) -> Self {
743 // Pre-condition: thresholds are already validated (clamped to >= 1 by
744 // `H2FloodConfig::new`, the only constructor — `H2FloodConfig`'s fields
745 // are private, so no caller can assemble one with a struct literal).
746 // A zero threshold would make the first event its counter sees a
747 // violation. The assertion outlived the hole it was written for
748 // (sozu-proxy/sozu#1418: `get_h2_flood_config` WAS that raw struct
749 // literal) and is kept as the debug-build tripwire for any future
750 // construction path inside this module.
751 //
752 // It covers all thirteen fields, not the five it used to: `impl
753 // Default` above is still an in-module struct literal, so a future
754 // `DEFAULT_MAX_HEADER_LIST_SIZE = 0` would otherwise pass unnoticed —
755 // and that is the worst of the thirteen, since `max_header_list_size`
756 // is also the HPACK decode budget and `0` refuses every request.
757 debug_assert!(
758 config.max_rst_stream_per_window >= 1
759 && config.max_ping_per_window >= 1
760 && config.max_settings_per_window >= 1
761 && config.max_empty_data_per_window >= 1
762 && config.max_window_update_stream0_per_window >= 1
763 && config.max_continuation_frames >= 1
764 && config.max_glitch_count >= 1,
765 "flood detector must be constructed with validated (>= 1) per-window thresholds"
766 );
767 debug_assert!(
768 config.max_rst_stream_lifetime >= 1
769 && config.max_rst_stream_abusive_lifetime >= 1
770 && config.max_rst_stream_emitted_lifetime >= 1
771 && config.max_header_list_size >= 1
772 && config.max_header_table_size >= 1
773 && config.max_header_fields >= 1,
774 "flood detector must be constructed with validated (>= 1) lifetime/size thresholds"
775 );
776 let detector = Self {
777 rst_stream_count: 0,
778 total_rst_received_lifetime: 0,
779 total_abusive_rst_received_lifetime: 0,
780 total_rst_streams_emitted_lifetime: 0,
781 streams_opened: 0,
782 window_update_stream0_credit: 0,
783 data_frames_recent: 0,
784 ping_count: 0,
785 total_ping_received_lifetime: 0,
786 settings_count: 0,
787 total_settings_received_lifetime: 0,
788 empty_data_count: 0,
789 window_update_stream0_count: 0,
790 continuation_count: 0,
791 accumulated_header_size: 0,
792 glitch_count: 0,
793 last_pre_response_rst_at: None,
794 window_start: now,
795 config,
796 };
797 detector.debug_assert_invariants();
798 detector
799 }
800
801 /// Current configured thresholds — a cheap `Copy` out, not a reference,
802 /// so callers cannot smuggle a `&mut` in through it. Backs the SETTINGS
803 /// `SETTINGS_HEADER_TABLE_SIZE` cap, the `pkawa::handle_header` budget
804 /// arguments, and the CONTINUATION accumulated-size log/GOAWAY check.
805 pub(super) fn config(&self) -> H2FloodConfig {
806 self.config
807 }
808
809 /// Total accumulated header block size across CONTINUATION frames for the
810 /// block currently in progress (CVE-2024-27316).
811 pub(super) fn accumulated_header_size(&self) -> u32 {
812 self.accumulated_header_size
813 }
814
815 /// Lifetime RST_STREAM frames received on this connection — read by the
816 /// `MUX-H2` session log line (`log_context!`/`log_context_stream!`).
817 pub(super) fn total_rst_received_lifetime(&self) -> u64 {
818 self.total_rst_received_lifetime
819 }
820
821 /// Lifetime RST_STREAM frames emitted by the server on this connection
822 /// (CVE-2025-8671) — read by the `MUX-H2` session log line.
823 pub(super) fn total_rst_streams_emitted_lifetime(&self) -> u64 {
824 self.total_rst_streams_emitted_lifetime
825 }
826
827 /// Record one stream toward the RST caps' denominator — on a frontend
828 /// connection a stream a backend answered, on a backend connection one
829 /// Sōzu opened (see [`Self::streams_opened`]).
830 pub(super) fn record_stream_opened(&mut self) {
831 let before = self.streams_opened;
832 self.streams_opened = self.streams_opened.saturating_add(1);
833 debug_assert!(
834 self.streams_opened > before || before == u64::MAX,
835 "streams-opened counter must advance (or already be saturated)"
836 );
837 self.debug_assert_invariants();
838 }
839
840 /// Credit the peer for `frames` DATA frames the proxy just sent: each
841 /// entitles it to [`WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME`]
842 /// connection-level WINDOW_UPDATEs that do not count toward the
843 /// per-window flood threshold.
844 ///
845 /// The stored credit is capped at [`Self::window_update_stream0_credit_cap`]
846 /// so a long download cannot bank credit for a later flood; a peer only
847 /// ever owes updates for the DATA still in flight.
848 pub(super) fn record_data_frames_sent(&mut self, frames: u64) {
849 let before = self.window_update_stream0_credit;
850 self.data_frames_recent = self.data_frames_recent.saturating_add(frames);
851 let cap = self.window_update_stream0_credit_cap();
852 self.window_update_stream0_credit = self
853 .window_update_stream0_credit
854 .saturating_add(frames.saturating_mul(WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME))
855 .min(cap);
856 debug_assert!(
857 self.window_update_stream0_credit >= before.min(cap),
858 "sending DATA must never take credit away below the cap"
859 );
860 debug_assert!(
861 self.window_update_stream0_credit <= cap,
862 "the stored stream-0 WINDOW_UPDATE credit must stay within its cap"
863 );
864 self.debug_assert_invariants();
865 }
866
867 /// Ceiling on the stored stream-0 WINDOW_UPDATE credit:
868 /// [`WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME`] times the configured
869 /// per-window threshold plus the DATA frames sent recently. The recent
870 /// part covers every update a peer can still owe for DATA in flight,
871 /// however fast the download; it halves at each window roll, so once the
872 /// download stops the credit falls back to the per-window part instead of
873 /// staying banked for a later flood.
874 fn window_update_stream0_credit_cap(&self) -> u64 {
875 u64::from(self.config.max_window_update_stream0_per_window)
876 .saturating_add(self.data_frames_recent)
877 .saturating_mul(WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME)
878 }
879
880 /// True once `count` exceeds `floor` and `ratio_count` exceeds
881 /// `streams_opened`. Shared by the three RST caps: the floor keeps a
882 /// short connection from tripping on a handful of resets, the ratio keeps
883 /// a long one from accumulating benign resets toward a fixed ceiling.
884 fn exceeds_floor_and_ratio(
885 count: u64,
886 floor: u64,
887 ratio_count: u64,
888 streams_opened: u64,
889 ) -> bool {
890 count > floor && ratio_count > streams_opened
891 }
892
893 /// Streams reset before a response, whichever side sent the RST: the
894 /// pre-response resets the peer sent plus the resets it provoked Sōzu
895 /// into. Both pre-response caps trip once this one count exceeds the
896 /// streams a backend answered. Those exclude the streams reset before
897 /// their response, so `resets > answered` is `resets > half of all
898 /// streams` — the same 50 % line as Envoy's premature-reset guard — and a
899 /// peer cannot reset every stream by splitting the resets between the two
900 /// kinds.
901 fn pre_response_resets(&self) -> u64 {
902 self.total_abusive_rst_received_lifetime
903 .saturating_add(self.total_rst_streams_emitted_lifetime)
904 }
905
906 /// Account one RST_STREAM received, in the order the caps need: when
907 /// `answered` — a frontend stream routed to a backend whose response
908 /// started — the stream first counts toward [`Self::streams_opened`],
909 /// then the reset goes through [`Self::record_rst_lifetime`]. Counting the
910 /// stream after the check would leave each check one answered stream
911 /// short, and a client that cancels every stream after its response
912 /// would trip the received-RST cap just past its floor.
913 ///
914 /// `now` is the caller's clock snapshot; a pre-response reset stamps it
915 /// for [`Self::refuses_new_streams`].
916 pub(super) fn record_rst_received(
917 &mut self,
918 response_started: bool,
919 answered: bool,
920 now: Instant,
921 ) -> Option<H2FloodViolation> {
922 debug_assert!(
923 response_started || !answered,
924 "a stream reset before its response is never an answered one"
925 );
926 if answered {
927 self.record_stream_opened();
928 }
929 if !response_started {
930 self.last_pre_response_rst_at = Some(now);
931 }
932 debug_assert!(
933 response_started || self.last_pre_response_rst_at == Some(now),
934 "a pre-response reset must stamp the soft-refusal clock"
935 );
936 self.record_rst_lifetime(response_started)
937 }
938
939 /// Whether a new client stream must be refused with `REFUSED_STREAM`
940 /// instead of opened: the soft state below the pre-response cap
941 /// (CVE-2023-44487).
942 ///
943 /// It holds while all three hold:
944 ///
945 /// - the pre-response reset count is past `stream_refusal_percent` of the
946 /// cap's floor;
947 /// - pre-response resets already outnumber the answered streams — the
948 /// cap's own ratio, so a client that would never trip the cap is never
949 /// refused;
950 /// - the peer reset a stream before its response within the last
951 /// [`FLOOD_WINDOW_DURATION`]. The count never decays, and the answered
952 /// streams that would rebalance the ratio cannot grow while every new
953 /// stream is refused, so this is what ends the refusal once the client
954 /// stops.
955 ///
956 /// The caller resets the stream as a local decision, which the
957 /// provoked-RST cap does not see. Once the peer acknowledged Sōzu's
958 /// SETTINGS, the refusal counts one glitch, like every other stream
959 /// refusal: the glitch budget bounds a peer that keeps opening streams it
960 /// is refused. A peer that keeps resetting streams still moves toward the
961 /// cap, which ends the connection unchanged. The
962 /// per-window RST_STREAM rate and the PING, SETTINGS, empty DATA,
963 /// CONTINUATION and stream-0 WINDOW_UPDATE floods have no soft state —
964 /// see [`DEFAULT_STREAM_REFUSAL_PERCENT`].
965 pub(super) fn refuses_new_streams(&self, now: Instant) -> bool {
966 let percent = u128::from(self.config.stream_refusal_percent);
967 if percent == 0 {
968 return false;
969 }
970 let recent = self
971 .last_pre_response_rst_at
972 .is_some_and(|at| now.saturating_duration_since(at) < FLOOD_WINDOW_DURATION);
973 // `count > percent% of floor`, in integers.
974 let past_soft_floor = u128::from(self.total_abusive_rst_received_lifetime) * 100
975 > u128::from(self.config.max_rst_stream_abusive_lifetime) * percent;
976 let refuses = recent && past_soft_floor && self.pre_response_resets() > self.streams_opened;
977 // Pair: a refusal needs pre-response resets on record, and the clock
978 // that ends it.
979 debug_assert!(
980 !refuses || self.total_abusive_rst_received_lifetime > 0,
981 "a refusal needs at least one pre-response reset"
982 );
983 debug_assert!(
984 !refuses || self.last_pre_response_rst_at.is_some(),
985 "a refusal needs the time of the last pre-response reset"
986 );
987 refuses
988 }
989
990 /// Increment the lifetime RST_STREAM counters and return a
991 /// [`H2FloodViolation`] if either the global or the abusive
992 /// (pre-response-start) cap has been exceeded.
993 ///
994 /// `response_started` indicates whether the backend response had already
995 /// begun when the RST arrived; `false` is the cheap-for-client /
996 /// expensive-for-us Rapid Reset signature (CVE-2023-44487). The caller
997 /// passes `true` for every reset a backend sends on a backend connection:
998 /// Sōzu opened that stream, so the reset costs the backend, not Sōzu.
999 ///
1000 /// The global cap trips once the count exceeds `max_rst_stream_lifetime`
1001 /// while the resets outside the pre-response count — after a response
1002 /// started, or of a stream already gone — exceed the streams a backend
1003 /// answered; the abusive one once it exceeds
1004 /// `max_rst_stream_abusive_lifetime` while the pre-response resets exceed
1005 /// the answered streams.
1006 fn record_rst_lifetime(&mut self, response_started: bool) -> Option<H2FloodViolation> {
1007 let total_before = self.total_rst_received_lifetime;
1008 let abusive_before = self.total_abusive_rst_received_lifetime;
1009 self.total_rst_received_lifetime = self.total_rst_received_lifetime.saturating_add(1);
1010 if !response_started {
1011 self.total_abusive_rst_received_lifetime =
1012 self.total_abusive_rst_received_lifetime.saturating_add(1);
1013 }
1014 // Monotonicity: the global lifetime counter advances by one per call
1015 // (until saturation), and the abusive sub-counter advances iff the RST
1016 // arrived before the backend response started. The abusive counter can
1017 // never exceed the global one — every abusive RST is also a received RST.
1018 debug_assert!(
1019 self.total_rst_received_lifetime >= total_before,
1020 "lifetime RST counter must be monotonic non-decreasing"
1021 );
1022 debug_assert_eq!(
1023 self.total_abusive_rst_received_lifetime > abusive_before,
1024 !response_started,
1025 "abusive RST counter advances iff the RST is pre-response-start"
1026 );
1027 debug_assert!(
1028 self.total_abusive_rst_received_lifetime <= self.total_rst_received_lifetime,
1029 "abusive RST count is a subset of total received RST count"
1030 );
1031 self.debug_assert_invariants();
1032 // Pre-response resets are the abusive cap's business below; counting
1033 // them here too would let a few of them push a client that cancels
1034 // its answered streams past the answered-stream count.
1035 if Self::exceeds_floor_and_ratio(
1036 self.total_rst_received_lifetime,
1037 self.config.max_rst_stream_lifetime,
1038 self.total_rst_received_lifetime
1039 .saturating_sub(self.total_abusive_rst_received_lifetime),
1040 self.streams_opened,
1041 ) {
1042 return Some(H2FloodViolation {
1043 error: H2Error::EnhanceYourCalm,
1044 reason: "Rapid Reset: lifetime RST_STREAM",
1045 metric_key: "h2.flood.violation.rst_stream_lifetime",
1046 count: self.total_rst_received_lifetime,
1047 threshold: self.config.max_rst_stream_lifetime,
1048 });
1049 }
1050 if Self::exceeds_floor_and_ratio(
1051 self.total_abusive_rst_received_lifetime,
1052 self.config.max_rst_stream_abusive_lifetime,
1053 self.pre_response_resets(),
1054 self.streams_opened,
1055 ) {
1056 return Some(H2FloodViolation {
1057 error: H2Error::EnhanceYourCalm,
1058 reason: "Rapid Reset: lifetime pre-response RST_STREAM",
1059 metric_key: "h2.flood.violation.rst_stream_pre_response_lifetime",
1060 count: self.total_abusive_rst_received_lifetime,
1061 threshold: self.config.max_rst_stream_abusive_lifetime,
1062 });
1063 }
1064 None
1065 }
1066
1067 /// Increment the lifetime **peer-provoked** server-emitted RST_STREAM
1068 /// counter and return a [`H2FloodViolation`] once it exceeds
1069 /// `max_rst_stream_emitted_lifetime` while the pre-response resets exceed
1070 /// the streams a backend answered.
1071 ///
1072 /// The only caller is `ConnectionH2::account_emitted_rst`, for a
1073 /// non-`NoError` reset whose origin is `RstOrigin::PeerProvoked`: a frame
1074 /// the peer crafted coerced the server into the reset (CVE-2025-8671
1075 /// "MadeYouReset"). Resets Sōzu decides on its own never reach here.
1076 pub(super) fn record_rst_emitted(&mut self) -> Option<H2FloodViolation> {
1077 let before = self.total_rst_streams_emitted_lifetime;
1078 self.total_rst_streams_emitted_lifetime =
1079 self.total_rst_streams_emitted_lifetime.saturating_add(1);
1080 // Monotonic: the emitted-RST counter never decays (the cap scales with
1081 // the streams a backend answered instead), so each call strictly advances it
1082 // until u64 saturation.
1083 debug_assert!(
1084 self.total_rst_streams_emitted_lifetime > before || before == u64::MAX,
1085 "emitted-RST lifetime counter must advance (or already be saturated)"
1086 );
1087 self.debug_assert_invariants();
1088 if Self::exceeds_floor_and_ratio(
1089 self.total_rst_streams_emitted_lifetime,
1090 self.config.max_rst_stream_emitted_lifetime,
1091 self.pre_response_resets(),
1092 self.streams_opened,
1093 ) {
1094 return Some(H2FloodViolation {
1095 error: H2Error::EnhanceYourCalm,
1096 reason: "MadeYouReset: lifetime server-emitted RST_STREAM",
1097 metric_key: "h2.flood.violation.rst_stream_emitted_lifetime",
1098 count: self.total_rst_streams_emitted_lifetime,
1099 threshold: self.config.max_rst_stream_emitted_lifetime,
1100 });
1101 }
1102 None
1103 }
1104
1105 /// CVE-2023-44487 Rapid Reset + CVE-2019-9514: record one inbound
1106 /// RST_STREAM toward the per-window rate counter. The never-decaying
1107 /// lifetime ceilings are tracked separately by [`Self::record_rst_lifetime`].
1108 pub(super) fn record_rst_stream_window(&mut self) {
1109 let before = self.rst_stream_count;
1110 self.rst_stream_count += 1;
1111 debug_assert_eq!(
1112 self.rst_stream_count,
1113 before + 1,
1114 "per-window RST_STREAM counter must advance by exactly one per inbound RST"
1115 );
1116 self.debug_assert_invariants();
1117 }
1118
1119 /// CVE-2019-9512: record one non-ACK PING frame toward both the
1120 /// per-window rate counter and the never-decaying lifetime counter.
1121 pub(super) fn record_ping_frame(&mut self) {
1122 let count_before = self.ping_count;
1123 let lifetime_before = self.total_ping_received_lifetime;
1124 self.ping_count += 1;
1125 self.total_ping_received_lifetime = self.total_ping_received_lifetime.saturating_add(1);
1126 debug_assert_eq!(
1127 self.ping_count,
1128 count_before + 1,
1129 "per-window PING counter must advance by one per non-ACK PING"
1130 );
1131 debug_assert!(
1132 self.total_ping_received_lifetime > lifetime_before || lifetime_before == u32::MAX,
1133 "lifetime PING counter must advance (or already be saturated)"
1134 );
1135 self.debug_assert_invariants();
1136 }
1137
1138 /// CVE-2019-9515: record one non-ACK SETTINGS frame toward both the
1139 /// per-window rate counter and the never-decaying lifetime counter.
1140 pub(super) fn record_settings_frame(&mut self) {
1141 let count_before = self.settings_count;
1142 let lifetime_before = self.total_settings_received_lifetime;
1143 self.settings_count += 1;
1144 self.total_settings_received_lifetime =
1145 self.total_settings_received_lifetime.saturating_add(1);
1146 debug_assert_eq!(
1147 self.settings_count,
1148 count_before + 1,
1149 "per-window SETTINGS counter must advance by one per non-ACK SETTINGS"
1150 );
1151 debug_assert!(
1152 self.total_settings_received_lifetime > lifetime_before || lifetime_before == u32::MAX,
1153 "lifetime SETTINGS counter must advance (or already be saturated)"
1154 );
1155 self.debug_assert_invariants();
1156 }
1157
1158 /// CVE-2019-9518: record one empty (no payload, no END_STREAM) DATA frame
1159 /// toward the per-window rate counter.
1160 pub(super) fn record_empty_data_frame(&mut self) {
1161 let before = self.empty_data_count;
1162 self.empty_data_count += 1;
1163 debug_assert_eq!(
1164 self.empty_data_count,
1165 before + 1,
1166 "empty-DATA flood counter must advance by exactly one per empty frame"
1167 );
1168 self.debug_assert_invariants();
1169 }
1170
1171 /// Record one non-zero connection-level (stream 0) WINDOW_UPDATE, before
1172 /// the arithmetic cost of applying it to the connection window is paid.
1173 ///
1174 /// An update that answers DATA the proxy sent spends one unit of
1175 /// [`Self::window_update_stream0_credit`] and is not counted; only an
1176 /// unsolicited one advances the per-window rate counter.
1177 pub(super) fn record_window_update_stream0(&mut self) {
1178 let count_before = self.window_update_stream0_count;
1179 let credit_before = self.window_update_stream0_credit;
1180 if self.window_update_stream0_credit > 0 {
1181 self.window_update_stream0_credit -= 1;
1182 } else {
1183 self.window_update_stream0_count = self.window_update_stream0_count.saturating_add(1);
1184 }
1185 // Pair: exactly one of the two moves — credit spent, or the frame
1186 // counted — never both, never neither.
1187 debug_assert_eq!(
1188 credit_before > 0,
1189 self.window_update_stream0_credit < credit_before,
1190 "a credited stream-0 WINDOW_UPDATE must spend exactly one unit of credit"
1191 );
1192 debug_assert_eq!(
1193 credit_before == 0,
1194 self.window_update_stream0_count > count_before || count_before == u32::MAX,
1195 "an unsolicited stream-0 WINDOW_UPDATE must advance the flood counter"
1196 );
1197 self.debug_assert_invariants();
1198 }
1199
1200 /// The glitch counter, for tests that pin what counts as a glitch.
1201 #[cfg(test)]
1202 pub(super) fn glitch_count(&self) -> u32 {
1203 self.glitch_count
1204 }
1205
1206 /// Record one general protocol anomaly that does not fit a specific flood
1207 /// pattern (a frame on a closed stream, an unknown SETTINGS identifier, a
1208 /// zero-increment WINDOW_UPDATE on an already-closed stream, ...) toward
1209 /// the cumulative, half-decaying glitch counter.
1210 pub(super) fn record_glitch(&mut self) {
1211 let before = self.glitch_count;
1212 self.glitch_count += 1;
1213 debug_assert!(
1214 self.glitch_count > before || before == u32::MAX,
1215 "glitch counter must advance (or already be saturated)"
1216 );
1217 self.debug_assert_invariants();
1218 }
1219
1220 /// CVE-2024-27316: record one CONTINUATION frame toward the per-block
1221 /// count and accumulated header-block size. `payload_len` is the frame's
1222 /// own payload length (RFC 9113 §6.10).
1223 pub(super) fn record_continuation_frame(&mut self, payload_len: u32) {
1224 let cont_count_before = self.continuation_count;
1225 let acc_size_before = self.accumulated_header_size;
1226 self.continuation_count += 1;
1227 self.accumulated_header_size = self.accumulated_header_size.saturating_add(payload_len);
1228 // Per-block CONTINUATION accounting must grow monotonically within a
1229 // header block: each frame bumps the count by one and the
1230 // accumulated size by the frame's payload (never shrinks mid-block).
1231 // `reset_continuation` is the only thing allowed to zero these — and
1232 // only once the block is complete.
1233 debug_assert_eq!(
1234 self.continuation_count,
1235 cont_count_before + 1,
1236 "CONTINUATION per-block counter must advance by one per frame"
1237 );
1238 debug_assert!(
1239 self.accumulated_header_size >= acc_size_before,
1240 "accumulated header size must not shrink within a header block"
1241 );
1242 self.debug_assert_invariants();
1243 }
1244
1245 /// Seed [`Self::accumulated_header_size`] from the first HEADERS frame's
1246 /// own field-block fragment length, but ONLY the very first time a block
1247 /// starts (`continuation_count == 0`). A re-entry from
1248 /// `H2State::ContinuationFrame` (the accumulated block replayed through
1249 /// `handle_frame(Frame::Headers)` once assembly completes) must not stomp
1250 /// a running total a CONTINUATION frame may already have advanced.
1251 pub(super) fn begin_header_block_if_new(&mut self, initial_fragment_len: u32) {
1252 if self.continuation_count == 0 {
1253 self.accumulated_header_size = initial_fragment_len;
1254 }
1255 self.debug_assert_invariants();
1256 }
1257
1258 /// Half-decay rate-based counters if the current window has expired.
1259 /// Uses half-window decay instead of full reset to catch burst-then-wait attacks.
1260 ///
1261 /// `now` is the caller's snapshot rather than a fresh `Instant::now()`, so
1262 /// a window cannot decay part-way through a pass: a burst that arrives in
1263 /// one pass is weighed in full against the window that was open when the
1264 /// pass started. That is the fail-closed direction.
1265 fn maybe_reset_window(&mut self, now: Instant) {
1266 if now.saturating_duration_since(self.window_start) >= FLOOD_WINDOW_DURATION {
1267 let (rst_before, ping_before, settings_before) =
1268 (self.rst_stream_count, self.ping_count, self.settings_count);
1269 let (empty_before, wu0_before, glitch_before) = (
1270 self.empty_data_count,
1271 self.window_update_stream0_count,
1272 self.glitch_count,
1273 );
1274 self.rst_stream_count /= 2;
1275 self.ping_count /= 2;
1276 self.settings_count /= 2;
1277 self.empty_data_count /= 2;
1278 self.window_update_stream0_count /= 2;
1279 self.data_frames_recent /= 2;
1280 self.window_update_stream0_credit = self
1281 .window_update_stream0_credit
1282 .min(self.window_update_stream0_credit_cap());
1283 self.glitch_count /= 2;
1284 self.window_start = now;
1285 // Half-decay invariant: each rate-based counter is exactly halved
1286 // (integer division), never increased. Catching burst-then-wait
1287 // attacks relies on the counter shrinking but not vanishing — a
1288 // full reset would let a patient attacker reset to zero each window.
1289 debug_assert_eq!(self.rst_stream_count, rst_before / 2, "RST count halves");
1290 debug_assert_eq!(self.ping_count, ping_before / 2, "PING count halves");
1291 debug_assert_eq!(
1292 self.settings_count,
1293 settings_before / 2,
1294 "SETTINGS count halves"
1295 );
1296 debug_assert_eq!(
1297 self.empty_data_count,
1298 empty_before / 2,
1299 "empty-DATA count halves"
1300 );
1301 debug_assert_eq!(
1302 self.window_update_stream0_count,
1303 wu0_before / 2,
1304 "stream-0 WINDOW_UPDATE count halves"
1305 );
1306 debug_assert_eq!(self.glitch_count, glitch_before / 2, "glitch count halves");
1307 // The lifetime counters are deliberately NOT touched here — they are
1308 // the never-decaying ceilings. Guard against a future edit decaying
1309 // them by accident.
1310 debug_assert!(
1311 now.saturating_duration_since(self.window_start) < FLOOD_WINDOW_DURATION,
1312 "window_start must be refreshed to the caller's now after decay"
1313 );
1314 }
1315 self.debug_assert_invariants();
1316 }
1317
1318 /// Check all flood counters. Returns a [`H2FloodViolation`] when a threshold
1319 /// is exceeded; the caller is responsible for logging with session context
1320 /// and escalating to GOAWAY.
1321 ///
1322 /// `now` is the caller's clock snapshot — in production
1323 /// `ConnectionH2::now`, refreshed by `Mux` once per pass. The detector
1324 /// never reads the clock itself, so the ten `check_flood_or_return!` sites
1325 /// in `h2.rs` all weigh a burst against one instant.
1326 pub(super) fn check_flood(&mut self, now: Instant) -> Option<H2FloodViolation> {
1327 self.maybe_reset_window(now);
1328
1329 fn flag(
1330 reason: &'static str,
1331 metric_key: &'static str,
1332 count: u32,
1333 threshold: u32,
1334 ) -> Option<H2FloodViolation> {
1335 if count > threshold {
1336 Some(H2FloodViolation {
1337 error: H2Error::EnhanceYourCalm,
1338 reason,
1339 metric_key,
1340 count: count as u64,
1341 threshold: threshold as u64,
1342 })
1343 } else {
1344 None
1345 }
1346 }
1347
1348 let violation = flag(
1349 "RST_STREAM",
1350 "h2.flood.violation.rst_stream_window",
1351 self.rst_stream_count,
1352 self.config.max_rst_stream_per_window,
1353 )
1354 .or_else(|| {
1355 flag(
1356 "PING",
1357 "h2.flood.violation.ping_window",
1358 self.ping_count,
1359 self.config.max_ping_per_window,
1360 )
1361 })
1362 .or_else(|| {
1363 flag(
1364 "PING lifetime",
1365 "h2.flood.violation.ping_lifetime",
1366 self.total_ping_received_lifetime,
1367 DEFAULT_MAX_PING_LIFETIME,
1368 )
1369 })
1370 .or_else(|| {
1371 flag(
1372 "SETTINGS",
1373 "h2.flood.violation.settings_window",
1374 self.settings_count,
1375 self.config.max_settings_per_window,
1376 )
1377 })
1378 .or_else(|| {
1379 flag(
1380 "SETTINGS lifetime",
1381 "h2.flood.violation.settings_lifetime",
1382 self.total_settings_received_lifetime,
1383 DEFAULT_MAX_SETTINGS_LIFETIME,
1384 )
1385 })
1386 .or_else(|| {
1387 flag(
1388 "empty DATA",
1389 "h2.flood.violation.empty_data_window",
1390 self.empty_data_count,
1391 self.config.max_empty_data_per_window,
1392 )
1393 })
1394 .or_else(|| {
1395 flag(
1396 "CONTINUATION",
1397 "h2.flood.violation.continuation_per_block",
1398 self.continuation_count,
1399 self.config.max_continuation_frames,
1400 )
1401 })
1402 .or_else(|| {
1403 flag(
1404 "WINDOW_UPDATE stream 0",
1405 "h2.flood.violation.window_update_stream0_window",
1406 self.window_update_stream0_count,
1407 self.config.max_window_update_stream0_per_window,
1408 )
1409 })
1410 .or_else(|| {
1411 flag(
1412 "accumulated header size",
1413 "h2.flood.violation.header_size_per_block",
1414 self.accumulated_header_size,
1415 self.config.max_header_list_size,
1416 )
1417 })
1418 .or_else(|| {
1419 flag(
1420 "glitch",
1421 "h2.flood.violation.glitch_window",
1422 self.glitch_count,
1423 self.config.max_glitch_count,
1424 )
1425 });
1426 // Post-condition: any reported violation is well-formed — every H2
1427 // flood escalation is an ENHANCE_YOUR_CALM connection error, and the
1428 // observed count strictly exceeds the threshold it tripped (the `flag`
1429 // helper and the lifetime checks all use strict `>`). A violation whose
1430 // count <= threshold would be a false positive terminating a healthy
1431 // connection.
1432 debug_assert!(
1433 violation
1434 .as_ref()
1435 .is_none_or(|v| v.error == H2Error::EnhanceYourCalm && v.count > v.threshold),
1436 "a flood violation must be EnhanceYourCalm with count strictly above threshold"
1437 );
1438 violation
1439 }
1440
1441 /// Reset CONTINUATION-specific counters when a header block is complete.
1442 pub(super) fn reset_continuation(&mut self) {
1443 self.continuation_count = 0;
1444 self.accumulated_header_size = 0;
1445 // Post-condition: both CONTINUATION-block accumulators are cleared so
1446 // the next header block starts from zero (CVE-2024-27316 per-block
1447 // accounting must not leak across blocks).
1448 debug_assert_eq!(
1449 self.continuation_count, 0,
1450 "continuation_count must be zero after a block completes"
1451 );
1452 debug_assert_eq!(
1453 self.accumulated_header_size, 0,
1454 "accumulated_header_size must be zero after a block completes"
1455 );
1456 self.debug_assert_invariants();
1457 }
1458
1459 // ---- invariants ----------------------------------------------------------
1460
1461 /// TigerStyle invariant sweep. A single full check of every structural
1462 /// invariant this type must preserve, asserted at the END of every public
1463 /// mutating method via [`debug_assert_invariants`](Self::debug_assert_invariants)
1464 /// — exactly the pattern `h2_flow_control::H2FlowControl`,
1465 /// `h2_stream_table::H2StreamTable` and `protocol::udp::manager::UdpManager`
1466 /// use. Compiled out entirely in release (`#[cfg(debug_assertions)]`); on
1467 /// in every test/e2e/fuzz/dev build. Must never change runtime behavior —
1468 /// it only reads.
1469 #[cfg(debug_assertions)]
1470 fn check_invariants(&self) {
1471 // The abusive (pre-response-start) lifetime RST counter is a subset
1472 // of the total lifetime RST counter — `record_rst_lifetime` only ever
1473 // advances both together or the total alone, never the abusive
1474 // counter alone.
1475 debug_assert!(
1476 self.total_abusive_rst_received_lifetime <= self.total_rst_received_lifetime,
1477 "abusive RST count is a subset of total received RST count"
1478 );
1479 }
1480
1481 /// Run the full [`check_invariants`](Self::check_invariants) sweep, but
1482 /// only in debug builds — a thin wrapper so call sites read as one line
1483 /// and the whole sweep is dead-stripped in release.
1484 #[inline]
1485 fn debug_assert_invariants(&self) {
1486 #[cfg(debug_assertions)]
1487 self.check_invariants();
1488 }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::*;
1494
1495 // ── H2FloodDetector ──────────────────────────────────────────────────
1496
1497 /// Default thresholds except the pre-response floor and the soft-refusal
1498 /// percentage.
1499 fn soft_refusal_config(abusive_floor: u64, percent: Option<u32>) -> H2FloodConfig {
1500 H2FloodConfig::from_optional(
1501 None,
1502 None,
1503 None,
1504 None,
1505 None,
1506 None,
1507 None,
1508 None,
1509 Some(abusive_floor),
1510 None,
1511 None,
1512 None,
1513 None,
1514 percent,
1515 )
1516 }
1517
1518 /// Past half the pre-response floor, with more pre-response resets than
1519 /// answered streams, new streams are refused and no cap trips.
1520 #[test]
1521 fn test_soft_refusal_engages_past_half_the_pre_response_floor() {
1522 let base = Instant::now();
1523 let mut detector = H2FloodDetector::new(soft_refusal_config(100, None), base);
1524 for n in 1..=50 {
1525 assert!(detector.record_rst_received(false, false, base).is_none());
1526 assert!(
1527 !detector.refuses_new_streams(base),
1528 "{n} pre-response resets are not past half the floor"
1529 );
1530 }
1531 assert!(detector.record_rst_received(false, false, base).is_none());
1532 assert!(
1533 detector.refuses_new_streams(base),
1534 "51 pre-response resets are past half the floor of 100"
1535 );
1536 }
1537
1538 /// The pre-response count never decays and refused streams cannot raise
1539 /// the answered count, so the refusal ends one window after the last
1540 /// pre-response reset instead.
1541 #[test]
1542 fn test_soft_refusal_lifts_once_pre_response_resets_stop() {
1543 let base = Instant::now();
1544 let mut detector = H2FloodDetector::new(soft_refusal_config(100, None), base);
1545 for _ in 0..60 {
1546 assert!(detector.record_rst_received(false, false, base).is_none());
1547 }
1548 assert!(detector.refuses_new_streams(base), "premise: refusing");
1549 let later = base + FLOOD_WINDOW_DURATION;
1550 assert!(
1551 !detector.refuses_new_streams(later),
1552 "one window without a pre-response reset ends the refusal"
1553 );
1554 assert!(detector.record_rst_received(false, false, later).is_none());
1555 assert!(
1556 detector.refuses_new_streams(later),
1557 "a new pre-response reset past the soft threshold refuses again"
1558 );
1559 }
1560
1561 /// A client whose pre-response resets stay at or under its answered
1562 /// streams never trips the cap, so it is never refused either, however
1563 /// many resets it sends.
1564 #[test]
1565 fn test_soft_refusal_follows_the_pre_response_ratio() {
1566 let base = Instant::now();
1567 let mut detector = H2FloodDetector::new(soft_refusal_config(100, None), base);
1568 for _ in 0..90 {
1569 detector.record_stream_opened();
1570 }
1571 for _ in 0..90 {
1572 assert!(detector.record_rst_received(false, false, base).is_none());
1573 }
1574 assert!(
1575 !detector.refuses_new_streams(base),
1576 "90 pre-response resets against 90 answered streams are not refused"
1577 );
1578 assert!(detector.record_rst_received(false, false, base).is_none());
1579 assert!(
1580 detector.refuses_new_streams(base),
1581 "one more pre-response reset than answered streams is"
1582 );
1583 }
1584
1585 /// While new streams are refused, a peer that keeps resetting streams
1586 /// still reaches the pre-response cap, which ends the connection exactly
1587 /// as without the soft state, and the RST_STREAM rate limit, which has no
1588 /// soft state, trips as before.
1589 #[test]
1590 fn test_soft_refusal_leaves_the_hard_limit_unchanged() {
1591 let base = Instant::now();
1592 let floor = 100;
1593 let mut detector = H2FloodDetector::new(soft_refusal_config(floor, None), base);
1594 let mut tripped_at = None;
1595 for n in 1..=floor + 1 {
1596 if let Some(violation) = detector.record_rst_received(false, false, base) {
1597 assert_eq!(violation.error, H2Error::EnhanceYourCalm);
1598 assert_eq!(
1599 violation.metric_key,
1600 "h2.flood.violation.rst_stream_pre_response_lifetime"
1601 );
1602 tripped_at = Some(n);
1603 break;
1604 }
1605 if n > floor / 2 {
1606 assert!(detector.refuses_new_streams(base), "refusing at {n}");
1607 }
1608 }
1609 assert_eq!(
1610 tripped_at,
1611 Some(floor + 1),
1612 "the pre-response cap trips just past its floor, soft state or not"
1613 );
1614
1615 let config = H2FloodConfig::default();
1616 let mut detector = H2FloodDetector::new(config, base);
1617 for _ in 0..=config.max_rst_stream_per_window() {
1618 detector.record_rst_stream_window();
1619 }
1620 assert!(
1621 matches!(
1622 detector.check_flood(base),
1623 Some(H2FloodViolation {
1624 error: H2Error::EnhanceYourCalm,
1625 metric_key: "h2.flood.violation.rst_stream_window",
1626 ..
1627 })
1628 ),
1629 "the RST_STREAM rate limit still trips past its threshold"
1630 );
1631 }
1632
1633 /// `0` disables the soft state, and `100` never engages it before the
1634 /// cap itself: a peer at the very edge of the cap is not refused.
1635 #[test]
1636 fn test_soft_refusal_disabled_at_zero_and_inert_at_hundred() {
1637 let base = Instant::now();
1638 for percent in [0, 100, 250] {
1639 let mut detector = H2FloodDetector::new(soft_refusal_config(100, Some(percent)), base);
1640 for _ in 0..100 {
1641 assert!(detector.record_rst_received(false, false, base).is_none());
1642 }
1643 assert!(
1644 !detector.refuses_new_streams(base),
1645 "percent {percent} must not refuse below the cap"
1646 );
1647 }
1648 }
1649
1650 /// The RST_STREAM rate has no soft state: a client at the edge of the rate
1651 /// limit, whose resets all follow a response, is not refused.
1652 #[test]
1653 fn test_soft_refusal_ignores_the_rst_rate() {
1654 let base = Instant::now();
1655 let config = H2FloodConfig::default();
1656 let mut detector = H2FloodDetector::new(config, base);
1657 for _ in 0..config.max_rst_stream_per_window() {
1658 detector.record_rst_stream_window();
1659 assert!(detector.record_rst_received(true, true, base).is_none());
1660 }
1661 assert!(
1662 detector.check_flood(base).is_none(),
1663 "premise: at the rate limit"
1664 );
1665 assert!(
1666 !detector.refuses_new_streams(base),
1667 "resets after a response, however fast, are not refused"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_flood_detector_no_flood_below_threshold() {
1673 let base = Instant::now();
1674 let config = H2FloodConfig::default();
1675 let mut detector = H2FloodDetector::new(config, base);
1676
1677 // All counters at zero -> no flood
1678 assert!(detector.check_flood(base).is_none());
1679
1680 // Increment each counter to exactly the threshold (not exceeding)
1681 detector.rst_stream_count = config.max_rst_stream_per_window;
1682 detector.ping_count = config.max_ping_per_window;
1683 detector.settings_count = config.max_settings_per_window;
1684 detector.empty_data_count = config.max_empty_data_per_window;
1685 detector.continuation_count = config.max_continuation_frames;
1686 detector.glitch_count = config.max_glitch_count;
1687 // At threshold but not exceeding -> no flood
1688 assert!(detector.check_flood(base).is_none());
1689 }
1690
1691 #[test]
1692 fn test_flood_detector_detects_rapid_reset() {
1693 let base = Instant::now();
1694 let config = H2FloodConfig::default();
1695 let mut detector = H2FloodDetector::new(config, base);
1696
1697 detector.rst_stream_count = config.max_rst_stream_per_window + 1;
1698 assert!(matches!(
1699 detector.check_flood(base),
1700 Some(H2FloodViolation {
1701 error: H2Error::EnhanceYourCalm,
1702 ..
1703 })
1704 ));
1705 }
1706
1707 #[test]
1708 fn test_flood_detector_detects_ping_flood() {
1709 let base = Instant::now();
1710 let config = H2FloodConfig::default();
1711 let mut detector = H2FloodDetector::new(config, base);
1712
1713 detector.ping_count = config.max_ping_per_window + 1;
1714 assert!(matches!(
1715 detector.check_flood(base),
1716 Some(H2FloodViolation {
1717 error: H2Error::EnhanceYourCalm,
1718 ..
1719 })
1720 ));
1721 }
1722
1723 #[test]
1724 fn test_flood_detector_detects_settings_flood() {
1725 let base = Instant::now();
1726 let config = H2FloodConfig::default();
1727 let mut detector = H2FloodDetector::new(config, base);
1728
1729 detector.settings_count = config.max_settings_per_window + 1;
1730 assert!(matches!(
1731 detector.check_flood(base),
1732 Some(H2FloodViolation {
1733 error: H2Error::EnhanceYourCalm,
1734 ..
1735 })
1736 ));
1737 }
1738
1739 #[test]
1740 fn test_flood_detector_detects_empty_data_flood() {
1741 let base = Instant::now();
1742 let config = H2FloodConfig::default();
1743 let mut detector = H2FloodDetector::new(config, base);
1744
1745 detector.empty_data_count = config.max_empty_data_per_window + 1;
1746 assert!(matches!(
1747 detector.check_flood(base),
1748 Some(H2FloodViolation {
1749 error: H2Error::EnhanceYourCalm,
1750 ..
1751 })
1752 ));
1753 }
1754
1755 #[test]
1756 fn test_flood_detector_detects_continuation_flood() {
1757 let base = Instant::now();
1758 let config = H2FloodConfig::default();
1759 let mut detector = H2FloodDetector::new(config, base);
1760
1761 detector.continuation_count = config.max_continuation_frames + 1;
1762 assert!(matches!(
1763 detector.check_flood(base),
1764 Some(H2FloodViolation {
1765 error: H2Error::EnhanceYourCalm,
1766 ..
1767 })
1768 ));
1769 }
1770
1771 #[test]
1772 fn test_flood_detector_detects_header_size_flood() {
1773 let base = Instant::now();
1774 let config = H2FloodConfig::default();
1775 let mut detector = H2FloodDetector::new(config, base);
1776
1777 detector.accumulated_header_size = MAX_HEADER_LIST_SIZE as u32 + 1;
1778 assert!(matches!(
1779 detector.check_flood(base),
1780 Some(H2FloodViolation {
1781 error: H2Error::EnhanceYourCalm,
1782 ..
1783 })
1784 ));
1785 }
1786
1787 #[test]
1788 fn test_flood_detector_detects_glitch_flood() {
1789 let base = Instant::now();
1790 let config = H2FloodConfig::default();
1791 let mut detector = H2FloodDetector::new(config, base);
1792
1793 detector.glitch_count = config.max_glitch_count + 1;
1794 assert!(matches!(
1795 detector.check_flood(base),
1796 Some(H2FloodViolation {
1797 error: H2Error::EnhanceYourCalm,
1798 ..
1799 })
1800 ));
1801 }
1802
1803 #[test]
1804 fn test_flood_detector_custom_thresholds() {
1805 let base = Instant::now();
1806 let config = H2FloodConfig {
1807 max_rst_stream_per_window: 5,
1808 max_ping_per_window: 10,
1809 max_settings_per_window: 3,
1810 max_empty_data_per_window: 8,
1811 max_continuation_frames: 2,
1812 max_glitch_count: 15,
1813 ..H2FloodConfig::default()
1814 };
1815 let mut detector = H2FloodDetector::new(config, base);
1816
1817 // Below custom threshold -> no flood
1818 detector.rst_stream_count = 5;
1819 assert!(detector.check_flood(base).is_none());
1820
1821 // Above custom threshold -> flood
1822 detector.rst_stream_count = 6;
1823 assert!(matches!(
1824 detector.check_flood(base),
1825 Some(H2FloodViolation {
1826 error: H2Error::EnhanceYourCalm,
1827 ..
1828 })
1829 ));
1830 }
1831
1832 #[test]
1833 fn test_flood_detector_reset_continuation() {
1834 let base = Instant::now();
1835 let config = H2FloodConfig::default();
1836 let mut detector = H2FloodDetector::new(config, base);
1837
1838 detector.continuation_count = 15;
1839 detector.accumulated_header_size = 30000;
1840
1841 detector.reset_continuation();
1842
1843 assert_eq!(detector.continuation_count, 0);
1844 assert_eq!(detector.accumulated_header_size, 0);
1845 }
1846
1847 #[test]
1848 fn test_flood_detector_half_decay_on_window_expiry() {
1849 let base = Instant::now();
1850 let config = H2FloodConfig::default();
1851 let mut detector = H2FloodDetector::new(config, base);
1852
1853 detector.rst_stream_count = 80;
1854 detector.ping_count = 60;
1855 detector.settings_count = 40;
1856 detector.empty_data_count = 20;
1857 detector.window_update_stream0_count = 90;
1858 detector.glitch_count = 50;
1859
1860 // Expiry is expressed as the instant the caller hands in, not by
1861 // back-dating the detector's own window: the detector no longer reads
1862 // a clock, so `base + FLOOD_WINDOW_DURATION` IS the next window.
1863 // No sleep, no wall-clock dependency.
1864 //
1865 // To SEE THIS RED: in `maybe_reset_window`, change the guard to
1866 // `> FLOOD_WINDOW_DURATION` (strict). The boundary instant then no
1867 // longer expires the window, nothing decays, and all six asserts
1868 // below fail with the undecayed values (80/60/40/20/90/50).
1869 let _ = detector.check_flood(base + FLOOD_WINDOW_DURATION);
1870
1871 assert_eq!(detector.rst_stream_count, 40);
1872 assert_eq!(detector.ping_count, 30);
1873 assert_eq!(detector.settings_count, 20);
1874 assert_eq!(detector.empty_data_count, 10);
1875 assert_eq!(detector.window_update_stream0_count, 45);
1876 assert_eq!(detector.glitch_count, 25);
1877 }
1878
1879 #[test]
1880 fn test_flood_detector_window_update_stream0_trips_at_threshold() {
1881 let base = Instant::now();
1882 let config = H2FloodConfig {
1883 max_window_update_stream0_per_window: 5,
1884 ..H2FloodConfig::default()
1885 };
1886 let mut detector = H2FloodDetector::new(config, base);
1887
1888 // At threshold — no flood yet (strict greater-than, matches existing counters).
1889 detector.window_update_stream0_count = 5;
1890 assert!(detector.check_flood(base).is_none());
1891
1892 // Above threshold — flood with the correct violation reason + metric key.
1893 detector.window_update_stream0_count = 6;
1894 let violation = detector
1895 .check_flood(base)
1896 .expect("WINDOW_UPDATE stream-0 flood must trip above threshold");
1897 assert_eq!(violation.error, H2Error::EnhanceYourCalm);
1898 assert_eq!(violation.reason, "WINDOW_UPDATE stream 0");
1899 assert_eq!(
1900 violation.metric_key,
1901 "h2.flood.violation.window_update_stream0_window"
1902 );
1903 assert_eq!(violation.count, 6);
1904 assert_eq!(violation.threshold, 5);
1905 }
1906
1907 #[test]
1908 fn test_flood_detector_window_update_stream0_honours_default() {
1909 // Default threshold must match the documented constant so operators
1910 // can reason about behaviour without reading code.
1911 let detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
1912 assert_eq!(
1913 detector.config.max_window_update_stream0_per_window,
1914 DEFAULT_MAX_WINDOW_UPDATE_STREAM0_PER_WINDOW
1915 );
1916 assert_eq!(detector.window_update_stream0_count, 0);
1917 }
1918
1919 #[test]
1920 fn test_flood_detector_decay_prevents_flood() {
1921 let base = Instant::now();
1922 let config = H2FloodConfig {
1923 max_rst_stream_per_window: 10,
1924 ..H2FloodConfig::default()
1925 };
1926 let mut detector = H2FloodDetector::new(config, base);
1927
1928 // Set counter just above threshold
1929 detector.rst_stream_count = 12;
1930
1931 // Without decay -> flood
1932 assert!(matches!(
1933 detector.check_flood(base),
1934 Some(H2FloodViolation {
1935 error: H2Error::EnhanceYourCalm,
1936 ..
1937 })
1938 ));
1939
1940 // Reset and cross into the next window via the injected instant.
1941 detector.rst_stream_count = 12;
1942
1943 // After decay: 12/2 = 6, which is below threshold 10 -> no flood.
1944 //
1945 // To SEE THIS RED: make `maybe_reset_window` take `Instant::now()`
1946 // instead of its `now` parameter. The injected future instant is then
1947 // ignored, the window never expires, the count stays 12 > 10 and this
1948 // assert trips on the flood it should have decayed away.
1949 assert!(detector.check_flood(base + FLOOD_WINDOW_DURATION).is_none());
1950 }
1951
1952 #[test]
1953 fn test_flood_detector_lifetime_rst_cap_triggers_enhance_your_calm() {
1954 // CVE-2023-44487 Rapid Reset: a patient attacker that stays under
1955 // the half-decaying per-window threshold must still be stopped by
1956 // the lifetime cap. Simulate a response-started RST (no abusive
1957 // counter bump) so only the lifetime ceiling is tested.
1958 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
1959 for _ in 0..DEFAULT_MAX_RST_STREAM_LIFETIME {
1960 assert!(detector.record_rst_lifetime(true).is_none());
1961 }
1962 assert_eq!(
1963 detector.total_rst_received_lifetime,
1964 DEFAULT_MAX_RST_STREAM_LIFETIME
1965 );
1966 assert_eq!(detector.total_abusive_rst_received_lifetime, 0);
1967 // Next RST crosses the ceiling.
1968 assert!(matches!(
1969 detector.record_rst_lifetime(true),
1970 Some(H2FloodViolation {
1971 error: H2Error::EnhanceYourCalm,
1972 ..
1973 })
1974 ));
1975 }
1976
1977 #[test]
1978 fn test_flood_detector_abusive_rst_cap_triggers_first() {
1979 // Pre-response-start RSTs have a much lower ceiling; they trip
1980 // well before the generic lifetime cap.
1981 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
1982 for _ in 0..DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME {
1983 assert!(detector.record_rst_lifetime(false).is_none());
1984 }
1985 assert_eq!(
1986 detector.total_abusive_rst_received_lifetime,
1987 DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME
1988 );
1989 assert!(matches!(
1990 detector.record_rst_lifetime(false),
1991 Some(H2FloodViolation {
1992 error: H2Error::EnhanceYourCalm,
1993 ..
1994 })
1995 ));
1996 }
1997
1998 #[test]
1999 fn test_flood_detector_emitted_rst_below_threshold_is_clean() {
2000 // Server may legitimately RST some streams (protocol errors,
2001 // client-side abuse caught by other mitigations). Staying at the
2002 // threshold must not trip the ceiling.
2003 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2004 for _ in 0..DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME {
2005 assert!(detector.record_rst_emitted().is_none());
2006 }
2007 assert_eq!(
2008 detector.total_rst_streams_emitted_lifetime,
2009 DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME
2010 );
2011 }
2012
2013 #[test]
2014 fn test_flood_detector_emitted_rst_cap_triggers_made_you_reset() {
2015 // CVE-2025-8671 MadeYouReset: unbounded server-emitted RST_STREAM is
2016 // a DoS vector equivalent to Rapid Reset with the emission direction
2017 // flipped. Crossing the ceiling must surface a EnhanceYourCalm
2018 // violation so the caller can GOAWAY.
2019 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2020 for _ in 0..DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME {
2021 assert!(detector.record_rst_emitted().is_none());
2022 }
2023 let violation = detector
2024 .record_rst_emitted()
2025 .expect("emitting past the cap should produce a violation");
2026 assert!(matches!(
2027 violation,
2028 H2FloodViolation {
2029 error: H2Error::EnhanceYourCalm,
2030 reason: "MadeYouReset: lifetime server-emitted RST_STREAM",
2031 ..
2032 }
2033 ));
2034 assert_eq!(violation.count, DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME + 1);
2035 assert_eq!(violation.threshold, DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME);
2036 }
2037
2038 #[test]
2039 fn test_flood_detector_emitted_rst_counter_does_not_decay() {
2040 let base = Instant::now();
2041 // Unlike the windowed rst_stream_count, the emitted lifetime counter
2042 // is strictly monotonic — a patient attacker cannot reset it by
2043 // waiting out a window. maybe_reset_window must NOT touch it.
2044 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), base);
2045 for _ in 0..10 {
2046 detector.record_rst_emitted();
2047 }
2048 // Force a window reset through the injected instant.
2049 //
2050 // To SEE THIS RED: add `self.total_rst_streams_emitted_lifetime /= 2;`
2051 // to the decay block in `maybe_reset_window`. The counter halves to 5
2052 // and this assert fails — which is the MadeYouReset ceiling (CVE-2025-8671)
2053 // becoming evadable by waiting out a window.
2054 let _ = detector.check_flood(base + FLOOD_WINDOW_DURATION);
2055 assert_eq!(detector.total_rst_streams_emitted_lifetime, 10);
2056 }
2057
2058 /// Every violation kind must carry a metric_key under the agreed
2059 /// `h2.flood.violation.*` namespace, and the keys must be unique. The
2060 /// statsd counter at `ConnectionH2::handle_flood_violation` reads
2061 /// `violation.metric_key` directly — drift between the construction site
2062 /// and the metric name would silently lose alerting on a CVE mitigation.
2063 #[test]
2064 fn test_flood_violation_metric_keys_are_unique_and_namespaced() {
2065 // Helper: run `record_rst_lifetime` until it trips, returning the metric_key.
2066 fn key_from_rst_lifetime(response_started: bool) -> &'static str {
2067 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2068 loop {
2069 if let Some(v) = detector.record_rst_lifetime(response_started) {
2070 return v.metric_key;
2071 }
2072 }
2073 }
2074
2075 // Helper: run `record_rst_emitted` until it trips, returning the metric_key.
2076 fn key_from_rst_emitted() -> &'static str {
2077 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2078 loop {
2079 if let Some(v) = detector.record_rst_emitted() {
2080 return v.metric_key;
2081 }
2082 }
2083 }
2084
2085 // Helper: drive a single `check_flood` counter past its threshold.
2086 // A nested `fn` cannot capture, so it takes its own snapshot; seeding
2087 // the window and checking it at the same instant means no decay can
2088 // interfere with the threshold this helper is probing.
2089 fn key_from_check_flood(setup: impl FnOnce(&mut H2FloodDetector)) -> &'static str {
2090 let now = Instant::now();
2091 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), now);
2092 setup(&mut detector);
2093 detector
2094 .check_flood(now)
2095 .expect("setup should always trip a flood")
2096 .metric_key
2097 }
2098
2099 let keys: [&'static str; 13] = [
2100 // Lifetime methods on the detector itself.
2101 key_from_rst_lifetime(true),
2102 key_from_rst_lifetime(false),
2103 key_from_rst_emitted(),
2104 // `check_flood` arms.
2105 key_from_check_flood(|d| d.rst_stream_count = u32::MAX),
2106 key_from_check_flood(|d| d.ping_count = u32::MAX),
2107 key_from_check_flood(|d| d.total_ping_received_lifetime = u32::MAX),
2108 key_from_check_flood(|d| d.settings_count = u32::MAX),
2109 key_from_check_flood(|d| d.total_settings_received_lifetime = u32::MAX),
2110 key_from_check_flood(|d| d.empty_data_count = u32::MAX),
2111 key_from_check_flood(|d| d.continuation_count = u32::MAX),
2112 key_from_check_flood(|d| d.window_update_stream0_count = u32::MAX),
2113 key_from_check_flood(|d| d.accumulated_header_size = u32::MAX),
2114 key_from_check_flood(|d| d.glitch_count = u32::MAX),
2115 ];
2116
2117 for key in keys {
2118 assert!(
2119 key.starts_with("h2.flood.violation."),
2120 "metric key {key} is missing the h2.flood.violation. prefix",
2121 );
2122 }
2123 let mut deduped = keys.to_vec();
2124 deduped.sort_unstable();
2125 deduped.dedup();
2126 assert_eq!(
2127 deduped.len(),
2128 keys.len(),
2129 "metric keys must be unique across violation kinds; collisions: {keys:?}",
2130 );
2131 let mut published = crate::metrics::names::h2::FLOOD_VIOLATION_KEYS.to_vec();
2132 published.sort_unstable();
2133 assert_eq!(
2134 deduped, published,
2135 "names::h2::FLOOD_VIOLATION_KEYS (read by `sozu top`) must list exactly the emitted keys",
2136 );
2137 }
2138
2139 #[test]
2140 fn test_flood_detector_response_started_rst_not_abusive() {
2141 // When the backend response has begun, the RST is cheap for us
2142 // too — it only bumps the generic lifetime counter.
2143 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2144 for _ in 0..(DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME + 100) {
2145 assert!(detector.record_rst_lifetime(true).is_none());
2146 }
2147 assert_eq!(detector.total_abusive_rst_received_lifetime, 0);
2148 assert_eq!(
2149 detector.total_rst_received_lifetime,
2150 DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME + 100
2151 );
2152 }
2153
2154 // ── RST caps scale with the streams a backend answered ──────────────────────────
2155
2156 /// A long-lived connection that cancels a minority of its streams before
2157 /// the response — browser prefetches, an SPA aborting requests — never
2158 /// reaches the pre-response cap, however many cancels accumulate.
2159 ///
2160 /// TO SEE THIS RED: in `H2FloodDetector::record_rst_lifetime`, compare
2161 /// `total_abusive_rst_received_lifetime` against the floor alone
2162 /// (`> self.config.max_rst_stream_abusive_lifetime`). The 1001st cancel
2163 /// then trips although only a tenth of the streams were cancelled.
2164 #[test]
2165 fn test_flood_detector_minority_pre_response_cancels_never_trip() {
2166 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2167 for stream in 0..100_000u64 {
2168 detector.record_stream_opened();
2169 if stream % 10 == 0 {
2170 assert!(
2171 detector.record_rst_lifetime(false).is_none(),
2172 "cancel #{} of {} streams must not trip",
2173 detector.total_abusive_rst_received_lifetime,
2174 detector.streams_opened
2175 );
2176 }
2177 }
2178 assert_eq!(detector.total_abusive_rst_received_lifetime, 10_000);
2179 }
2180
2181 /// The Rapid Reset client resets every stream it opens: half of the
2182 /// streams is reached at once, so the cap trips right past the floor.
2183 #[test]
2184 fn test_flood_detector_rapid_reset_trips_just_past_the_floor() {
2185 // A stream reset before its response is never answered, so it never
2186 // counts toward `streams_opened`.
2187 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2188 for _ in 0..DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME {
2189 assert!(detector.record_rst_lifetime(false).is_none());
2190 }
2191 let violation = detector
2192 .record_rst_lifetime(false)
2193 .expect("a reset of every opened stream must trip past the floor");
2194 assert_eq!(
2195 violation.metric_key,
2196 "h2.flood.violation.rst_stream_pre_response_lifetime"
2197 );
2198 assert_eq!(violation.count, DEFAULT_MAX_RST_STREAM_ABUSIVE_LIFETIME + 1);
2199 }
2200
2201 /// Past the floor, the pre-response cap holds at half of all streams:
2202 /// `streams_opened` counts the answered ones and excludes the reset
2203 /// ones, so more resets than answered streams is more than half.
2204 #[test]
2205 fn test_flood_detector_pre_response_cap_is_half_of_streams_opened() {
2206 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2207 for _ in 0..5000 {
2208 detector.record_stream_opened();
2209 }
2210 for _ in 0..5000 {
2211 assert!(detector.record_rst_lifetime(false).is_none());
2212 }
2213 assert!(
2214 detector.record_rst_lifetime(false).is_some(),
2215 "5001 pre-response resets beside 5000 answered streams is more than half"
2216 );
2217 }
2218
2219 /// Forty percent of streams cancelled before their response, over a
2220 /// connection long enough to pass the floor many times, never trips;
2221 /// sixty percent does.
2222 #[test]
2223 fn test_flood_detector_pre_response_cap_trips_only_above_half() {
2224 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2225 for round in 0..10_000 {
2226 for _ in 0..3 {
2227 detector.record_stream_opened();
2228 }
2229 for _ in 0..2 {
2230 assert!(
2231 detector.record_rst_lifetime(false).is_none(),
2232 "40% pre-response cancels must not trip (round {round})"
2233 );
2234 }
2235 }
2236
2237 let mut flood = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2238 let mut tripped = false;
2239 for _ in 0..10_000 {
2240 for _ in 0..2 {
2241 flood.record_stream_opened();
2242 }
2243 for _ in 0..3 {
2244 tripped |= flood.record_rst_lifetime(false).is_some();
2245 }
2246 }
2247 assert!(tripped, "60% pre-response cancels must trip past the floor");
2248 }
2249
2250 /// Peer-provoked emitted resets follow the same shape: a minority over a
2251 /// long connection never trips, one per stream trips past the floor.
2252 ///
2253 /// TO SEE THIS RED: in `H2FloodDetector::record_rst_emitted`, compare
2254 /// against the floor alone; the 10 001st provoked reset trips although it
2255 /// is one in twenty streams.
2256 #[test]
2257 fn test_flood_detector_emitted_cap_scales_with_streams_opened() {
2258 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2259 for stream in 0..400_000u64 {
2260 detector.record_stream_opened();
2261 if stream % 20 == 0 {
2262 assert!(detector.record_rst_emitted().is_none());
2263 }
2264 }
2265 assert_eq!(detector.total_rst_streams_emitted_lifetime, 20_000);
2266
2267 let mut flood = H2FloodDetector::new(H2FloodConfig::default(), Instant::now());
2268 for _ in 0..DEFAULT_MAX_RST_STREAM_EMITTED_LIFETIME {
2269 assert!(flood.record_rst_emitted().is_none());
2270 }
2271 assert!(
2272 flood.record_rst_emitted().is_some(),
2273 "one provoked reset per opened stream must trip past the floor"
2274 );
2275 }
2276
2277 /// Pre-response resets the peer sends and resets it provokes Sōzu into
2278 /// are one budget: a peer that resets every stream before its response,
2279 /// half one way and half the other, trips the cap.
2280 #[test]
2281 fn test_flood_detector_pre_response_caps_share_one_count() {
2282 let config = H2FloodConfig {
2283 max_rst_stream_abusive_lifetime: 10,
2284 max_rst_stream_emitted_lifetime: 10,
2285 ..H2FloodConfig::default()
2286 };
2287 let mut detector = H2FloodDetector::new(config, Instant::now());
2288 for _ in 0..100 {
2289 detector.record_stream_opened();
2290 }
2291 let mut tripped = false;
2292 for _ in 0..60 {
2293 tripped |= detector.record_rst_lifetime(false).is_some();
2294 tripped |= detector.record_rst_emitted().is_some();
2295 }
2296 assert!(
2297 tripped,
2298 "120 pre-response resets beside 100 answered streams is more than half"
2299 );
2300 }
2301
2302 // ── stream-0 WINDOW_UPDATE credit ───────────────────────────────────
2303
2304 /// A peer returning connection credit for every DATA frame it receives
2305 /// sends one stream-0 WINDOW_UPDATE per frame, inside one window, as
2306 /// fast as the download goes. None of them is counted.
2307 ///
2308 /// TO SEE THIS RED: in `H2FloodDetector::record_window_update_stream0`,
2309 /// always advance `window_update_stream0_count`. The 2001st update trips.
2310 #[test]
2311 fn test_flood_detector_window_updates_answering_data_are_not_counted() {
2312 let now = Instant::now();
2313 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), now);
2314 // 64 MiB in 16 KiB frames, acknowledged one frame at a time.
2315 for _ in 0..4096 {
2316 detector.record_data_frames_sent(1);
2317 detector.record_window_update_stream0();
2318 assert!(detector.check_flood(now).is_none());
2319 }
2320 assert_eq!(detector.window_update_stream0_count, 0);
2321 }
2322
2323 /// A peer acknowledging every half of a 1 MiB window sends one update
2324 /// per 32 frames of 16 KiB: at any throughput, all are credited. A Sōzu
2325 /// in front, at one per half of its 16 MiB window, sends fewer still.
2326 #[test]
2327 fn test_flood_detector_chained_proxy_window_updates_are_not_counted() {
2328 let now = Instant::now();
2329 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), now);
2330 for _ in 0..10_000 {
2331 detector.record_data_frames_sent(32);
2332 detector.record_window_update_stream0();
2333 }
2334 assert!(detector.check_flood(now).is_none());
2335 assert_eq!(detector.window_update_stream0_count, 0);
2336 }
2337
2338 /// With no DATA sent, every stream-0 WINDOW_UPDATE is unsolicited and the
2339 /// per-window threshold still trips on the flood.
2340 #[test]
2341 fn test_flood_detector_unsolicited_window_updates_still_trip() {
2342 let now = Instant::now();
2343 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), now);
2344 for _ in 0..DEFAULT_MAX_WINDOW_UPDATE_STREAM0_PER_WINDOW {
2345 detector.record_window_update_stream0();
2346 assert!(detector.check_flood(now).is_none());
2347 }
2348 detector.record_window_update_stream0();
2349 let violation = detector
2350 .check_flood(now)
2351 .expect("an unsolicited stream-0 WINDOW_UPDATE flood must trip");
2352 assert_eq!(
2353 violation.metric_key,
2354 "h2.flood.violation.window_update_stream0_window"
2355 );
2356 }
2357
2358 /// Credit is bounded by the DATA actually sent: once spent, the excess is
2359 /// counted again.
2360 #[test]
2361 fn test_flood_detector_window_update_credit_is_spent_once() {
2362 let now = Instant::now();
2363 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), now);
2364 detector.record_data_frames_sent(10);
2365 for _ in 0..(10 * WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME) {
2366 detector.record_window_update_stream0();
2367 }
2368 assert_eq!(detector.window_update_stream0_count, 0);
2369 assert_eq!(detector.window_update_stream0_credit, 0);
2370 detector.record_window_update_stream0();
2371 assert_eq!(detector.window_update_stream0_count, 1);
2372 }
2373
2374 /// A long download cannot bank credit for a later flood: once the DATA
2375 /// stops, the credit falls back to twice the per-window threshold within
2376 /// a few windows, and updates past it count again.
2377 #[test]
2378 fn test_flood_detector_window_update_credit_is_capped() {
2379 let now = Instant::now();
2380 let mut detector = H2FloodDetector::new(H2FloodConfig::default(), now);
2381 detector.record_data_frames_sent(1_000_000);
2382 assert_eq!(detector.window_update_stream0_credit, 2_000_000);
2383 let mut at = now;
2384 for _ in 0..32 {
2385 at += FLOOD_WINDOW_DURATION;
2386 assert!(detector.check_flood(at).is_none());
2387 }
2388 let floor = u64::from(DEFAULT_MAX_WINDOW_UPDATE_STREAM0_PER_WINDOW)
2389 * WINDOW_UPDATE_STREAM0_CREDIT_PER_DATA_FRAME;
2390 assert_eq!(detector.window_update_stream0_credit, floor);
2391 for _ in 0..floor {
2392 detector.record_window_update_stream0();
2393 }
2394 assert_eq!(detector.window_update_stream0_count, 0);
2395 detector.record_window_update_stream0();
2396 assert_eq!(detector.window_update_stream0_count, 1);
2397 }
2398
2399 /// A fast download with the peer's acknowledgements lagging a full
2400 /// window behind is not counted, even at a low threshold: the cap grows
2401 /// with the DATA in flight.
2402 #[test]
2403 fn test_flood_detector_window_update_credit_covers_data_in_flight() {
2404 let now = Instant::now();
2405 let config = H2FloodConfig {
2406 max_window_update_stream0_per_window: 100,
2407 ..H2FloodConfig::default()
2408 };
2409 let mut detector = H2FloodDetector::new(config, now);
2410 // 15 MiB of 16 KiB frames sent before the first update arrives.
2411 detector.record_data_frames_sent(960);
2412 for _ in 0..960 {
2413 detector.record_window_update_stream0();
2414 }
2415 assert!(detector.check_flood(now).is_none());
2416 assert_eq!(detector.window_update_stream0_count, 0);
2417 }
2418
2419 // ── H2FloodConfig ──────────────────────────────────────────────────
2420
2421 #[test]
2422 fn test_flood_config_default_values() {
2423 let config = H2FloodConfig::default();
2424 assert_eq!(config.max_rst_stream_per_window, 2000);
2425 assert_eq!(config.max_ping_per_window, 2000);
2426 assert_eq!(config.max_settings_per_window, 1000);
2427 assert_eq!(config.max_empty_data_per_window, 2000);
2428 assert_eq!(config.max_window_update_stream0_per_window, 2000);
2429 // Memory bounds, deliberately not scaled with the rate thresholds.
2430 assert_eq!(config.max_continuation_frames, 20);
2431 assert_eq!(config.max_header_fields, 128);
2432 assert_eq!(config.max_glitch_count, 2000);
2433 assert_eq!(config.max_rst_stream_lifetime, 200_000);
2434 assert_eq!(config.max_rst_stream_abusive_lifetime, 1000);
2435 assert_eq!(config.max_rst_stream_emitted_lifetime, 10_000);
2436 assert_eq!(config.max_header_list_size, MAX_HEADER_LIST_SIZE as u32);
2437 }
2438
2439 #[test]
2440 fn test_flood_config_default_matches_constants() {
2441 let config = H2FloodConfig::default();
2442 assert_eq!(
2443 config.max_rst_stream_per_window,
2444 DEFAULT_MAX_RST_STREAM_PER_WINDOW
2445 );
2446 assert_eq!(config.max_ping_per_window, DEFAULT_MAX_PING_PER_WINDOW);
2447 assert_eq!(
2448 config.max_settings_per_window,
2449 DEFAULT_MAX_SETTINGS_PER_WINDOW
2450 );
2451 assert_eq!(
2452 config.max_empty_data_per_window,
2453 DEFAULT_MAX_EMPTY_DATA_PER_WINDOW
2454 );
2455 assert_eq!(
2456 config.max_continuation_frames,
2457 DEFAULT_MAX_CONTINUATION_FRAMES
2458 );
2459 assert_eq!(config.max_glitch_count, DEFAULT_MAX_GLITCH_COUNT);
2460 }
2461
2462 #[test]
2463 fn test_flood_config_equality() {
2464 let config_a = H2FloodConfig::default();
2465 let config_b = H2FloodConfig::default();
2466 assert_eq!(config_a, config_b);
2467
2468 let config_c = H2FloodConfig {
2469 max_rst_stream_per_window: 1,
2470 ..H2FloodConfig::default()
2471 };
2472 assert_ne!(config_a, config_c);
2473 }
2474
2475 /// An unset knob keeps the compile-time default, exactly as the
2476 /// `unwrap_or(defaults.<field>)` table `lib/src/http.rs` and
2477 /// `lib/src/https.rs` used to carry did.
2478 #[test]
2479 fn test_flood_config_from_optional_all_none_is_default() {
2480 let config = H2FloodConfig::from_optional(
2481 None, None, None, None, None, None, None, None, None, None, None, None, None, None,
2482 );
2483 assert_eq!(config, H2FloodConfig::default());
2484 }
2485
2486 /// `from_optional` takes fourteen same-typed positional arguments, so a
2487 /// transposed pair compiles silently and would quietly apply one operator
2488 /// knob's value to a different CVE's threshold. Fourteen distinct values,
2489 /// each asserted against its own field, is what makes that a test failure
2490 /// instead of a production surprise.
2491 #[test]
2492 fn test_flood_config_from_optional_maps_each_knob_to_its_own_field() {
2493 let config = H2FloodConfig::from_optional(
2494 Some(11),
2495 Some(12),
2496 Some(13),
2497 Some(14),
2498 Some(15),
2499 Some(16),
2500 Some(17),
2501 Some(18),
2502 Some(19),
2503 Some(20),
2504 Some(21),
2505 Some(22),
2506 Some(23),
2507 Some(24),
2508 );
2509 assert_eq!(config.max_rst_stream_per_window, 11);
2510 assert_eq!(config.max_ping_per_window, 12);
2511 assert_eq!(config.max_settings_per_window, 13);
2512 assert_eq!(config.max_empty_data_per_window, 14);
2513 assert_eq!(config.max_window_update_stream0_per_window, 15);
2514 assert_eq!(config.max_continuation_frames, 16);
2515 assert_eq!(config.max_glitch_count, 17);
2516 assert_eq!(config.max_rst_stream_lifetime, 18);
2517 assert_eq!(config.max_rst_stream_abusive_lifetime, 19);
2518 assert_eq!(config.max_rst_stream_emitted_lifetime, 20);
2519 assert_eq!(config.max_header_list_size, 21);
2520 assert_eq!(config.max_header_table_size, 22);
2521 assert_eq!(config.max_header_fields, 23);
2522 assert_eq!(config.stream_refusal_percent, 24);
2523 }
2524
2525 /// Regression for sozu-proxy/sozu#1418: a zero that reached the runtime
2526 /// without passing config load must be clamped, not carried. `check_flood`
2527 /// compares `count > threshold`, so a zero per-window threshold trips on
2528 /// the first counted frame of every connection.
2529 #[test]
2530 fn test_flood_config_from_optional_clamps_every_zero_to_one() {
2531 let config = H2FloodConfig::from_optional(
2532 Some(0),
2533 Some(0),
2534 Some(0),
2535 Some(0),
2536 Some(0),
2537 Some(0),
2538 Some(0),
2539 Some(0),
2540 Some(0),
2541 Some(0),
2542 Some(0),
2543 Some(0),
2544 Some(0),
2545 Some(0),
2546 );
2547 assert_eq!(config.max_rst_stream_per_window, 1);
2548 assert_eq!(config.max_ping_per_window, 1);
2549 assert_eq!(config.max_settings_per_window, 1);
2550 assert_eq!(config.max_empty_data_per_window, 1);
2551 assert_eq!(config.max_window_update_stream0_per_window, 1);
2552 assert_eq!(config.max_continuation_frames, 1);
2553 assert_eq!(config.max_glitch_count, 1);
2554 assert_eq!(config.max_rst_stream_lifetime, 1);
2555 assert_eq!(config.max_rst_stream_abusive_lifetime, 1);
2556 assert_eq!(config.max_rst_stream_emitted_lifetime, 1);
2557 assert_eq!(config.max_header_list_size, 1);
2558 assert_eq!(config.max_header_table_size, 1);
2559 assert_eq!(config.max_header_fields, 1);
2560 // The one knob a zero does not clamp: it disables the soft refusal.
2561 assert_eq!(config.stream_refusal_percent, 0);
2562 }
2563
2564 /// Every accessor reads back its own field — the same transposition guard
2565 /// as above, on the read side that `h2.rs` uses.
2566 #[test]
2567 fn test_flood_config_accessors_read_their_own_field() {
2568 let config = H2FloodConfig::from_optional(
2569 Some(11),
2570 Some(12),
2571 Some(13),
2572 Some(14),
2573 Some(15),
2574 Some(16),
2575 Some(17),
2576 Some(18),
2577 Some(19),
2578 Some(20),
2579 Some(21),
2580 Some(22),
2581 Some(23),
2582 Some(24),
2583 );
2584 assert_eq!(config.max_rst_stream_per_window(), 11);
2585 assert_eq!(config.max_ping_per_window(), 12);
2586 assert_eq!(config.max_settings_per_window(), 13);
2587 assert_eq!(config.max_empty_data_per_window(), 14);
2588 assert_eq!(config.max_window_update_stream0_per_window(), 15);
2589 assert_eq!(config.max_continuation_frames(), 16);
2590 assert_eq!(config.max_glitch_count(), 17);
2591 assert_eq!(config.max_rst_stream_lifetime(), 18);
2592 assert_eq!(config.max_rst_stream_abusive_lifetime(), 19);
2593 assert_eq!(config.max_rst_stream_emitted_lifetime(), 20);
2594 assert_eq!(config.max_header_list_size(), 21);
2595 assert_eq!(config.max_header_table_size(), 22);
2596 assert_eq!(config.max_header_fields(), 23);
2597 assert_eq!(config.stream_refusal_percent(), 24);
2598 }
2599}