1
use super::*;
2

            
3
const INGRESS: &str = "10.104.0.0/16";
4

            
5
1876
fn peer(addr: &str) -> Option<SocketAddr> {
6
1876
    Some(SocketAddr::new(
7
1876
        addr.parse().expect("test address parses"),
8
1876
        45678,
9
1876
    ))
10
1876
}
11

            
12
26
fn limiter() -> AuthLimiter {
13
26
    AuthLimiter::new(TrustedProxies::from_spec(INGRESS))
14
26
}
15

            
16
#[test]
17
2
fn attempts_are_allowed_up_to_the_limit_then_refused() {
18
2
    let limiter = limiter();
19
2
    let client = peer("203.0.113.7");
20

            
21
20
    for attempt in 1..=MAX_ATTEMPTS {
22
20
        assert_eq!(
23
20
            limiter.check(client, None),
24
            Decision::Allow,
25
            "attempt {attempt} is within the window's budget"
26
        );
27
    }
28

            
29
2
    assert_eq!(limiter.check(client, None), Decision::Deny);
30
2
}
31

            
32
#[test]
33
2
fn one_address_running_out_does_not_affect_another() {
34
2
    let limiter = limiter();
35
2
    let noisy = peer("203.0.113.7");
36

            
37
22
    for _ in 0..=MAX_ATTEMPTS {
38
22
        limiter.check(noisy, None);
39
22
    }
40
2
    assert_eq!(limiter.check(noisy, None), Decision::Deny);
41

            
42
2
    assert_eq!(
43
2
        limiter.check(peer("198.51.100.4"), None),
44
        Decision::Allow,
45
        "throttling must be per client, not global"
46
    );
47
2
}
48

            
49
#[test]
50
2
fn a_forged_forwarded_header_cannot_buy_a_fresh_budget() {
51
2
    let limiter = limiter();
52
2
    let attacker = peer("203.0.113.7");
53

            
54
20
    for _ in 0..MAX_ATTEMPTS {
55
20
        limiter.check(attacker, None);
56
20
    }
57
2
    assert_eq!(limiter.check(attacker, None), Decision::Deny);
58

            
59
    // The attacker connects directly, so its header is not evidence and must
60
    // not move it to a different bucket.
61
6
    for forged in ["1.2.3.4", "5.6.7.8", "9.10.11.12"] {
62
6
        assert_eq!(
63
6
            limiter.check(attacker, Some(forged)),
64
            Decision::Deny,
65
            "an untrusted peer must not escape its bucket by claiming {forged}"
66
        );
67
    }
68
2
}
69

            
70
#[test]
71
2
fn clients_behind_a_trusted_proxy_get_their_own_budgets() {
72
2
    let limiter = limiter();
73
2
    let ingress = peer("10.104.0.9");
74

            
75
20
    for _ in 0..MAX_ATTEMPTS {
76
20
        limiter.check(ingress, Some("203.0.113.7"));
77
20
    }
78
2
    assert_eq!(limiter.check(ingress, Some("203.0.113.7")), Decision::Deny);
79

            
80
2
    assert_eq!(
81
2
        limiter.check(ingress, Some("198.51.100.4")),
82
        Decision::Allow,
83
        "one throttled client must not throttle everyone sharing the ingress"
84
    );
85
2
}
86

            
87
#[test]
88
2
fn rotating_hosts_inside_one_ipv6_allocation_share_a_budget() {
89
2
    let limiter = limiter();
90

            
91
    // 2^64 addresses out of one /64 would otherwise be 2^64 fresh budgets.
92
20
    for host in 0..MAX_ATTEMPTS {
93
20
        assert_eq!(
94
20
            limiter.check(peer(&format!("2001:db8:1:1::{host:x}")), None),
95
            Decision::Allow
96
        );
97
    }
98

            
99
2
    assert_eq!(
100
2
        limiter.check(peer("2001:db8:1:1::ffff"), None),
101
        Decision::Deny,
102
        "host rotation within a /64 must not mint new budgets"
103
    );
104
2
}
105

            
106
#[test]
107
2
fn a_separate_ipv6_network_keeps_its_own_budget() {
108
2
    let limiter = limiter();
109

            
110
22
    for host in 0..=MAX_ATTEMPTS {
111
22
        limiter.check(peer(&format!("2001:db8:1:1::{host:x}")), None);
112
22
    }
113
2
    assert_eq!(limiter.check(peer("2001:db8:1:1::1"), None), Decision::Deny);
114

            
115
2
    assert_eq!(
116
2
        limiter.check(peer("2001:db8:1:2::1"), None),
117
        Decision::Allow,
118
        "a different /64 is a different client"
119
    );
120
2
}
121

            
122
#[test]
123
2
fn a_request_with_no_peer_is_counted_rather_than_waved_through() {
124
2
    let limiter = limiter();
125

            
126
2
    for _ in 0..MAX_ATTEMPTS {
127
20
        assert_eq!(limiter.check(None, None), Decision::Allow);
128
    }
129

            
130
2
    assert_eq!(
131
2
        limiter.check(None, None),
132
        Decision::Deny,
133
        "an unidentifiable request must still be throttled"
134
    );
135
2
}
136

            
137
#[test]
138
2
fn the_global_ceiling_binds_even_when_every_attempt_comes_from_a_new_client() {
139
2
    let limiter = limiter();
140

            
141
    // Each attempt uses a fresh /64, so no per-client bucket ever fills.
142
2
    let mut allowed = 0;
143
602
    for network in 0..(GLOBAL_MAX_ATTEMPTS + 50) {
144
602
        if limiter
145
602
            .check(peer(&format!("2001:db8:{network:x}::1")), None)
146
602
            .is_denied()
147
        {
148
2
            break;
149
600
        }
150
600
        allowed += 1;
151
    }
152

            
153
2
    assert_eq!(
154
        allowed, GLOBAL_MAX_ATTEMPTS,
155
        "identity rotation must still spend from a budget it cannot reset"
156
    );
157
2
}
158

            
159
#[test]
160
2
fn verifications_have_their_own_far_larger_budget() {
161
2
    let limiter = limiter();
162
2
    let client = peer("203.0.113.7");
163

            
164
    // Exhausting the login budget must not stop this client from making
165
    // ordinary authenticated requests.
166
22
    for _ in 0..=MAX_ATTEMPTS {
167
22
        limiter.check(client, None);
168
22
    }
169
2
    assert_eq!(limiter.check(client, None), Decision::Deny);
170

            
171
2
    assert_eq!(
172
2
        limiter.check_verification(client, None),
173
        Decision::Allow,
174
        "a throttled login must not also lock the client out of its session"
175
    );
176
2
}
177

            
178
#[test]
179
2
fn a_flood_of_forged_tokens_is_refused() {
180
2
    let limiter = limiter();
181
2
    let attacker = peer("203.0.113.7");
182

            
183
2
    for _ in 0..MAX_VERIFICATIONS {
184
1200
        assert_eq!(limiter.check_verification(attacker, None), Decision::Allow);
185
    }
186

            
187
2
    assert_eq!(
188
2
        limiter.check_verification(attacker, None),
189
        Decision::Deny,
190
        "unbounded verification lets attacker-chosen subjects drain the admin pool"
191
    );
192
2
}
193

            
194
#[test]
195
2
fn verification_budgets_are_per_client() {
196
2
    let limiter = limiter();
197

            
198
1202
    for _ in 0..=MAX_VERIFICATIONS {
199
1202
        limiter.check_verification(peer("203.0.113.7"), None);
200
1202
    }
201
2
    assert_eq!(
202
2
        limiter.check_verification(peer("203.0.113.7"), None),
203
        Decision::Deny
204
    );
205

            
206
2
    assert_eq!(
207
2
        limiter.check_verification(peer("198.51.100.4"), None),
208
        Decision::Allow
209
    );
210
2
}
211

            
212
#[test]
213
2
fn a_forged_forwarded_header_cannot_buy_a_fresh_verification_budget() {
214
2
    let limiter = limiter();
215
2
    let attacker = peer("203.0.113.7");
216

            
217
1200
    for _ in 0..MAX_VERIFICATIONS {
218
1200
        limiter.check_verification(attacker, None);
219
1200
    }
220

            
221
2
    assert_eq!(
222
2
        limiter.check_verification(attacker, Some("1.2.3.4")),
223
        Decision::Deny,
224
        "an untrusted peer must not escape its bucket by claiming another address"
225
    );
226
2
}
227

            
228
#[test]
229
2
fn a_refused_client_does_not_spend_the_shared_budget() {
230
2
    let limiter = limiter();
231
2
    let noisy = peer("203.0.113.7");
232

            
233
    // Far more requests than the global ceiling, all from one address that is
234
    // past its own limit after the first ten.
235
1200
    for _ in 0..(GLOBAL_MAX_ATTEMPTS * 2) {
236
1200
        limiter.check(noisy, None);
237
1200
    }
238

            
239
2
    assert_eq!(
240
2
        limiter.check(peer("198.51.100.4"), None),
241
        Decision::Allow,
242
        "one address hammering a bucket it has already exhausted must not deny \
243
         login to everybody else"
244
    );
245
2
}
246

            
247
/// Drives the middleware itself, not just the counter beneath it.
248
mod middleware {
249
    use super::super::{AuthLimiter, throttle_auth, throttle_verification};
250
    use crate::client_ip::TrustedProxies;
251
    use axum::extract::ConnectInfo;
252
    use axum::http::{Request, StatusCode};
253
    use axum::routing::get;
254
    use axum::{Router, body::Body, middleware};
255
    use std::net::SocketAddr;
256
    use std::sync::Arc;
257
    use tower::ServiceExt;
258

            
259
8
    fn app(handler: fn(Arc<AuthLimiter>) -> Router) -> Router {
260
8
        handler(Arc::new(AuthLimiter::new(TrustedProxies::from_spec(
261
8
            "10.104.0.0/20",
262
8
        ))))
263
8
    }
264

            
265
6
    fn login_app(limiter: Arc<AuthLimiter>) -> Router {
266
6
        Router::new()
267
124
            .route("/auth/login", get(|| async { "ok" }))
268
6
            .route_layer(middleware::from_fn_with_state(limiter, throttle_auth))
269
6
    }
270

            
271
2
    fn page_app(limiter: Arc<AuthLimiter>) -> Router {
272
2
        Router::new()
273
2404
            .route("/page", get(|| async { "ok" }))
274
2
            .route_layer(middleware::from_fn_with_state(
275
2
                limiter,
276
                throttle_verification,
277
            ))
278
2
    }
279

            
280
1272
    fn request(uri: &str, peer: Option<&str>, forwarded: &[&str]) -> Request<Body> {
281
1272
        let mut builder = Request::builder().uri(uri);
282
1272
        for value in forwarded {
283
46
            builder = builder.header("x-forwarded-for", *value);
284
46
        }
285
1272
        let mut req = builder.body(Body::empty()).expect("request builds");
286
1272
        if let Some(peer) = peer {
287
1250
            let addr: SocketAddr = format!("{peer}:45678").parse().expect("peer parses");
288
1250
            req.extensions_mut().insert(ConnectInfo(addr));
289
1250
        }
290
1272
        req
291
1272
    }
292

            
293
1272
    async fn status(app: &Router, req: Request<Body>) -> StatusCode {
294
1272
        app.clone()
295
1272
            .oneshot(req)
296
1272
            .await
297
1272
            .expect("router responds")
298
1272
            .status()
299
1272
    }
300

            
301
    #[tokio::test]
302
2
    async fn login_is_served_until_the_budget_runs_out_then_refused() {
303
2
        let app = app(login_app);
304

            
305
20
        for attempt in 1..=super::MAX_ATTEMPTS {
306
20
            assert_eq!(
307
20
                status(&app, request("/auth/login", Some("203.0.113.7"), &[])).await,
308
2
                StatusCode::OK,
309
2
                "attempt {attempt} is within budget"
310
2
            );
311
2
        }
312
2

            
313
2
        assert_eq!(
314
2
            status(&app, request("/auth/login", Some("203.0.113.7"), &[])).await,
315
2
            StatusCode::TOO_MANY_REQUESTS
316
2
        );
317
2
    }
318

            
319
    #[tokio::test]
320
2
    async fn a_request_with_no_connect_info_is_still_throttled() {
321
        // The extractor is read from extensions rather than required, so a
322
        // request without one must be counted, not waved through or rejected.
323
2
        let app = app(login_app);
324

            
325
2
        for _ in 0..super::MAX_ATTEMPTS {
326
20
            assert_eq!(
327
20
                status(&app, request("/auth/login", None, &[])).await,
328
2
                StatusCode::OK
329
2
            );
330
2
        }
331
2
        assert_eq!(
332
2
            status(&app, request("/auth/login", None, &[])).await,
333
2
            StatusCode::TOO_MANY_REQUESTS
334
2
        );
335
2
    }
336

            
337
    #[tokio::test]
338
2
    async fn every_forwarded_header_line_reaches_the_limiter() {
339
        // Two clients behind the trusted ingress, distinguished only by the
340
        // rightmost hop across BOTH header lines.
341
2
        let app = app(login_app);
342

            
343
2
        for _ in 0..super::MAX_ATTEMPTS {
344
20
            let req = request(
345
20
                "/auth/login",
346
20
                Some("10.104.0.9"),
347
20
                &["1.2.3.4", "203.0.113.7"],
348
            );
349
20
            assert_eq!(status(&app, req).await, StatusCode::OK);
350
        }
351

            
352
2
        let exhausted = request(
353
2
            "/auth/login",
354
2
            Some("10.104.0.9"),
355
2
            &["1.2.3.4", "203.0.113.7"],
356
        );
357
2
        assert_eq!(status(&app, exhausted).await, StatusCode::TOO_MANY_REQUESTS);
358

            
359
2
        let other = request("/auth/login", Some("10.104.0.9"), &["198.51.100.4"]);
360
2
        assert_eq!(
361
2
            status(&app, other).await,
362
2
            StatusCode::OK,
363
2
            "a different client behind the same proxy keeps its own budget"
364
2
        );
365
2
    }
366

            
367
    #[tokio::test]
368
2
    async fn verification_has_its_own_ceiling_on_ordinary_pages() {
369
2
        let app = app(page_app);
370

            
371
2
        for _ in 0..super::MAX_VERIFICATIONS {
372
1200
            assert_eq!(
373
1200
                status(&app, request("/page", Some("203.0.113.7"), &[])).await,
374
                StatusCode::OK
375
            );
376
        }
377

            
378
2
        assert_eq!(
379
2
            status(&app, request("/page", Some("203.0.113.7"), &[])).await,
380
            StatusCode::TOO_MANY_REQUESTS
381
        );
382
2
        assert_eq!(
383
2
            status(&app, request("/page", Some("198.51.100.4"), &[])).await,
384
2
            StatusCode::OK,
385
2
            "the ceiling is per client"
386
2
        );
387
2
    }
388
}