rullst_core/security/
csrf.rs1use axum::{
4 extract::Request,
5 http::{StatusCode, header},
6 middleware::Next,
7 response::{IntoResponse, Response},
8};
9use rand::distr::{Alphanumeric, SampleString};
10use subtle::ConstantTimeEq;
11
12pub fn generate_csrf_token() -> String {
14 Alphanumeric.sample_string(&mut rand::rng(), 32)
15}
16
17#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct CsrfToken(String);
24
25impl CsrfToken {
26 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31}
32
33#[derive(Clone, Copy, Debug)]
36struct CsrfMiddlewareApplied;
37
38#[derive(serde::Deserialize)]
39struct CsrfForm {
40 _token: Option<String>,
41}
42
43pub(crate) fn extract_token_from_body(bytes: &[u8]) -> Option<String> {
45 serde_urlencoded::from_bytes::<CsrfForm>(bytes)
46 .ok()
47 .and_then(|form| form._token)
48}
49
50pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
62 if let Some(policy) = req
63 .extensions()
64 .get::<super::MachineEndpointPolicy>()
65 .cloned()
66 && policy.matches(&req)
67 {
68 return match policy.authenticate(req).await {
69 Ok(request) => next.run(request).await,
70 Err(status) => (status, "Machine request rejected").into_response(),
71 };
72 }
73 if req.extensions().get::<CsrfMiddlewareApplied>().is_some() {
74 return next.run(req).await;
75 }
76
77 if is_signed_webhook_exemption(&req) {
78 return next.run(req).await;
79 }
80
81 req.extensions_mut().insert(CsrfMiddlewareApplied);
82
83 let method = req.method();
84
85 if method == axum::http::Method::GET || method == axum::http::Method::HEAD {
88 handle_csrf_get(req, next).await
89 } else if method == axum::http::Method::OPTIONS || method == axum::http::Method::TRACE {
90 next.run(req).await
91 } else {
92 handle_csrf_state_modifying(req, next).await
93 }
94}
95
96fn is_signed_webhook_exemption(req: &Request) -> bool {
97 if req.method() != axum::http::Method::POST {
98 return false;
99 }
100
101 let config = req
102 .extensions()
103 .get::<crate::config::SecurityConfig>()
104 .unwrap_or(&crate::config::RullstConfig::global().security);
105 config
106 .csrf_signed_webhook_paths
107 .iter()
108 .any(|path| path == req.uri().path())
109}
110
111pub(crate) fn is_csrf_exempt_path(path: &str) -> bool {
112 path == "/robots.txt"
113 || path == "/sitemap.xml"
114 || path == "/favicon.ico"
115 || path.starts_with("/static/")
116 || path.ends_with(".txt")
117 || path.ends_with(".xml")
118 || path.ends_with(".ico")
119 || path.ends_with(".json")
120 || path.ends_with(".css")
121 || path.ends_with(".js")
122 || path.ends_with(".png")
123 || path.ends_with(".jpg")
124 || path.ends_with(".jpeg")
125 || path.ends_with(".svg")
126 || path.ends_with(".webp")
127 || path.ends_with(".wasm")
128}
129
130async fn handle_csrf_get(mut req: Request, next: Next) -> Response {
131 if is_csrf_exempt_path(req.uri().path()) {
132 return next.run(req).await;
133 }
134
135 let cookie_token = csrf_token_from_cookies(req.headers());
136
137 if let Some(token) = cookie_token {
138 req.extensions_mut().insert(CsrfToken(token));
139 return next.run(req).await;
140 }
141
142 let token = generate_csrf_token();
143 req.extensions_mut().insert(CsrfToken(token.clone()));
144 {
145 let same_site = req
146 .extensions()
147 .get::<crate::config::SecurityConfig>()
148 .map(|cfg| cfg.csrf_same_site.clone())
149 .unwrap_or_else(|| "Lax".to_string());
150 let secure_cookie = req
151 .extensions()
152 .get::<crate::config::Environment>()
153 .copied()
154 .map(crate::config::Environment::requires_secure_defaults)
155 .unwrap_or_else(|| {
156 crate::config::Environment::detect(None)
157 .map(crate::config::Environment::requires_secure_defaults)
158 .unwrap_or(true)
159 });
160
161 let mut response = next.run(req).await;
162
163 let secure_attr = if secure_cookie { "; Secure" } else { "" };
164 if let Ok(cookie_val) = header::HeaderValue::from_str(&format!(
165 "rullst_csrf={}; Path=/; SameSite={}{}",
166 token, same_site, secure_attr
167 )) {
168 response
169 .headers_mut()
170 .append(header::SET_COOKIE, cookie_val);
171 }
172 response
173 }
174}
175
176fn csrf_token_from_cookies(headers: &header::HeaderMap) -> Option<String> {
182 let mut found = None;
183 for value in headers.get_all(header::COOKIE) {
184 for cookie in value.as_bytes().split(|byte| *byte == b';') {
185 let Some(token) = cookie.trim_ascii().strip_prefix(b"rullst_csrf=") else {
186 continue;
187 };
188 if found.is_some()
189 || !(1..=128).contains(&token.len())
190 || !token
191 .iter()
192 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
193 {
194 return None;
195 }
196 let Ok(token) = std::str::from_utf8(token) else {
197 return None;
198 };
199 found = Some(token.to_owned());
200 }
201 }
202 found
203}
204
205#[cfg_attr(mutants, mutants::skip)]
206async fn handle_csrf_state_modifying(mut req: Request, next: Next) -> Response {
207 let csrf_cookie = csrf_token_from_cookies(req.headers());
208
209 let Some(cookie_token) = csrf_cookie else {
210 return (StatusCode::FORBIDDEN, "CSRF token cookie missing").into_response();
211 };
212
213 let mut header_values = req.headers().get_all("X-CSRF-Token").iter();
215 let header_token = header_values.next();
216 if header_values.next().is_some() {
217 return (StatusCode::FORBIDDEN, "Ambiguous CSRF token").into_response();
218 }
219
220 if let Some(token) = header_token {
221 if token.as_bytes().len() == cookie_token.len()
222 && token.as_bytes().ct_eq(cookie_token.as_bytes()).into()
223 {
224 req.extensions_mut().insert(CsrfToken(cookie_token.clone()));
225 return next.run(req).await;
226 }
227 return (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response();
228 }
229
230 let content_type = req
232 .headers()
233 .get(header::CONTENT_TYPE)
234 .and_then(|v| v.to_str().ok())
235 .unwrap_or("");
236
237 if content_type.split(';').next().is_some_and(|media_type| {
238 media_type
239 .trim()
240 .eq_ignore_ascii_case("application/x-www-form-urlencoded")
241 }) {
242 let (mut parts, body) = req.into_parts();
243
244 let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
246 Ok(b) => b,
247 Err(_) => {
248 return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
249 }
250 };
251
252 let body_token = extract_token_from_body(&bytes);
253
254 if let Some(token) = body_token {
255 if token.len() == cookie_token.len()
256 && token.as_bytes().ct_eq(cookie_token.as_bytes()).into()
257 {
258 parts.extensions.insert(CsrfToken(cookie_token));
259 let reconstructed_req = Request::from_parts(parts, axum::body::Body::from(bytes));
261 return next.run(reconstructed_req).await;
262 }
263 }
264 }
265
266 (StatusCode::FORBIDDEN, "Invalid or missing CSRF token").into_response()
267}
268
269#[cfg(test)]
270#[path = "csrf_tests.rs"]
271mod tests;