Skip to main content

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}