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