pebble_cms/web/
security.rs1use 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
15static 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"));
24static 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'";
26static 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
151pub 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 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 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}