Skip to main content

max / makenotwork

18.9 KB · 484 lines History Blame Raw
1 //! Rate limiting: Cloudflare-aware IP extraction, per-app SyncKit extraction,
2 //! and governor config builders.
3
4 use tower_governor::errors::GovernorError;
5 use tower_governor::key_extractor::KeyExtractor;
6
7 use crate::db::SyncAppId;
8
9 /// IP key extractor that trusts `CF-Connecting-IP` as the client identity.
10 ///
11 /// `CF-Connecting-IP` is safe to trust because every public path to the app
12 /// sets it from a value the client cannot forge:
13 /// - makenot.work subdomains: Caddy enforces Cloudflare mTLS, so only
14 /// Cloudflare reaches the origin and Cloudflare sets the header itself.
15 /// - Custom domains (the `:443` on-demand-TLS block): Caddy overwrites
16 /// `CF-Connecting-IP` with the real TCP peer and strips `X-Forwarded-For`
17 /// before proxying, so a client-supplied value never survives.
18 ///
19 /// When the header is absent, only direct dev/test access with no Caddy in
20 /// front, we fall back to [`PeerIpKeyExtractor`], the actual TCP peer from
21 /// `ConnectInfo`. We deliberately do NOT fall back to `SmartIpKeyExtractor`:
22 /// it trusts `X-Forwarded-For`/`X-Real-IP`, which a client can forge to mint
23 /// fresh rate-limit buckets and evade login/signup/reset throttles. In prod
24 /// the peer is always Caddy (loopback), so a missing CF header collapses every
25 /// requester into one bucket, the safe degraded behavior `extract_client_ip`
26 /// already warns about, rather than handing out spoofable per-IP buckets.
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub struct CloudflareIpKeyExtractor;
29
30 impl KeyExtractor for CloudflareIpKeyExtractor {
31 type Key = std::net::IpAddr;
32
33 fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
34 if let Some(ip) = req
35 .headers()
36 .get("cf-connecting-ip")
37 .and_then(|v: &axum::http::HeaderValue| v.to_str().ok())
38 .and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok())
39 {
40 return Ok(ip);
41 }
42
43 tower_governor::key_extractor::PeerIpKeyExtractor.extract(req)
44 }
45 }
46
47 /// Per-SyncKit-app key extractor. Reads the `app` claim from the
48 /// `Authorization: Bearer <token>` header to bucket requests per app.
49 ///
50 /// The signature IS verified here (HS256, against the SyncKit JWT secret).
51 /// Verification matters for rate limiting specifically: without it, an attacker
52 /// can mint unsigned tokens carrying arbitrary `app` UUIDs and spray a fresh
53 /// claim per request, landing each in its own fresh bucket and defeating the
54 /// per-app limiter entirely. By verifying, only validly-signed tokens earn
55 /// their real per-app bucket; everything else (missing/forged/malformed token)
56 /// collapses into ONE shared sentinel bucket, so a spray cannot manufacture
57 /// unlimited buckets. Auth itself is still enforced downstream by `SyncUser`,
58 /// and an IP-layer limiter backstops volume regardless.
59 ///
60 /// Expiry/issuer are intentionally NOT enforced here, an expired-but-genuine
61 /// token should still bucket by its real app (the handler will reject it). We
62 /// only need proof the `app` claim wasn't fabricated.
63 #[derive(Debug, Clone)]
64 pub struct SyncAppKeyExtractor {
65 /// SyncKit JWT signing secret. `None` when SyncKit isn't configured, in
66 /// that case there is no secret to verify against, so every token collapses
67 /// into the shared nil sentinel bucket (the anti-spray behavior), pinned by
68 /// the `no_secret_collapses_unverified_app_to_nil` test. (An earlier version
69 /// fell back to an unverified payload parse; that was removed.)
70 secret: Option<std::sync::Arc<String>>,
71 }
72
73 impl SyncAppKeyExtractor {
74 pub fn new(secret: Option<std::sync::Arc<String>>) -> Self {
75 Self { secret }
76 }
77
78 /// Verify the HS256 signature and pull out the `app` claim. Returns `None`
79 /// on any failure (bad signature, malformed token, missing claim).
80 fn verify_app(secret: &str, token: &str) -> Option<SyncAppId> {
81 use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
82
83 #[derive(serde::Deserialize)]
84 struct AppClaim {
85 app: SyncAppId,
86 }
87
88 let mut validation = Validation::new(Algorithm::HS256);
89 // Bucket genuine-but-expired tokens by their real app; only the
90 // signature must hold. Clearing required claims + disabling exp means
91 // the decode succeeds purely on a valid signature carrying `app`.
92 validation.validate_exp = false;
93 validation.required_spec_claims.clear();
94
95 decode::<AppClaim>(
96 token,
97 &DecodingKey::from_secret(secret.as_bytes()),
98 &validation,
99 )
100 .ok()
101 .map(|data| data.claims.app)
102 }
103 }
104
105 impl KeyExtractor for SyncAppKeyExtractor {
106 type Key = SyncAppId;
107
108 fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
109 let Some(token) = req
110 .headers()
111 .get("authorization")
112 .and_then(|v| v.to_str().ok())
113 .and_then(|s| s.strip_prefix("Bearer "))
114 else {
115 // No bearer token, use a nil sentinel key so the request passes
116 // through to the handler, where SyncUser will properly return 401.
117 return Ok(SyncAppId::nil());
118 };
119
120 let app = match &self.secret {
121 Some(secret) => Self::verify_app(secret, token),
122 // No secret configured: the token's `app` claim is unauthenticated, so
123 // we must NOT mint a per-app bucket from it, that would let forged
124 // tokens spray fresh buckets. Collapse to the nil sentinel; the
125 // IP-layer limiter backstops volume and sync routes are inert without a
126 // secret anyway (Sec-MINOR, Run 9).
127 None => None,
128 };
129
130 // A verified app gets its own bucket; anything that fails verification
131 // collapses to the nil sentinel so forged tokens can't spray buckets.
132 Ok(app.unwrap_or_else(SyncAppId::nil))
133 }
134 }
135
136 // ── Config builders ──
137
138 // ── Bucket-map sweeping (Run #14 CHRONIC 1) ──
139
140 /// Type-erased `retain_recent` GC hooks, one per limiter built below. Each hook
141 /// sweeps one limiter's keyed GCRA store and returns its post-sweep entry count.
142 ///
143 /// tower_governor's in-memory store grows one entry per unique client key for
144 /// process lifetime unless swept, so EVERY limiter must be registered here. The
145 /// three `rate_limiter_*` constructors below are the only sanctioned way to
146 /// build a limiter precisely because they register on construct, do not build a
147 /// `GovernorConfig` directly (it would leak, unswept). The registry is touched
148 /// only at startup (registration) and once per sweep interval, so the `Mutex` is
149 /// effectively uncontended; no lock is ever held across an `.await`.
150 static GOVERNOR_SWEEPERS: std::sync::Mutex<Vec<Box<dyn Fn() -> usize + Send + Sync>>> =
151 std::sync::Mutex::new(Vec::new());
152
153 /// Register a limiter's GC hook. Monomorphized at each call site (where the
154 /// limiter's concrete key type is known), so this stays non-generic.
155 fn register_for_sweep(hook: impl Fn() -> usize + Send + Sync + 'static) {
156 if let Ok(mut hooks) = GOVERNOR_SWEEPERS.lock() {
157 hooks.push(Box::new(hook));
158 }
159 }
160
161 /// Spawn the periodic task that sweeps every registered limiter's bucket map.
162 /// Call once at startup (guarded by `Once`, so extra calls, e.g. per-test
163 /// `build_app`, are no-ops). Requires a Tokio runtime.
164 pub fn start_governor_sweeper() {
165 static STARTED: std::sync::Once = std::sync::Once::new();
166 STARTED.call_once(|| {
167 tokio::spawn(async {
168 let interval =
169 std::time::Duration::from_secs(crate::constants::GOVERNOR_SWEEP_INTERVAL_SECS);
170 loop {
171 tokio::time::sleep(interval).await;
172 // Collect counts without holding the lock across any await.
173 let (limiters, retained) = {
174 let Ok(hooks) = GOVERNOR_SWEEPERS.lock() else {
175 continue;
176 };
177 let retained: usize = hooks.iter().map(|hook| hook()).sum();
178 (hooks.len(), retained)
179 };
180 tracing::debug!(
181 limiters,
182 retained_keys = retained,
183 "swept governor bucket maps"
184 );
185 }
186 });
187 });
188 }
189
190 /// Build an IP-based rate limiter from a per-millisecond interval and burst size.
191 pub fn rate_limiter_ms(
192 ms: u64,
193 burst: u32,
194 ) -> std::sync::Arc<
195 tower_governor::governor::GovernorConfig<
196 CloudflareIpKeyExtractor,
197 ::governor::middleware::StateInformationMiddleware,
198 >,
199 > {
200 let config = std::sync::Arc::new(
201 tower_governor::governor::GovernorConfigBuilder::default()
202 .key_extractor(CloudflareIpKeyExtractor)
203 .per_millisecond(ms)
204 .burst_size(burst)
205 .use_headers()
206 .finish()
207 .expect("rate limiter config"),
208 );
209 let limiter = config.limiter().clone();
210 register_for_sweep(move || {
211 limiter.retain_recent();
212 limiter.len()
213 });
214 config
215 }
216
217 /// Build an IP-based rate limiter from a per-second rate and burst size.
218 pub fn rate_limiter_per_sec(
219 per_sec: u64,
220 burst: u32,
221 ) -> std::sync::Arc<
222 tower_governor::governor::GovernorConfig<
223 CloudflareIpKeyExtractor,
224 ::governor::middleware::StateInformationMiddleware,
225 >,
226 > {
227 let config = std::sync::Arc::new(
228 tower_governor::governor::GovernorConfigBuilder::default()
229 .key_extractor(CloudflareIpKeyExtractor)
230 .per_second(per_sec)
231 .burst_size(burst)
232 .use_headers()
233 .finish()
234 .expect("rate limiter config"),
235 );
236 let limiter = config.limiter().clone();
237 register_for_sweep(move || {
238 limiter.retain_recent();
239 limiter.len()
240 });
241 config
242 }
243
244 /// Build a per-SyncKit-app rate limiter from a per-millisecond interval and burst size.
245 ///
246 /// `secret` is the SyncKit JWT signing secret; when present the extractor
247 /// verifies token signatures (so forged `app` claims can't mint fresh buckets).
248 pub fn synckit_app_rate_limiter_ms(
249 secret: Option<std::sync::Arc<String>>,
250 ms: u64,
251 burst: u32,
252 ) -> std::sync::Arc<
253 tower_governor::governor::GovernorConfig<
254 SyncAppKeyExtractor,
255 ::governor::middleware::StateInformationMiddleware,
256 >,
257 > {
258 let config = std::sync::Arc::new(
259 tower_governor::governor::GovernorConfigBuilder::default()
260 .key_extractor(SyncAppKeyExtractor::new(secret))
261 .per_millisecond(ms)
262 .burst_size(burst)
263 .use_headers()
264 .finish()
265 .expect("synckit app rate limiter config"),
266 );
267 let limiter = config.limiter().clone();
268 register_for_sweep(move || {
269 limiter.retain_recent();
270 limiter.len()
271 });
272 config
273 }
274
275 #[cfg(test)]
276 mod tests {
277 use super::*;
278 use axum::http::Request;
279 use base64::Engine;
280 use tower_governor::key_extractor::KeyExtractor;
281
282 /// Build a fake JWT with the given app ID in the payload (no signature verification).
283 fn fake_jwt(app_id: &SyncAppId) -> String {
284 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
285 .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
286 let payload_json = serde_json::json!({
287 "sub": "00000000-0000-0000-0000-000000000001",
288 "app": app_id,
289 "iss": "makenotwork-synckit",
290 "exp": 9_999_999_999_i64,
291 "iat": 1_000_000_000_i64,
292 });
293 let payload =
294 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.to_string());
295 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("fakesig");
296 format!("{header}.{payload}.{sig}")
297 }
298
299 /// Sign a real HS256 token carrying the given app id, using `secret`.
300 fn signed_jwt(secret: &str, app_id: &SyncAppId) -> String {
301 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
302 let claims = serde_json::json!({
303 "sub": "00000000-0000-0000-0000-000000000001",
304 "app": app_id,
305 "iss": "makenotwork-synckit",
306 "exp": 9_999_999_999_i64,
307 "iat": 1_000_000_000_i64,
308 });
309 encode(
310 &Header::new(Algorithm::HS256),
311 &claims,
312 &EncodingKey::from_secret(secret.as_bytes()),
313 )
314 .unwrap()
315 }
316
317 #[test]
318 fn no_secret_collapses_unverified_app_to_nil() {
319 // No secret configured (SyncKit off): the `app` claim is unauthenticated,
320 // so it must NOT become its own bucket, the extractor collapses it to the
321 // nil sentinel rather than trusting a forgeable UUID (Sec-MINOR, Run 9).
322 let app_id = SyncAppId::new();
323 let token = fake_jwt(&app_id);
324
325 let req = Request::builder()
326 .header("authorization", format!("Bearer {token}"))
327 .body(())
328 .unwrap();
329
330 let extracted = SyncAppKeyExtractor::new(None).extract(&req).unwrap();
331 assert_eq!(extracted, SyncAppId::nil());
332 }
333
334 #[test]
335 fn verified_extracts_app_id_from_validly_signed_jwt() {
336 let secret = "test-secret-key-for-synckit-jwt".to_string();
337 let app_id = SyncAppId::new();
338 let token = signed_jwt(&secret, &app_id);
339
340 let req = Request::builder()
341 .header("authorization", format!("Bearer {token}"))
342 .body(())
343 .unwrap();
344
345 let extractor = SyncAppKeyExtractor::new(Some(std::sync::Arc::new(secret)));
346 assert_eq!(extractor.extract(&req).unwrap(), app_id);
347 }
348
349 #[test]
350 fn verified_forged_token_collapses_to_nil_bucket() {
351 // A token whose `app` claim is attacker-chosen but whose signature does
352 // NOT match the configured secret must NOT earn its own bucket, it
353 // collapses to the nil sentinel so a spray of fresh `app` claims can't
354 // manufacture unlimited buckets.
355 let secret = "test-secret-key-for-synckit-jwt".to_string();
356 let attacker_app = SyncAppId::new();
357 let forged = fake_jwt(&attacker_app); // signed with "fakesig", not `secret`
358
359 let req = Request::builder()
360 .header("authorization", format!("Bearer {forged}"))
361 .body(())
362 .unwrap();
363
364 let extractor = SyncAppKeyExtractor::new(Some(std::sync::Arc::new(secret)));
365 assert_eq!(extractor.extract(&req).unwrap(), SyncAppId::nil());
366 }
367
368 #[test]
369 fn verified_spray_of_forged_apps_all_share_one_bucket() {
370 // The core anti-spray property: many DISTINCT forged app claims must all
371 // map to the SAME (nil) key, not N distinct keys.
372 let secret = std::sync::Arc::new("test-secret-key-for-synckit-jwt".to_string());
373 let extractor = SyncAppKeyExtractor::new(Some(secret));
374 for _ in 0..5 {
375 let forged = fake_jwt(&SyncAppId::new());
376 let req = Request::builder()
377 .header("authorization", format!("Bearer {forged}"))
378 .body(())
379 .unwrap();
380 assert_eq!(extractor.extract(&req).unwrap(), SyncAppId::nil());
381 }
382 }
383
384 #[test]
385 fn missing_auth_header_returns_nil_sentinel() {
386 let req = Request::builder().body(()).unwrap();
387 let key = SyncAppKeyExtractor::new(None).extract(&req).unwrap();
388 assert_eq!(key, SyncAppId::nil());
389 }
390
391 #[test]
392 fn non_bearer_auth_returns_nil_sentinel() {
393 let req = Request::builder()
394 .header("authorization", "Basic dXNlcjpwYXNz")
395 .body(())
396 .unwrap();
397 let key = SyncAppKeyExtractor::new(None).extract(&req).unwrap();
398 assert_eq!(key, SyncAppId::nil());
399 }
400
401 #[test]
402 fn malformed_jwt_collapses_to_nil_sentinel() {
403 // Previously this returned an extractor error; now a malformed token
404 // collapses to the shared nil bucket (same anti-spray treatment as a
405 // forged one) rather than erroring the request out of the limiter.
406 let req = Request::builder()
407 .header("authorization", "Bearer not-a-jwt")
408 .body(())
409 .unwrap();
410 let key = SyncAppKeyExtractor::new(None).extract(&req).unwrap();
411 assert_eq!(key, SyncAppId::nil());
412 }
413
414 #[test]
415 fn jwt_missing_app_claim_collapses_to_nil_sentinel() {
416 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
417 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
418 .encode(r#"{"sub":"user","iss":"test"}"#);
419 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("sig");
420 let token = format!("{header}.{payload}.{sig}");
421
422 let req = Request::builder()
423 .header("authorization", format!("Bearer {token}"))
424 .body(())
425 .unwrap();
426 let key = SyncAppKeyExtractor::new(None).extract(&req).unwrap();
427 assert_eq!(key, SyncAppId::nil());
428 }
429
430 #[test]
431 fn cf_connecting_ip_is_used_when_present() {
432 let req = Request::builder()
433 .header("cf-connecting-ip", "203.0.113.7")
434 .body(())
435 .unwrap();
436 let key = CloudflareIpKeyExtractor.extract(&req).unwrap();
437 assert_eq!(key, "203.0.113.7".parse::<std::net::IpAddr>().unwrap());
438 }
439
440 #[test]
441 fn forged_x_forwarded_for_is_not_trusted() {
442 // No cf-connecting-ip and no ConnectInfo (the prod case where Caddy is
443 // bypassed). A client-supplied X-Forwarded-For must NOT become the key,
444 // the fallback is the TCP peer (PeerIpKeyExtractor), which errors here
445 // because no ConnectInfo extension is present. The key assertion is that
446 // we do NOT return the spoofed 1.2.3.4.
447 let req = Request::builder()
448 .header("x-forwarded-for", "1.2.3.4")
449 .header("x-real-ip", "1.2.3.4")
450 .body(())
451 .unwrap();
452 let result = CloudflareIpKeyExtractor.extract(&req);
453 assert!(
454 result.is_err(),
455 "forged XFF/X-Real-IP must not yield a per-IP bucket; got {result:?}"
456 );
457 }
458
459 #[test]
460 fn unsigned_apps_collapse_to_nil_when_no_secret() {
461 // With no secret configured the `app` claim is unauthenticated, so two
462 // DIFFERENT forged tokens must NOT mint two distinct buckets, both
463 // collapse to the shared nil sentinel. Otherwise an attacker sprays fresh
464 // per-app rate-limit buckets by fabricating app UUIDs (Sec-MINOR, Run 9).
465 let app1 = SyncAppId::new();
466 let app2 = SyncAppId::new();
467
468 let req1 = Request::builder()
469 .header("authorization", format!("Bearer {}", fake_jwt(&app1)))
470 .body(())
471 .unwrap();
472 let req2 = Request::builder()
473 .header("authorization", format!("Bearer {}", fake_jwt(&app2)))
474 .body(())
475 .unwrap();
476
477 let extractor = SyncAppKeyExtractor::new(None);
478 let key1 = extractor.extract(&req1).unwrap();
479 let key2 = extractor.extract(&req2).unwrap();
480 assert_eq!(key1, SyncAppId::nil());
481 assert_eq!(key2, SyncAppId::nil());
482 }
483 }
484