Skip to main content

security_rust/throttle/
guard.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use super::store::ThrottleStore;
4use super::{StoreError, ThrottleConfig, ThrottleDecision, ThrottleOutcome};
5
6/// 滑动窗口限流 / 封禁闸门。
7///
8/// ## key 由调用方构造
9///
10/// `key` 是计数桶的名字,本模块只把它当作不透明字符串。约定形如
11/// `format!("ip:{}", ip)`(按来源)或 `format!("acct:{}", user)`(按账户);
12/// 两类 key 前缀不同,天然互不干扰,可同时启用。
13///
14/// **不要把用户输入直接当 key**:拿请求里原样的用户名 / 任意 header 当 key,
15/// 攻击者只要每次换一个值就能把自己拆成无限多个桶,限流形同虚设;
16/// 空 key 同理,会让所有构造失败的请求共用同一个桶。调用方须先规范化
17/// (截断长度、统一大小写、限制字符集),并保证 key 非空。
18pub struct Throttle<S: ThrottleStore> {
19    store: S,
20    config: ThrottleConfig,
21}
22
23impl<S: ThrottleStore> Throttle<S> {
24    pub fn new(store: S, config: ThrottleConfig) -> Self {
25        Self { store, config }
26    }
27
28    pub fn config(&self) -> &ThrottleConfig {
29        &self.config
30    }
31
32    /// 请求进入时调用。先查封禁,再算剩余额度。
33    ///
34    /// 存储报错 → `ThrottleDecision::Unavailable`(见该变体的说明,**不 fail-closed**):
35    /// 这里是限流的纵深防御,不是主认证闸门。后端故障时返回 `Banned` 会把全体
36    /// 用户挡在门外(自我 DoS,且攻击者可能主动诱发),而放行只是暂时失去
37    /// 暴力破解防护 —— 主认证闸门 `SessionGuard` 仍然在拦。调用方拿到
38    /// `Unavailable` 后自行选择,建议放行 + 告警。
39    pub fn check(&self, key: &str, now: u64) -> ThrottleDecision {
40        match self.store.is_banned(key, now) {
41            // 自己比对 `now`,不假定后端已过滤:后端若返回原始值(第三方的
42            // Redis 实现常见的做法),直接信它就等于把 key 永久锁死。
43            Ok(Some(until)) if until > now => ThrottleDecision::Banned { until },
44            Ok(_) => match self.store.failure_count(key, now, self.config.window_secs) {
45                Ok(count) => ThrottleDecision::Allow {
46                    remaining: self.remaining(count),
47                },
48                Err(_) => ThrottleDecision::Unavailable,
49            },
50            Err(_) => ThrottleDecision::Unavailable,
51        }
52    }
53
54    /// 同时检查多个维度(如 `[ip_key, account_key]`),返回最严格的结果。
55    ///
56    /// 合并规则(严格度):任一 `Banned` → `Banned`(取最晚的 `until`);
57    /// 否则任一 `Unavailable` → `Unavailable`;否则 `Allow` 取**最小** `remaining`。
58    ///
59    /// 每个 key 各自独立查询,**不合并计数**:`ip:` 与 `acct:` 是两类互不干扰的桶,
60    /// 合并会让 NAT 后面的其他人替攻击者吃掉额度。
61    ///
62    /// 空 `keys` 什么都查不到,返回 `Allow { remaining: 0 }` 而非满额 —— 这个数字
63    /// 会被写进 X-RateLimit-* 响应头,凭空报满额等于谎报额度。
64    pub fn check_any(&self, keys: &[&str], now: u64) -> ThrottleDecision {
65        let mut banned_until: Option<u64> = None;
66        let mut unavailable = false;
67        let mut min_remaining: Option<u32> = None;
68        for key in keys {
69            match self.check(key, now) {
70                ThrottleDecision::Banned { until } => {
71                    // 不能提前返回:规则要求取「最晚」的解封时刻,得扫完所有 key
72                    banned_until = Some(banned_until.map_or(until, |b: u64| b.max(until)));
73                }
74                ThrottleDecision::Unavailable => unavailable = true,
75                ThrottleDecision::Allow { remaining } => {
76                    min_remaining = Some(min_remaining.map_or(remaining, |r| r.min(remaining)));
77                }
78            }
79        }
80        if let Some(until) = banned_until {
81            return ThrottleDecision::Banned { until };
82        }
83        if unavailable {
84            return ThrottleDecision::Unavailable;
85        }
86        ThrottleDecision::Allow {
87            remaining: min_remaining.unwrap_or(0),
88        }
89    }
90
91    /// 认证失败时调用。达到 threshold 就封禁并返回 `Banned`,否则返回 `Allow { remaining }`。
92    ///
93    /// 返回 [`ThrottleOutcome`] 而非 `ThrottleDecision`:这里**不存在** `Unavailable`
94    /// —— 存储故障走 `Err`,两个可达状态对应两个分支,调用方不必再写一个永不执行的
95    /// 第三个臂。判断「要不要拦这个请求」用 [`Throttle::check_any`]。
96    ///
97    /// 判定顺序:先拿窗口内计数,`count >= threshold` 时写封禁并返回解封时刻。
98    /// `threshold` 是「第几次失败触发封禁」,因此第 `threshold` 次调用返回的是
99    /// `Banned` 而不是 `Allow { remaining: 0 }` —— 剩余额度为 0 的那次已经是拒绝。
100    ///
101    /// 注意 `record_failure`(计数)与 `ban`(封禁)是两次独立的锁获取,**不原子**:
102    /// 两者之间并发一次 `reset` 是可能的,结果是刚认证成功的用户又被封上
103    /// (可用性问题,不构成绕过 —— 计数也确实已经记下了)。若那个窗口不可接受,
104    /// 得把「计数 + 判阈值 + 写封禁」并成一个 store 操作。
105    ///
106    /// 同理,`ban` 写失败时计数已经落库:本调用返回 `Err`,但下一步 `check`
107    /// 会看到 `Allow { remaining: 0 }`(额度确实耗尽),由调用方据此拒绝。
108    pub fn record_failure(&self, key: &str, now: u64) -> Result<ThrottleOutcome, StoreError> {
109        let count = self
110            .store
111            .record_failure(key, now, self.config.window_secs)?;
112        if count >= self.config.threshold {
113            let until = now.saturating_add(self.config.ban_secs);
114            self.store.ban(key, until)?;
115            return Ok(ThrottleOutcome::Banned { until });
116        }
117        Ok(ThrottleOutcome::Allow {
118            remaining: self.remaining(count),
119        })
120    }
121
122    /// 认证成功时调用:**只清失败计数,保留封禁**。
123    ///
124    /// 「凭据正确 ⇒ 不是暴力破解 ⇒ 顺手解封」只对 `acct:` 桶成立。对 `ip:` 这类
125    /// 共享桶,封禁是 NAT / 代理后面的所有人共用的 —— 换成 `reset`(清计数 + 清封禁)
126    /// 意味着桶里**任意**另一个用户认证成功,就能替爆破者解除封禁并洗掉计数。
127    ///
128    /// 这是有意的安全取舍,不是漏写的细节:代价是账户桶下用户被爆破牵连时,
129    /// 即使立刻输对密码也要等满 `ban_secs`(默认 900 秒)才恢复;换来的是共享桶的
130    /// 封禁不会被他人一次成功认证解除。封禁不需要在这里额外清理,`is_banned` 的
131    /// `now` 过滤会让它自然到期;要人工提前解封用 [`Throttle::reset`]。
132    pub fn record_success(&self, key: &str) -> Result<(), StoreError> {
133        self.store.clear_failures(key)
134    }
135
136    /// 人工解封 / 解限。
137    pub fn reset(&self, key: &str) -> Result<(), StoreError> {
138        self.store.reset(key)
139    }
140
141    pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
142        self.store.purge_expired(now)
143    }
144
145    /// 剩余可失败次数。计数已超阈值(如封禁过期但失败仍在窗口内)时钳到 0。
146    fn remaining(&self, count: u32) -> u32 {
147        self.config.threshold.saturating_sub(count)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::super::MemoryThrottleStore;
154    use super::*;
155
156    const NOW: u64 = 1_000_000;
157
158    fn throttle(threshold: u32, window_secs: u64, ban_secs: u64) -> Throttle<MemoryThrottleStore> {
159        Throttle::new(
160            MemoryThrottleStore::new(),
161            ThrottleConfig {
162                threshold,
163                window_secs,
164                ban_secs,
165            },
166        )
167    }
168
169    #[test]
170    fn config_accessor_returns_construction_config() {
171        let t = throttle(3, 60, 900);
172        assert_eq!(t.config().threshold, 3, "got {:?}", t.config());
173        assert_eq!(t.config().window_secs, 60, "got {:?}", t.config());
174        assert_eq!(t.config().ban_secs, 900, "got {:?}", t.config());
175    }
176
177    #[test]
178    fn throttle_is_generic_over_store() {
179        // 只绑定 ThrottleStore trait,便于多实例部署换后端
180        fn accepts<S: ThrottleStore>(t: &Throttle<S>) -> u32 {
181            t.config().threshold
182        }
183        let t = Throttle::new(MemoryThrottleStore::new(), ThrottleConfig::default());
184        assert_eq!(accepts(&t), 5);
185    }
186
187    #[test]
188    fn check_on_fresh_key_is_full_budget() {
189        let t = throttle(5, 60, 900);
190        assert_eq!(
191            t.check("ip:1.2.3.4", NOW),
192            ThrottleDecision::Allow { remaining: 5 },
193            "窗口为空时剩余额度应为满额"
194        );
195    }
196
197    #[test]
198    fn check_does_not_consume_budget() {
199        let t = throttle(5, 60, 900);
200        for _ in 0..10 {
201            assert_eq!(
202                t.check("ip:a", NOW),
203                ThrottleDecision::Allow { remaining: 5 }
204            );
205        }
206        assert_eq!(
207            t.record_failure("ip:a", NOW).unwrap(),
208            ThrottleOutcome::Allow { remaining: 4 },
209            "check 不该计入失败"
210        );
211    }
212
213    #[test]
214    fn check_reports_decremented_remaining() {
215        let t = throttle(5, 60, 900);
216        t.record_failure("ip:a", NOW).unwrap();
217        t.record_failure("ip:a", NOW).unwrap();
218        assert_eq!(
219            t.check("ip:a", NOW),
220            ThrottleDecision::Allow { remaining: 3 },
221            "放行时的剩余额度要反映窗口内已累计的失败"
222        );
223    }
224
225    #[test]
226    fn ban_outlives_the_counting_window() {
227        // 封禁时长 > 窗口:计数滑出后仍应保持封禁
228        let t = throttle(1, 60, 900);
229        t.record_failure("ip:a", NOW).unwrap();
230        assert_eq!(
231            t.check("ip:a", NOW + 61),
232            ThrottleDecision::Banned { until: NOW + 900 },
233            "窗口滑出不影响封禁"
234        );
235    }
236
237    #[test]
238    fn purge_expired_delegates_to_store() {
239        let t = throttle(5, 60, 900);
240        t.record_failure("ip:a", NOW).unwrap();
241        assert_eq!(t.purge_expired(NOW).unwrap(), 0, "窗口内不该被清");
242        assert_eq!(t.purge_expired(NOW + 1_000).unwrap(), 1);
243        assert_eq!(
244            t.check("ip:a", NOW + 1_000),
245            ThrottleDecision::Allow { remaining: 5 }
246        );
247    }
248}