Skip to main content

cratestack_axum/ratelimit/
scope.rs

1//! What the default key function produces, and the two knobs that change
2//! which scope an unverified caller lands in (cratestack#871).
3
4use std::net::{IpAddr, Ipv6Addr};
5
6use cratestack_core::BucketBudget;
7
8/// A caller identity an upstream layer has actually **verified**.
9///
10/// Insert it as a request extension from a layer that runs *before*
11/// [`super::RateLimitLayer`] and has validated the credential (signature,
12/// introspection, mTLS, session lookup). When present, the default key
13/// function keys on it directly and applies **no** bucket budget: a
14/// verified principal is not caller-mintable, so its cardinality is
15/// bounded by however many principals actually exist.
16///
17/// This is opt-in rather than the default because in this framework
18/// authentication runs *inside* the generated handlers — after this layer.
19/// Making verified identity mandatory would collapse every existing
20/// consumer's authenticated traffic onto the peer address overnight.
21///
22/// The inner value is hashed before it becomes a bucket key, so a
23/// principal id is never written to a store key or a log line verbatim.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct VerifiedPrincipal(pub String);
26
27/// What to do with an `Authorization` header that **nothing has
28/// verified** — which is every `Authorization` header this layer sees,
29/// since it runs before authentication.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[non_exhaustive]
32pub enum UnverifiedAuthPolicy {
33    /// Key on the (hashed) header as before, but subject to a
34    /// [`BucketBudget`] that caps how many distinct buckets one scope may
35    /// mint. Preserves per-caller throttling for real callers
36    /// (cratestack#416) while bounding the keyspace an attacker can
37    /// create. The default.
38    #[default]
39    Budget,
40    /// Ignore the header entirely and key on the verified peer address.
41    /// Strictly stronger against amplification — nothing caller-supplied
42    /// enters the key at all — at the cost of collapsing every caller
43    /// behind one NAT/proxy egress into one bucket. Choose it when the
44    /// limiter is a security control and callers are known to be
45    /// per-address.
46    Ignore,
47}
48
49/// Which scope a derived budget belongs to. Not part of [`BucketBudget`]:
50/// a store cannot tell a peer scope from the global one and does not need
51/// to, so the distinction lives here — where it is decided — and is used
52/// only to pick how loudly to log an over-cap charge.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(super) enum BudgetScope {
55    /// Per verified peer address (IPv6 aggregated to its /64).
56    Peer,
57    /// One scope for the whole process, used when no verified peer
58    /// address is available at all.
59    Global,
60}
61
62/// The default key function's full answer: the bucket the caller asked
63/// for, and — when that bucket is caller-mintable — the budget governing
64/// whether it may be created.
65#[derive(Debug, Clone)]
66pub(super) struct KeyDerivation {
67    pub(super) key: String,
68    pub(super) budget: Option<BucketBudget>,
69    pub(super) scope: Option<BudgetScope>,
70}
71
72impl KeyDerivation {
73    /// A key nobody can mint at will: a verified principal, a verified
74    /// peer address, or whatever a consumer's own `with_key_fn` returns.
75    pub(super) fn unbudgeted(key: String) -> Self {
76        Self {
77            key,
78            budget: None,
79            scope: None,
80        }
81    }
82
83    pub(super) fn budgeted(key: String, budget: BucketBudget, scope: BudgetScope) -> Self {
84        Self {
85            key,
86            budget: Some(budget),
87            scope: Some(scope),
88        }
89    }
90}
91
92/// The address form used **everywhere a peer address becomes a key** — the
93/// budget scope, the `ip:` fallback bucket, and the `ip:` bucket an
94/// unauthenticated request gets.
95///
96/// IPv6 is aggregated to its **/64** because that is the smallest block
97/// routinely delegated to a single subscriber: without aggregation an
98/// attacker with one ordinary residential prefix has 2^64 distinct "peers"
99/// and the per-peer cap costs them nothing. IPv4 is deliberately NOT
100/// aggregated — /24 collateral under CGNAT would collapse thousands of
101/// unrelated subscribers into one budget, and IPv4 gives an attacker no
102/// comparable free-address supply.
103///
104/// # Why it is ONE function and not two (cratestack#871 review, blocker 1)
105///
106/// The first cut aggregated only the scope and left the bucket keys on the
107/// full address. That left the whole mechanism evadable from the other
108/// side, and it was measured: rotating the source address inside a single
109/// /64 produced 200 buckets with an `Authorization` header (cap 8) and 200
110/// buckets, 200/200 allowed, with **no header at all** — the cratestack#846
111/// signature with the address, rather than the token, as the rotating
112/// variable. Aggregating the scope while leaving the key un-aggregated
113/// bounds nothing.
114///
115/// The accepted cost, stated rather than hidden: two distinct hosts inside
116/// one *routable* /64 share a throttling bucket. That is a real
117/// cratestack#416 trade-off, taken because a /64 is one subscriber and an
118/// attacker's 2^64-address supply is not a hypothetical.
119///
120/// # IPv4-mapped addresses are unwrapped FIRST (cratestack#871 round-2)
121///
122/// A dual-stack listener — `TcpListener::bind("[::]:0")`, the ordinary
123/// Linux bind — delivers every IPv4 client as `::ffff:a.b.c.d`. Those have
124/// all-zero top groups, so blindly taking the /64 mapped **every IPv4
125/// client in the world onto `ip:::/64`**: measured, 200 distinct IPv4
126/// clients collapsed into 1 bucket with 5 allowed. That is a
127/// cratestack#416 collision of unlimited width and a one-client denial of
128/// service against all IPv4 traffic — strictly worse than the evasion the
129/// aggregation was added to close.
130///
131/// So a mapped address is unwrapped to its IPv4 form and then treated
132/// exactly like any other IPv4 address: per-address, never aggregated.
133///
134/// **`to_ipv4_mapped`, deliberately not `to_ipv4`.** The latter also
135/// accepts the deprecated IPv4-*compatible* form (`::a.b.c.d`, RFC 4291
136/// §2.5.5.1), which means it maps `::1` to `0.0.0.1` and `::` to
137/// `0.0.0.0` — conflating the IPv6 loopback and the unspecified address
138/// with real IPv4 addresses. That trades one collision for another.
139/// Instead, the whole all-zero `::/64` region (unspecified, loopback, and
140/// both mapped/compatible forms) is exempted from aggregation below and
141/// keyed on the full address. Nothing in that region is globally routable,
142/// so it hands an attacker no address supply to rotate through.
143pub(super) fn bucket_address(ip: IpAddr) -> String {
144    let v6 = match ip {
145        IpAddr::V4(v4) => return v4.to_string(),
146        IpAddr::V6(v6) => v6,
147    };
148    if let Some(v4) = v6.to_ipv4_mapped() {
149        return v4.to_string();
150    }
151    let s = v6.segments();
152    if s[0] == 0 && s[1] == 0 && s[2] == 0 && s[3] == 0 {
153        // The `::/64` special region — see the note above. Aggregating it
154        // would merge unrelated special addresses into one bucket.
155        return v6.to_string();
156    }
157    let network = Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0);
158    format!("{network}/64")
159}