Skip to main content

alibi_core/middleware/csrf/
mod.rs

1use super::Middleware;
2use crate::config::{AuthConfig, extract_origin};
3use crate::error::{AuthError, AuthResult};
4use crate::types::{AuthRequest, AuthResponse, HttpMethod};
5use async_trait::async_trait;
6#[cfg(test)]
7use std::collections::HashMap;
8use std::sync::Arc;
9
10const CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: &str =
11    "Cross-site navigation login blocked. This request appears to be a CSRF attack.";
12const INVALID_CALLBACK_URL: &str = "Invalid callbackURL";
13const INVALID_ERROR_CALLBACK_URL: &str = "Invalid errorCallbackURL";
14const INVALID_NEW_USER_CALLBACK_URL: &str = "Invalid newUserCallbackURL";
15const INVALID_REDIRECT_URL: &str = "Invalid redirectURL";
16const INVALID_ORIGIN: &str = "Invalid origin";
17const MISSING_OR_NULL_ORIGIN: &str = "Missing or null Origin";
18
19/// Configuration for Better Auth request-origin and CSRF protection.
20#[derive(Debug, Clone)]
21pub struct CsrfConfig {
22    /// Whether the request protection middleware is enabled. Defaults to `true`.
23    pub enabled: bool,
24}
25
26impl Default for CsrfConfig {
27    fn default() -> Self {
28        Self { enabled: true }
29    }
30}
31
32impl CsrfConfig {
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    #[must_use]
39    pub const fn enabled(mut self, enabled: bool) -> Self {
40        self.enabled = enabled;
41        self
42    }
43}
44
45/// Better Auth request protection middleware.
46///
47/// This mirrors the upstream TypeScript behavior:
48/// - mutating requests with cookies require a trusted `Origin` / `Referer`
49/// - first-login `POST /sign-up/email` and `POST /sign-in/email` also use
50///   Fetch Metadata headers to block cross-site navigation attacks
51/// - callback / redirect targets are validated against trusted origins unless
52///   `advanced.disable_origin_check` is set
53#[derive(Debug)]
54pub struct CsrfMiddleware {
55    config: CsrfConfig,
56    auth_config: Arc<AuthConfig>,
57}
58
59impl CsrfMiddleware {
60    #[must_use]
61    pub const fn new(config: CsrfConfig, auth_config: Arc<AuthConfig>) -> Self {
62        Self {
63            config,
64            auth_config,
65        }
66    }
67
68    /// Retain protection settings with the effective request-local URL policy.
69    #[must_use]
70    pub fn with_auth_config(&self, auth_config: Arc<AuthConfig>) -> Self {
71        Self::new(self.config.clone(), auth_config)
72    }
73
74    const fn is_state_changing(method: &HttpMethod) -> bool {
75        matches!(
76            method,
77            HttpMethod::Post | HttpMethod::Put | HttpMethod::Delete | HttpMethod::Patch
78        )
79    }
80
81    fn normalized_path<'a>(&self, path: &'a str) -> &'a str {
82        let base_path = self.auth_config.base_path.as_str();
83        if !base_path.is_empty() && base_path != "/" {
84            path.strip_prefix(base_path).unwrap_or(path)
85        } else {
86            path
87        }
88    }
89
90    fn is_form_csrf_path(path: &str) -> bool {
91        matches!(path, "/sign-in/email" | "/sign-up/email")
92    }
93
94    fn header<'a>(req: &'a AuthRequest, name: &str) -> Option<&'a str> {
95        req.headers
96            .iter()
97            .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str()))
98    }
99
100    fn has_cookies(req: &AuthRequest) -> bool {
101        Self::header(req, "cookie").is_some()
102    }
103
104    fn has_fetch_metadata(req: &AuthRequest) -> bool {
105        ["sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest"]
106            .into_iter()
107            .any(|name| Self::header(req, name).is_some_and(|value| !value.trim().is_empty()))
108    }
109
110    fn validate_origin(&self, req: &AuthRequest, force_validate: bool) -> Result<(), AuthError> {
111        if self.auth_config.advanced.disable_csrf_check == Some(true)
112            || self.auth_config.origin_check_disabled_for(req.path())
113        {
114            return Ok(());
115        }
116
117        if !force_validate && !Self::has_cookies(req) {
118            return Ok(());
119        }
120
121        let inferred = (Self::header(req, "origin") == Some("null")
122            && Self::header(req, "sec-fetch-site") == Some("same-origin"))
123        .then(|| req.url().and_then(|url| extract_origin(url.as_str())))
124        .flatten();
125        let origin = inferred
126            .or_else(|| {
127                Self::header(req, "origin")
128                    .filter(|value| !value.is_empty())
129                    .map(ToOwned::to_owned)
130                    .or_else(|| Self::header(req, "referer").and_then(extract_origin))
131            })
132            .filter(|value| value != "null")
133            .ok_or_else(|| AuthError::forbidden(MISSING_OR_NULL_ORIGIN))?;
134
135        if self.auth_config.is_origin_trusted(&origin) {
136            Ok(())
137        } else {
138            Err(AuthError::forbidden(INVALID_ORIGIN))
139        }
140    }
141
142    fn validate_form_csrf(&self, req: &AuthRequest) -> Result<(), AuthError> {
143        if self.auth_config.advanced.disable_csrf_check == Some(true)
144            || self.auth_config.advanced.disable_origin_check
145                && self.auth_config.advanced.disable_csrf_check.is_none()
146        {
147            return Ok(());
148        }
149
150        if Self::has_cookies(req) {
151            return self.validate_origin(req, false);
152        }
153
154        if Self::has_fetch_metadata(req) {
155            let is_cross_site_navigation = matches!(
156                (
157                    Self::header(req, "sec-fetch-site"),
158                    Self::header(req, "sec-fetch-mode"),
159                ),
160                (Some("cross-site"), Some("navigate"))
161            );
162
163            if is_cross_site_navigation {
164                return Err(AuthError::forbidden(CROSS_SITE_NAVIGATION_LOGIN_BLOCKED));
165            }
166
167            return self.validate_origin(req, true);
168        }
169
170        if Self::header(req, "origin").is_some_and(|value| !value.is_empty())
171            || Self::header(req, "referer").is_some_and(|value| !value.is_empty())
172        {
173            return self.validate_origin(req, true);
174        }
175        Ok(())
176    }
177
178    fn validate_redirect_targets(&self, req: &AuthRequest) -> Result<(), AuthError> {
179        if self.auth_config.origin_check_disabled_for(req.path()) {
180            return Ok(());
181        }
182
183        for (name, value) in Self::request_target_values(req)? {
184            if !self.auth_config.is_redirect_target_trusted(&value) {
185                return Err(AuthError::forbidden(Self::target_error_message(name)));
186            }
187        }
188
189        Ok(())
190    }
191
192    fn request_target_values(req: &AuthRequest) -> Result<Vec<(&'static str, String)>, AuthError> {
193        let body = Self::request_body_map(req).unwrap_or_default();
194        let mut targets = Vec::new();
195        for (key, label) in [
196            ("callbackURL", "callbackURL"),
197            ("redirectTo", "redirectURL"),
198            ("errorCallbackURL", "errorCallbackURL"),
199            ("newUserCallbackURL", "newUserCallbackURL"),
200        ] {
201            let value = body
202                .get(key)
203                .filter(|value| Self::is_truthy(value))
204                .cloned()
205                .or_else(|| {
206                    req.query_values(key)
207                        .filter(|_| key == "callbackURL")
208                        .map(|values| match values {
209                            [value] => serde_json::Value::String(value.clone()),
210                            values => serde_json::Value::Array(
211                                values
212                                    .iter()
213                                    .cloned()
214                                    .map(serde_json::Value::String)
215                                    .collect(),
216                            ),
217                        })
218                        .filter(Self::is_truthy)
219                });
220            let Some(value) = value else {
221                continue;
222            };
223            let Some(value) = value.as_str() else {
224                return Err(AuthError::bad_request(format!(
225                    "Invalid {label}: expected a string"
226                )));
227            };
228            targets.push((key, value.to_owned()));
229        }
230        Ok(targets)
231    }
232
233    fn is_truthy(value: &serde_json::Value) -> bool {
234        match value {
235            serde_json::Value::Null => false,
236            serde_json::Value::Bool(value) => *value,
237            serde_json::Value::Number(value) => value.as_f64() != Some(0.0),
238            serde_json::Value::String(value) => !value.is_empty(),
239            serde_json::Value::Array(_) | serde_json::Value::Object(_) => true,
240        }
241    }
242
243    fn request_body_map(req: &AuthRequest) -> Option<serde_json::Map<String, serde_json::Value>> {
244        let content_type = Self::header(req, "content-type").unwrap_or_default();
245        // Dispatch already decoded the entity. MIME parameters must not select a
246        // different body for security checks. Retain raw-form support only for
247        // callers using this middleware without the HTTP decoding pipeline.
248        if req
249            .extensions()
250            .get::<crate::types::ParsedRequestBody>()
251            .is_none()
252            && content_type.contains("application/x-www-form-urlencoded")
253        {
254            let body = req.body.as_ref()?;
255            return Some(
256                url::form_urlencoded::parse(body)
257                    .map(|(key, value)| {
258                        (
259                            key.into_owned(),
260                            serde_json::Value::String(value.into_owned()),
261                        )
262                    })
263                    .collect(),
264            );
265        }
266        req.body_as_json::<serde_json::Value>()
267            .ok()?
268            .as_object()
269            .cloned()
270    }
271
272    fn target_error_message(name: &str) -> &'static str {
273        match name {
274            "callbackURL" => INVALID_CALLBACK_URL,
275            "redirectTo" => INVALID_REDIRECT_URL,
276            "errorCallbackURL" => INVALID_ERROR_CALLBACK_URL,
277            "newUserCallbackURL" => INVALID_NEW_USER_CALLBACK_URL,
278            _ => INVALID_ORIGIN,
279        }
280    }
281
282    /// Router protection after route/body resolution and before application hooks.
283    ///
284    /// # Errors
285    /// Rejects untrusted request origins and redirect targets.
286    pub fn check_request_origin(&self, req: &AuthRequest) -> AuthResult<()> {
287        if !self.config.enabled || !Self::is_state_changing(req.method()) {
288            return Ok(());
289        }
290        self.validate_origin(req, false)?;
291        self.validate_redirect_targets(req)
292    }
293
294    /// Endpoint-local first-login protection, after application before hooks.
295    ///
296    /// # Errors
297    /// Rejects cross-site navigation or untrusted first-login origins.
298    pub fn check_form_origin(&self, req: &AuthRequest) -> AuthResult<()> {
299        if self.config.enabled
300            && Self::is_state_changing(req.method())
301            && Self::is_form_csrf_path(self.normalized_path(req.path()))
302        {
303            self.validate_form_csrf(req)?;
304        }
305        Ok(())
306    }
307    /// Check an explicitly selected login operation using its genuine incoming request.
308    /// The request URL need not be an auth route for a public server API call.
309    ///
310    /// # Errors
311    /// Rejects cross-site navigation or untrusted login origins.
312    pub fn check_login_origin(&self, req: &AuthRequest) -> AuthResult<()> {
313        if self.config.enabled {
314            self.validate_form_csrf(req)?;
315        }
316        Ok(())
317    }
318}
319
320#[async_trait]
321impl Middleware for CsrfMiddleware {
322    fn name(&self) -> &'static str {
323        "csrf"
324    }
325
326    async fn before_request(&self, req: &AuthRequest) -> AuthResult<Option<AuthResponse>> {
327        if !self.config.enabled || !Self::is_state_changing(req.method()) {
328            return Ok(None);
329        }
330
331        let path = self.normalized_path(req.path());
332        let result = if Self::is_form_csrf_path(path) {
333            self.validate_form_csrf(req)
334        } else {
335            self.validate_origin(req, false)
336        }
337        .and_then(|()| self.validate_redirect_targets(req));
338        Ok(result.err().map(AuthError::to_auth_response))
339    }
340}
341
342// LCOV_EXCL_START
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn make_request(
348        path: &str,
349        origin: Option<&str>,
350        cookie: bool,
351        extra_headers: &[(&str, &str)],
352    ) -> AuthRequest {
353        let mut headers = HashMap::new();
354        headers.insert("content-type".to_owned(), "application/json".to_owned());
355        if let Some(origin) = origin {
356            headers.insert("origin".to_owned(), origin.to_owned());
357        }
358        if cookie {
359            headers.insert(
360                "cookie".to_owned(),
361                "better-auth.session_token=test-token".to_owned(),
362            );
363        }
364        for (name, value) in extra_headers {
365            headers.insert((*name).to_owned(), (*value).to_owned());
366        }
367        AuthRequest::from_parts(
368            HttpMethod::Post,
369            path.to_owned(),
370            headers,
371            None,
372            HashMap::new(),
373        )
374    }
375
376    fn test_auth_config(trusted_origins: Vec<String>) -> Arc<AuthConfig> {
377        Arc::new(
378            AuthConfig::new("test-secret-key-that-is-at-least-32-characters-long")
379                .base_url("http://localhost:3000")
380                .trusted_origins(trusted_origins),
381        )
382    }
383
384    fn forbidden_message(response: Option<AuthResponse>) -> String {
385        let response = response.expect("expected rejection response");
386        assert_eq!(response.status, 403);
387        let body = serde_json::from_slice::<serde_json::Value>(&response.body).unwrap();
388        (*(body).get("message").unwrap_or(&serde_json::Value::Null))
389            .as_str()
390            .unwrap()
391            .to_owned()
392    }
393
394    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
395    #[tokio::test]
396    async fn cookie_backed_requests_require_a_trusted_origin() {
397        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
398        let req = make_request("/sign-out", Some("http://evil.com"), true, &[]);
399        let message = forbidden_message(mw.before_request(&req).await.unwrap());
400        assert_eq!(message, INVALID_ORIGIN);
401    }
402
403    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
404    #[tokio::test]
405    async fn cookie_backed_requests_require_origin_or_referer() {
406        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
407        let req = make_request("/sign-out", None, true, &[]);
408        let message = forbidden_message(mw.before_request(&req).await.unwrap());
409        assert_eq!(message, MISSING_OR_NULL_ORIGIN);
410    }
411
412    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
413    #[tokio::test]
414    async fn sign_in_allows_same_origin_fetch_metadata_requests() {
415        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
416        let req = make_request(
417            "/sign-in/email",
418            Some("http://localhost:3000"),
419            false,
420            &[
421                ("sec-fetch-site", "same-origin"),
422                ("sec-fetch-mode", "cors"),
423            ],
424        );
425        assert!(mw.before_request(&req).await.unwrap().is_none());
426    }
427
428    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
429    #[tokio::test]
430    async fn sign_in_blocks_cross_site_navigation_login_attempts() {
431        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
432        let req = make_request(
433            "/sign-in/email",
434            Some("http://evil.com"),
435            false,
436            &[
437                ("sec-fetch-site", "cross-site"),
438                ("sec-fetch-mode", "navigate"),
439            ],
440        );
441        let message = forbidden_message(mw.before_request(&req).await.unwrap());
442        assert_eq!(message, CROSS_SITE_NAVIGATION_LOGIN_BLOCKED);
443    }
444
445    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
446    #[tokio::test]
447    async fn sign_up_rejects_untrusted_origin_without_metadata() {
448        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
449        let req = make_request("/sign-up/email", Some("http://evil.com"), false, &[]);
450        assert_eq!(
451            forbidden_message(mw.before_request(&req).await.unwrap()),
452            "Invalid origin"
453        );
454    }
455
456    // Pinned origin-check middleware applies JavaScript truthiness, then checks
457    // each redirect field's type before route schema parsing.
458    #[tokio::test]
459    async fn redirect_targets_enforce_types_origins_and_callback_precedence() {
460        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
461        for (field, label) in [
462            ("callbackURL", "callbackURL"),
463            ("redirectTo", "redirectURL"),
464            ("errorCallbackURL", "errorCallbackURL"),
465            ("newUserCallbackURL", "newUserCallbackURL"),
466        ] {
467            for value in [
468                serde_json::json!(5),
469                serde_json::json!(true),
470                serde_json::json!([]),
471                serde_json::json!({}),
472            ] {
473                let mut req = make_request("/send-verification-email", None, false, &[]);
474                req.body = Some(serde_json::json!({field:value}).to_string().into_bytes());
475                let response = mw
476                    .before_request(&req)
477                    .await
478                    .unwrap()
479                    .expect("truthy non-string redirect must be rejected before the route");
480                assert_eq!(response.status, 400);
481                assert_eq!(
482                    serde_json::from_slice::<serde_json::Value>(&response.body).unwrap(),
483                    serde_json::json!({"message":format!("Invalid {label}: expected a string")})
484                );
485            }
486            for value in [
487                serde_json::Value::Null,
488                serde_json::json!(false),
489                serde_json::json!(0),
490                serde_json::json!(""),
491            ] {
492                let mut req = make_request("/send-verification-email", None, false, &[]);
493                req.body = Some(serde_json::json!({field:value}).to_string().into_bytes());
494                assert!(mw.before_request(&req).await.unwrap().is_none());
495            }
496        }
497        let mut request = make_request("/send-verification-email", None, false, &[]);
498        request.query.insert(
499            "callbackURL".to_owned(),
500            "http://evil.com/dashboard".to_owned(),
501        );
502        request.body = Some(
503            serde_json::json!({"callbackURL":"/safe"})
504                .to_string()
505                .into_bytes(),
506        );
507        assert!(
508            mw.before_request(&request).await.unwrap().is_none(),
509            "the body callback overrides the query callback"
510        );
511        request.body = Some(
512            serde_json::json!({"callbackURL":null})
513                .to_string()
514                .into_bytes(),
515        );
516        let message = forbidden_message(mw.before_request(&request).await.unwrap());
517        assert_eq!(
518            message, INVALID_CALLBACK_URL,
519            "a falsy body callback falls back to the query"
520        );
521        request.query.clear();
522        request.query.insert(
523            "redirectTo".to_owned(),
524            "http://evil.com/ignored".to_owned(),
525        );
526        assert!(
527            mw.before_request(&request).await.unwrap().is_none(),
528            "upstream reads other redirects from the body only"
529        );
530    }
531
532    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
533    #[tokio::test]
534    async fn csrf_can_be_disabled_explicitly() {
535        let mw = CsrfMiddleware::new(CsrfConfig::new().enabled(false), test_auth_config(vec![]));
536        let req = make_request("/sign-out", Some("http://evil.com"), true, &[]);
537        assert!(mw.before_request(&req).await.unwrap().is_none());
538    }
539
540    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
541    #[tokio::test]
542    async fn advanced_disable_origin_check_skips_callback_url_validation() {
543        let mut config = AuthConfig::new("test-secret-key-that-is-at-least-32-characters-long")
544            .base_url("http://localhost:3000")
545            .disable_origin_check(true);
546        config.trusted_origins = vec![];
547        let mw = CsrfMiddleware::new(CsrfConfig::new(), Arc::new(config));
548        let mut req = make_request("/sign-in/social", None, false, &[]);
549        req.body = Some(
550            serde_json::json!({
551                "provider": "google",
552                "callbackURL": "http://evil.com/dashboard"
553            })
554            .to_string()
555            .into_bytes(),
556        );
557
558        assert!(mw.before_request(&req).await.unwrap().is_none());
559    }
560
561    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
562    #[test]
563    fn extract_origin_still_handles_paths() {
564        assert_eq!(
565            extract_origin("https://example.com/path"),
566            Some("https://example.com".to_owned())
567        );
568        assert_eq!(
569            extract_origin("http://localhost:3000"),
570            Some("http://localhost:3000".to_owned())
571        );
572        assert_eq!(extract_origin("not-a-url"), None);
573    }
574}
575// LCOV_EXCL_STOP