Skip to main content

pebble_cms/web/
security.rs

1use crate::web::state::AppState;
2use axum::body::Body;
3use axum::extract::{ConnectInfo, State};
4use axum::http::header::HeaderValue;
5use axum::http::{header, Method, Request, Response, StatusCode};
6use axum::middleware::Next;
7use axum::response::IntoResponse;
8use axum_extra::extract::CookieJar;
9use once_cell::sync::Lazy;
10use std::collections::HashMap;
11use std::net::SocketAddr;
12use std::sync::{Arc, RwLock};
13use std::time::{Duration, Instant};
14
15// Pre-computed header values to avoid runtime parsing and unwrap
16static HEADER_NOSNIFF: Lazy<HeaderValue> = Lazy::new(|| HeaderValue::from_static("nosniff"));
17static HEADER_DENY: Lazy<HeaderValue> = Lazy::new(|| HeaderValue::from_static("DENY"));
18static HEADER_XSS_PROTECTION: Lazy<HeaderValue> =
19    Lazy::new(|| HeaderValue::from_static("1; mode=block"));
20static HEADER_REFERRER_POLICY: Lazy<HeaderValue> =
21    Lazy::new(|| HeaderValue::from_static("strict-origin-when-cross-origin"));
22static HEADER_HSTS: Lazy<HeaderValue> =
23    Lazy::new(|| HeaderValue::from_static("max-age=63072000; includeSubDomains"));
24// Public pages: strict CSP — no inline scripts, all JS self-hosted
25static HEADER_CSP_PUBLIC: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
26// Admin pages: relaxed script-src to allow inline scripts in admin templates
27static HEADER_CSP_ADMIN: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
28
29pub struct RateLimiter {
30    attempts: RwLock<HashMap<String, Vec<Instant>>>,
31    max_attempts: usize,
32    window: Duration,
33    lockout: Duration,
34}
35
36impl Default for RateLimiter {
37    fn default() -> Self {
38        Self::new(5, Duration::from_secs(300), Duration::from_secs(900))
39    }
40}
41
42impl RateLimiter {
43    pub fn new(max_attempts: usize, window: Duration, lockout: Duration) -> Self {
44        Self {
45            attempts: RwLock::new(HashMap::new()),
46            max_attempts,
47            window,
48            lockout,
49        }
50    }
51
52    pub fn check(&self, key: &str) -> bool {
53        let now = Instant::now();
54        let mut attempts = self.attempts.write().unwrap_or_else(|e| e.into_inner());
55
56        let entry = attempts.entry(key.to_string()).or_default();
57        entry.retain(|t| now.duration_since(*t) < self.window);
58
59        if entry.len() >= self.max_attempts {
60            let oldest = entry.first().copied();
61            if let Some(oldest_time) = oldest {
62                if now.duration_since(oldest_time) < self.lockout {
63                    return false;
64                }
65                entry.clear();
66            }
67        }
68
69        true
70    }
71
72    pub fn record_attempt(&self, key: &str) {
73        let mut attempts = self.attempts.write().unwrap_or_else(|e| e.into_inner());
74        let entry = attempts.entry(key.to_string()).or_default();
75        entry.push(Instant::now());
76    }
77
78    pub fn clear(&self, key: &str) {
79        let mut attempts = self.attempts.write().unwrap_or_else(|e| e.into_inner());
80        attempts.remove(key);
81    }
82
83    pub fn cleanup(&self) {
84        let now = Instant::now();
85        let mut attempts = self.attempts.write().unwrap_or_else(|e| e.into_inner());
86        attempts.retain(|_, v| {
87            v.retain(|t| now.duration_since(*t) < self.window);
88            !v.is_empty()
89        });
90    }
91}
92
93pub struct CsrfManager;
94
95impl Default for CsrfManager {
96    fn default() -> Self {
97        Self
98    }
99}
100
101impl CsrfManager {
102    pub fn generate(&self) -> String {
103        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
104        use rand::RngCore;
105
106        let mut bytes = [0u8; 32];
107        rand::rngs::OsRng.fill_bytes(&mut bytes);
108        URL_SAFE_NO_PAD.encode(bytes)
109    }
110
111    pub fn validate(&self, form_token: &str, cookie_token: &str) -> bool {
112        if form_token.is_empty() || cookie_token.is_empty() {
113            return false;
114        }
115        if form_token.len() != cookie_token.len() {
116            return false;
117        }
118        let result = form_token
119            .bytes()
120            .zip(cookie_token.bytes())
121            .fold(0u8, |acc, (a, b)| acc | (a ^ b));
122        result == 0
123    }
124}
125
126pub async fn apply_security_headers(request: Request<Body>, next: Next) -> Response<Body> {
127    let is_admin = request.uri().path().starts_with("/admin");
128    let mut response = next.run(request).await;
129
130    let headers = response.headers_mut();
131    headers.insert(header::X_CONTENT_TYPE_OPTIONS, HEADER_NOSNIFF.clone());
132    headers.insert(header::X_FRAME_OPTIONS, HEADER_DENY.clone());
133    headers.insert(header::X_XSS_PROTECTION, HEADER_XSS_PROTECTION.clone());
134    headers.insert(header::REFERRER_POLICY, HEADER_REFERRER_POLICY.clone());
135    headers.insert(header::STRICT_TRANSPORT_SECURITY, HEADER_HSTS.clone());
136
137    if !headers.contains_key(header::CONTENT_SECURITY_POLICY) {
138        let csp = if is_admin {
139            HEADER_CSP_ADMIN
140        } else {
141            HEADER_CSP_PUBLIC
142        };
143        if let Ok(val) = HeaderValue::from_str(csp) {
144            headers.insert(header::CONTENT_SECURITY_POLICY, val);
145        }
146    }
147
148    response
149}
150
151/// Middleware that rate-limits write operations (POST/DELETE) on admin endpoints.
152/// Keyed by session cookie so legitimate multi-user setups aren't penalized.
153pub async fn write_rate_limit_middleware(
154    State(state): State<Arc<AppState>>,
155    connect_info: Option<ConnectInfo<SocketAddr>>,
156    request: Request<Body>,
157    next: Next,
158) -> Response<Body> {
159    let method = request.method().clone();
160    let path = request.uri().path().to_string();
161
162    // Only rate-limit write operations on admin routes (not login — that has its own limiter)
163    let is_write = (method == Method::POST || method == Method::DELETE)
164        && path.starts_with("/admin")
165        && path != "/admin/login";
166
167    if !is_write {
168        return next.run(request).await;
169    }
170
171    // Key by session cookie or IP for unauthenticated requests
172    let cookies = CookieJar::from_headers(request.headers());
173    let key = cookies
174        .get("session")
175        .map(|c| format!("write:{}", c.value()))
176        .unwrap_or_else(|| {
177            let ip = connect_info
178                .map(|ci| ci.0.ip().to_string())
179                .unwrap_or_else(|| "unknown".to_string());
180            format!("write:{}", ip)
181        });
182
183    if !state.write_rate_limiter.check(&key) {
184        return (
185            StatusCode::TOO_MANY_REQUESTS,
186            "Too many write operations. Please slow down.",
187        )
188            .into_response();
189    }
190
191    state.write_rate_limiter.record_attempt(&key);
192    next.run(request).await
193}