lean_ctx/gateway_server/
security.rs1use std::collections::HashMap;
19use std::net::IpAddr;
20use std::sync::Mutex;
21use std::time::{Duration, Instant};
22
23use axum::http::header::{CACHE_CONTROL, HeaderName, HeaderValue};
24
25const MAX_FAILURES_PER_WINDOW: u32 = 10;
27const WINDOW: Duration = Duration::from_mins(1);
29const MAX_TRACKED_IPS: usize = 10_000;
31
32const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self'; \
35 img-src 'self' data:; font-src 'self'; connect-src 'self'; \
36 frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
37
38pub async fn security_headers(
40 req: axum::extract::Request,
41 next: axum::middleware::Next,
42) -> axum::response::Response {
43 let path = req.uri().path().to_string();
44 let mut res = next.run(req).await;
45 let h = res.headers_mut();
46
47 let set = |h: &mut axum::http::HeaderMap, name: &'static str, value: &'static str| {
48 h.insert(
49 HeaderName::from_static(name),
50 HeaderValue::from_static(value),
51 );
52 };
53 set(h, "content-security-policy", CSP);
54 set(h, "x-content-type-options", "nosniff");
55 set(h, "x-frame-options", "DENY");
56 set(h, "referrer-policy", "no-referrer");
57 set(h, "cross-origin-opener-policy", "same-origin");
58 set(h, "cross-origin-resource-policy", "same-origin");
59
60 let cache = if path.starts_with("/api/") || path == "/metrics" {
64 "no-store"
65 } else if path.starts_with("/static/") || path.starts_with("/me/static/") {
66 "public, max-age=3600"
67 } else {
68 "no-cache"
69 };
70 h.insert(CACHE_CONTROL, HeaderValue::from_static(cache));
71 res
72}
73
74#[derive(Debug, Default)]
76pub struct AuthThrottle {
77 windows: Mutex<HashMap<IpAddr, (Instant, u32)>>,
78}
79
80impl AuthThrottle {
81 pub fn is_blocked(&self, ip: IpAddr) -> bool {
84 let mut w = lock(&self.windows);
85 match w.get(&ip) {
86 Some((start, n)) if start.elapsed() < WINDOW => *n >= MAX_FAILURES_PER_WINDOW,
87 Some(_) => {
88 w.remove(&ip);
89 false
90 }
91 None => false,
92 }
93 }
94
95 pub fn record_failure(&self, ip: IpAddr) -> u32 {
97 let mut w = lock(&self.windows);
98 if w.len() >= MAX_TRACKED_IPS && !w.contains_key(&ip) {
99 w.retain(|_, (start, _)| start.elapsed() < WINDOW);
100 if w.len() >= MAX_TRACKED_IPS {
101 return MAX_FAILURES_PER_WINDOW;
104 }
105 }
106 let now = Instant::now();
107 let entry = w.entry(ip).or_insert((now, 0));
108 if entry.0.elapsed() >= WINDOW {
109 *entry = (now, 0);
110 }
111 entry.1 += 1;
112 entry.1
113 }
114
115 pub fn record_success(&self, ip: IpAddr) {
117 lock(&self.windows).remove(&ip);
118 }
119}
120
121fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
122 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn ip(last: u8) -> IpAddr {
130 IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, last))
131 }
132
133 #[test]
134 fn throttle_blocks_after_budget_and_resets_on_success() {
135 let t = AuthThrottle::default();
136 assert!(!t.is_blocked(ip(1)));
137 for _ in 0..MAX_FAILURES_PER_WINDOW {
138 t.record_failure(ip(1));
139 }
140 assert!(t.is_blocked(ip(1)), "budget exhausted → blocked");
141 assert!(!t.is_blocked(ip(2)), "per-IP isolation");
142 t.record_success(ip(1));
143 assert!(!t.is_blocked(ip(1)), "success clears the window");
144 }
145
146 #[test]
147 fn throttle_counts_failures_within_window() {
148 let t = AuthThrottle::default();
149 assert_eq!(t.record_failure(ip(3)), 1);
150 assert_eq!(t.record_failure(ip(3)), 2);
151 assert!(!t.is_blocked(ip(3)), "under budget stays open");
152 }
153
154 #[test]
155 fn csp_has_no_remote_sources() {
156 for directive in CSP.split(';') {
159 assert!(
160 !directive.contains("http"),
161 "CSP must not allow remote origins: {directive}"
162 );
163 }
164 assert!(CSP.contains("frame-ancestors 'none'"));
165 }
166}