Skip to main content

lean_ctx/gateway_server/
security.rs

1//! Admin-port security hardening (#54): response headers + auth throttling.
2//!
3//! Split out of `serve.rs` so the wiring stays readable and both pieces are
4//! unit-testable in isolation.
5//!
6//! **Headers** — every admin response carries a strict `Content-Security-Policy`
7//! (the console is self-contained: no CDN, no inline scripts), clickjacking and
8//! MIME-sniffing guards, and cache rules that keep token-guarded JSON out of
9//! shared caches. HSTS is deliberately *not* set here: TLS terminates at the
10//! ingress/reverse proxy (see `lean-ctx-deploy` SECURITY.md), and a backend-set
11//! HSTS on a plain-HTTP loopback deployment would poison local browsers.
12//!
13//! **Throttle** — fixed-window failed-auth limiter per client IP. The Bearer
14//! token is 256-bit random (brute force is not a practical risk); the limiter
15//! exists so scanners/mistyped scripts produce a clean, auditable signal (429 +
16//! a `tracing` line per failure) instead of an unbounded 401 stream.
17
18use 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
25/// Failed attempts allowed per IP per window before 429.
26const MAX_FAILURES_PER_WINDOW: u32 = 10;
27/// Window length of the failed-auth limiter.
28const WINDOW: Duration = Duration::from_mins(1);
29/// Hard cap on tracked IPs (memory guard; oldest windows are pruned lazily).
30const MAX_TRACKED_IPS: usize = 10_000;
31
32/// CSP for the embedded console: everything ships from the gateway itself.
33/// `img-src data:` covers the inline SVG favicon; no other exception exists.
34const 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
38/// Middleware: stamps the security headers on every admin-port response.
39pub 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    // Token-guarded payloads must never land in shared caches; immutable
61    // static assets may (they change only with the binary). `/me/static/` is
62    // the personal view's asset namespace on the proxy port (enterprise#64).
63    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/// Fixed-window failed-auth limiter per client IP (#54/#57).
75#[derive(Debug, Default)]
76pub struct AuthThrottle {
77    windows: Mutex<HashMap<IpAddr, (Instant, u32)>>,
78}
79
80impl AuthThrottle {
81    /// True when `ip` has exhausted its failure budget for the current window
82    /// (the caller responds 429 without evaluating credentials).
83    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    /// Records a failed attempt; returns the failure count in the window.
96    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                // Saturated by active windows — treat the newcomer as blocked
102                // rather than growing without bound.
103                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    /// Clears the window after a successful authentication.
116    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        // The console is fully embedded; any remote origin in the CSP would
157        // signal an accidental CDN dependency.
158        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}