Skip to main content

rullst_core/security/
csrf.rs

1//! CSRF protection middleware and Double Submit Cookie validation.
2
3use 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
12/// Generates a cryptographically secure 32-character random alphanumeric string.
13pub fn generate_csrf_token() -> String {
14    Alphanumeric.sample_string(&mut rand::rng(), 32)
15}
16
17/// Request-scoped CSRF token made available to handlers rendering forms.
18///
19/// On the first `GET` or `HEAD` request this is the same token that the
20/// middleware writes to the response cookie. Exposing it through request extensions avoids
21/// rendering an empty hidden field before the browser has received that cookie.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct CsrfToken(String);
24
25impl CsrfToken {
26    /// Returns the token that must be echoed in an `X-CSRF-Token` header or
27    /// `_token` form field.
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33/// Private request marker preventing nested framework/application composition
34/// from applying the double-submit protocol twice.
35#[derive(Clone, Copy, Debug)]
36struct CsrfMiddlewareApplied;
37
38#[derive(serde::Deserialize)]
39struct CsrfForm {
40    _token: Option<String>,
41}
42
43/// Helper to extract the token from form-encoded body bytes.
44pub(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
50/// Middleware that enforces CSRF protection using the Double Submit Cookie pattern.
51/// GET and HEAD requests receive the request-scoped [`CsrfToken`] and generate a
52/// CSRF cookie if missing; HEAD mirrors GET's headers (RFC 9110), so a `get`
53/// route extracting `Extension<CsrfToken>` also answers HEAD. `OPTIONS` and
54/// `TRACE` pass through, while state-changing requests must match the
55/// `rullst_csrf` cookie token with either the `X-CSRF-Token` header or form
56/// `_token` field.
57///
58/// Applying this middleware more than once to the same request is idempotent. This
59/// matters when an application router adds the explicit development layer and
60/// [`crate::Server`] later composes the production security baseline around it.
61pub 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    // Axum serves HEAD through the GET handler, so HEAD gets the same token
86    // and cookie as GET.
87    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
176/// Returns the single valid `rullst_csrf` cookie across every `Cookie` header.
177///
178/// Pairs are split on the raw header bytes: browsers send non-ASCII
179/// `document.cookie` values as UTF-8, and such an unrelated cookie must not
180/// hide the CSRF cookie. A duplicate or malformed CSRF cookie still fails.
181fn 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    // Check header first (common for AJAX/HTMX)
214    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    // If not in header, check if it's a form-urlencoded request before buffering the body
231    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        // Read request body (limited to 1MB to prevent memory exhaustion)
245        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                // Reconstruct the request so it can be parsed by subsequent handlers.
260                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;