cratestack_axum/ratelimit/policy.rs
1//! What the rate-limit layer does when the *store* itself fails
2//! (cratestack#846), and how long it is willing to wait to find out.
3
4use std::time::Duration;
5
6use cratestack_core::CratestackError;
7use cratestack_core::log_throttle::LogThrottle;
8
9/// How [`super::RateLimitLayer`] treats a failure of the backing
10/// [`super::RateLimitStore`], as distinct from a caller who is genuinely
11/// over budget.
12///
13/// # The distinction that matters is transport vs logical, not open vs closed
14///
15/// The first cut of this knob was "on any store error, allow". A security
16/// review falsified the premise it rested on — that a store failure is
17/// never caller-controlled — with a measured attack:
18/// [`super::key_fn::default_key_fn`] hashes an **unvalidated**
19/// `Authorization` header (this layer runs before authentication), so an
20/// unauthenticated caller mints one Redis key per request just by
21/// rotating that header. Drive that until the instance reaches
22/// `maxmemory` and every subsequent `HSET` fails with `OOM` — at which
23/// point a blanket fail-open serves *every* request unthrottled,
24/// including from buckets that were already exhausted. The bypass is
25/// reachable by anyone.
26///
27/// So the axis is not "open vs closed". It is:
28///
29/// - A **transport** failure — the socket broke, the server is
30/// unreachable — is not caller-controlled and self-heals once the
31/// connection is replaced. Refusing here converts a limiter hiccup into
32/// a simultaneous outage of every rate-limited route, for a condition
33/// nobody in the request path can fix. This is what
34/// [`StoreErrorPolicy::Allow`] serves through.
35/// - A **logical** failure — the store was reached and said no (`OOM`, a
36/// permission error, a poisoned mutex, a malformed reply) — may be
37/// caller-induced, does not self-heal, and is exactly the shape an
38/// attacker steers toward. It stays closed under **every** policy.
39///
40/// Concretely: `Allow` matches [`CratestackError::Unavailable`] and nothing
41/// else. Backends signal transport-class failures with that variant
42/// (`cratestack-redis`'s `ratelimit::util::is_transport_class`); anything
43/// else they return is refused even under `Allow`.
44///
45/// Key derivation remains fail-closed under both policies (cratestack#416)
46/// for the same reason the OOM case is: its inputs are caller-controlled.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48#[non_exhaustive]
49pub enum StoreErrorPolicy {
50 /// Serve the request unthrottled when — and only when — the store
51 /// failure is transport-class. Every other store failure is refused
52 /// exactly as under [`StoreErrorPolicy::Deny`].
53 #[default]
54 Allow,
55 /// Refuse on any store failure, transport-class included, with the
56 /// store's own error status and the normal typed error envelope.
57 /// For deployments where the limiter is a security control (a
58 /// paywall, a brute-force guard) rather than a capacity control.
59 Deny,
60}
61
62impl StoreErrorPolicy {
63 /// Whether this policy serves `error` through to the inner service.
64 ///
65 /// Deliberately a match on the error *variant* rather than on
66 /// `status_code()`: 503 is also reachable from a hand-written store
67 /// that means something else by it, and a helper whose job is to gate
68 /// a security-relevant bypass should be readable without a detour
69 /// through the HTTP mapping table.
70 pub(super) fn permits(self, error: &CratestackError) -> bool {
71 match self {
72 Self::Deny => false,
73 Self::Allow => matches!(error, CratestackError::Unavailable(_)),
74 }
75 }
76}
77
78/// Default ceiling on one `store.consume` call — first attempt *and* any
79/// backend-internal retry, as a single budget.
80///
81/// The security review measured the alternative: `redis`'s
82/// `ConnectionManager` defaults both its connection and response timeouts
83/// to `None`, so during a real outage each attempt awaited an unbounded
84/// reconnect cycle — 9.46s, doubled to 18.92s by the retry. "Degrade to
85/// unlimited" silently meant "hang for nineteen seconds, then allow",
86/// which is worse for the caller than the refusal it replaced and is
87/// itself a denial-of-service lever.
88///
89/// 500ms is chosen to be far above a healthy Redis round-trip (sub-
90/// millisecond on a local network, single-digit milliseconds across an
91/// availability zone) and far below anything a human would call a hang.
92/// Tune with [`super::RateLimitLayer::with_store_timeout`].
93pub const DEFAULT_STORE_TIMEOUT: Duration = Duration::from_millis(500);
94
95/// Message carried by the synthetic error a budget elapse produces. A
96/// timeout IS a transport-class failure — the store did not answer — so
97/// it is reported as [`CratestackError::Unavailable`] and is therefore
98/// servable under `Allow`, unlike an `OOM`.
99pub(super) fn store_timeout_error() -> CratestackError {
100 CratestackError::Unavailable("rate limit store timed out".to_owned())
101}
102
103/// The two throttled `WARN`s the store-error path emits.
104///
105/// Owned per-layer rather than kept in `static`s. Two reasons, in order
106/// of importance: a process-global log budget is shared mutable state
107/// that makes any test asserting on these lines order-dependent (the
108/// first call in a process always emits, so whichever test runs first
109/// wins); and a process hosting two routers with independent limiters
110/// has no reason to make one limiter's outage silence the other's.
111#[derive(Debug)]
112pub(super) struct StoreErrorWarnings {
113 /// The per-request "store error" line. Throttled because the
114 /// condition is attacker-drivable: during an outage it fires once per
115 /// request, at whatever rate the caller chooses, so leaving it
116 /// unthrottled turns a store failure into a log-volume amplifier on
117 /// top of everything else. The suppressed count travels in the
118 /// message so the throttle never understates the blast radius.
119 pub(super) store_error: LogThrottle,
120 /// Separate budget, not a second use of the one above: this line says
121 /// "we are now serving unthrottled", which an operator must keep
122 /// seeing at a predictable cadence during a long outage. The first
123 /// cut used a `Once`, which under-reported badly — a limiter that
124 /// stops limiting for an hour deserves more than one line an hour
125 /// ago.
126 pub(super) fail_open: LogThrottle,
127}
128
129impl Default for StoreErrorWarnings {
130 fn default() -> Self {
131 Self {
132 store_error: LogThrottle::new(Duration::from_secs(10)),
133 fail_open: LogThrottle::new(Duration::from_secs(60)),
134 }
135 }
136}