Skip to main content

better_auth_core/middleware/
csrf.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;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9const CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: &str =
10    "Cross-site navigation login blocked. This request appears to be a CSRF attack.";
11const INVALID_CALLBACK_URL: &str = "Invalid callbackURL";
12const INVALID_ERROR_CALLBACK_URL: &str = "Invalid errorCallbackURL";
13const INVALID_NEW_USER_CALLBACK_URL: &str = "Invalid newUserCallbackURL";
14const INVALID_REDIRECT_URL: &str = "Invalid redirectURL";
15const INVALID_ORIGIN: &str = "Invalid origin";
16const MISSING_OR_NULL_ORIGIN: &str = "Missing or null Origin";
17
18/// Configuration for Better Auth request-origin and CSRF protection.
19#[derive(Debug, Clone)]
20pub struct CsrfConfig {
21    /// Whether the request protection middleware is enabled. Defaults to `true`.
22    pub enabled: bool,
23}
24
25impl Default for CsrfConfig {
26    fn default() -> Self {
27        Self { enabled: true }
28    }
29}
30
31impl CsrfConfig {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    pub fn enabled(mut self, enabled: bool) -> Self {
37        self.enabled = enabled;
38        self
39    }
40}
41
42/// Better Auth request protection middleware.
43///
44/// This mirrors the upstream TypeScript behavior:
45/// - mutating requests with cookies require a trusted `Origin` / `Referer`
46/// - first-login `POST /sign-up/email` and `POST /sign-in/email` also use
47///   Fetch Metadata headers to block cross-site navigation attacks
48/// - callback / redirect targets are validated against trusted origins unless
49///   `advanced.disable_origin_check` is set
50pub struct CsrfMiddleware {
51    config: CsrfConfig,
52    auth_config: Arc<AuthConfig>,
53}
54
55impl CsrfMiddleware {
56    pub fn new(config: CsrfConfig, auth_config: Arc<AuthConfig>) -> Self {
57        Self {
58            config,
59            auth_config,
60        }
61    }
62
63    fn is_state_changing(method: &HttpMethod) -> bool {
64        matches!(
65            method,
66            HttpMethod::Post | HttpMethod::Put | HttpMethod::Delete | HttpMethod::Patch
67        )
68    }
69
70    fn normalized_path<'a>(&self, path: &'a str) -> &'a str {
71        let base_path = self.auth_config.base_path.as_str();
72        if !base_path.is_empty() && base_path != "/" {
73            path.strip_prefix(base_path).unwrap_or(path)
74        } else {
75            path
76        }
77    }
78
79    fn is_form_csrf_path(path: &str) -> bool {
80        matches!(path, "/sign-in/email" | "/sign-up/email")
81    }
82
83    fn header<'a>(req: &'a AuthRequest, name: &str) -> Option<&'a str> {
84        req.headers
85            .iter()
86            .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str()))
87    }
88
89    fn has_cookies(req: &AuthRequest) -> bool {
90        Self::header(req, "cookie").is_some()
91    }
92
93    fn has_fetch_metadata(req: &AuthRequest) -> bool {
94        ["sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest"]
95            .into_iter()
96            .any(|name| Self::header(req, name).is_some_and(|value| !value.trim().is_empty()))
97    }
98
99    fn validate_origin(&self, req: &AuthRequest, force_validate: bool) -> Result<(), AuthError> {
100        if self.auth_config.advanced.disable_csrf_check {
101            return Ok(());
102        }
103
104        if !force_validate && !Self::has_cookies(req) {
105            return Ok(());
106        }
107
108        let origin = Self::header(req, "origin")
109            .map(ToOwned::to_owned)
110            .or_else(|| Self::header(req, "referer").and_then(extract_origin))
111            .filter(|value| value != "null")
112            .ok_or_else(|| AuthError::forbidden(MISSING_OR_NULL_ORIGIN))?;
113
114        if self.auth_config.is_origin_trusted(&origin) {
115            Ok(())
116        } else {
117            Err(AuthError::forbidden(INVALID_ORIGIN))
118        }
119    }
120
121    fn validate_form_csrf(&self, req: &AuthRequest) -> Result<(), AuthError> {
122        if self.auth_config.advanced.disable_csrf_check {
123            return Ok(());
124        }
125
126        if Self::has_cookies(req) {
127            return self.validate_origin(req, false);
128        }
129
130        if Self::has_fetch_metadata(req) {
131            let is_cross_site_navigation = matches!(
132                (
133                    Self::header(req, "sec-fetch-site"),
134                    Self::header(req, "sec-fetch-mode"),
135                ),
136                (Some("cross-site"), Some("navigate"))
137            );
138
139            if is_cross_site_navigation {
140                return Err(AuthError::forbidden(CROSS_SITE_NAVIGATION_LOGIN_BLOCKED));
141            }
142
143            return self.validate_origin(req, true);
144        }
145
146        Ok(())
147    }
148
149    fn validate_redirect_targets(&self, req: &AuthRequest) -> Result<(), AuthError> {
150        if self.auth_config.advanced.disable_origin_check {
151            return Ok(());
152        }
153
154        for (name, value) in Self::request_target_values(req) {
155            if !self.auth_config.is_redirect_target_trusted(&value) {
156                return Err(AuthError::forbidden(Self::target_error_message(name)));
157            }
158        }
159
160        Ok(())
161    }
162
163    fn request_target_values(req: &AuthRequest) -> Vec<(&'static str, String)> {
164        let mut targets = Vec::new();
165        Self::append_target_from_map(&mut targets, &req.query);
166
167        if let Some(body) = Self::request_body_map(req) {
168            Self::append_target_from_map(&mut targets, &body);
169        }
170
171        targets
172    }
173
174    fn append_target_from_map(
175        targets: &mut Vec<(&'static str, String)>,
176        values: &HashMap<String, String>,
177    ) {
178        for key in [
179            "callbackURL",
180            "redirectTo",
181            "errorCallbackURL",
182            "newUserCallbackURL",
183        ] {
184            if let Some(value) = values.get(key) {
185                targets.push((key, value.clone()));
186            }
187        }
188    }
189
190    fn request_body_map(req: &AuthRequest) -> Option<HashMap<String, String>> {
191        let content_type = Self::header(req, "content-type").unwrap_or_default();
192
193        if content_type.contains("application/x-www-form-urlencoded") {
194            let body = req.body.as_ref()?;
195            return Some(
196                url::form_urlencoded::parse(body)
197                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
198                    .collect(),
199            );
200        }
201
202        let value = req.body_as_json::<serde_json::Value>().ok()?;
203        let object = value.as_object()?;
204        Some(
205            object
206                .iter()
207                .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string())))
208                .collect(),
209        )
210    }
211
212    fn target_error_message(name: &str) -> &'static str {
213        match name {
214            "callbackURL" => INVALID_CALLBACK_URL,
215            "redirectTo" => INVALID_REDIRECT_URL,
216            "errorCallbackURL" => INVALID_ERROR_CALLBACK_URL,
217            "newUserCallbackURL" => INVALID_NEW_USER_CALLBACK_URL,
218            _ => INVALID_ORIGIN,
219        }
220    }
221
222    fn reject(error: AuthError) -> AuthResponse {
223        error.to_auth_response()
224    }
225}
226
227#[async_trait]
228impl Middleware for CsrfMiddleware {
229    fn name(&self) -> &'static str {
230        "csrf"
231    }
232
233    async fn before_request(&self, req: &AuthRequest) -> AuthResult<Option<AuthResponse>> {
234        if !self.config.enabled || !Self::is_state_changing(req.method()) {
235            return Ok(None);
236        }
237
238        let path = self.normalized_path(req.path());
239        let csrf_result = if Self::is_form_csrf_path(path) {
240            self.validate_form_csrf(req)
241        } else {
242            self.validate_origin(req, false)
243        };
244
245        if let Err(error) = csrf_result {
246            return Ok(Some(Self::reject(error)));
247        }
248
249        if let Err(error) = self.validate_redirect_targets(req) {
250            return Ok(Some(Self::reject(error)));
251        }
252
253        Ok(None)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn make_request(
262        path: &str,
263        origin: Option<&str>,
264        cookie: bool,
265        extra_headers: &[(&str, &str)],
266    ) -> AuthRequest {
267        let mut headers = HashMap::new();
268        headers.insert("content-type".to_string(), "application/json".to_string());
269        if let Some(origin) = origin {
270            headers.insert("origin".to_string(), origin.to_string());
271        }
272        if cookie {
273            headers.insert(
274                "cookie".to_string(),
275                "better-auth.session_token=test-token".to_string(),
276            );
277        }
278        for (name, value) in extra_headers {
279            headers.insert((*name).to_string(), (*value).to_string());
280        }
281        AuthRequest {
282            method: HttpMethod::Post,
283            path: path.to_string(),
284            headers,
285            body: None,
286            query: HashMap::new(),
287            virtual_user_id: None,
288        }
289    }
290
291    fn test_auth_config(trusted_origins: Vec<String>) -> Arc<AuthConfig> {
292        Arc::new(
293            AuthConfig::new("test-secret-key-that-is-at-least-32-characters-long")
294                .base_url("http://localhost:3000")
295                .trusted_origins(trusted_origins),
296        )
297    }
298
299    async fn forbidden_message(response: Option<AuthResponse>) -> String {
300        let response = response.expect("expected rejection response");
301        assert_eq!(response.status, 403);
302        let body = serde_json::from_slice::<serde_json::Value>(&response.body).unwrap();
303        body["message"].as_str().unwrap().to_string()
304    }
305
306    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
307    #[tokio::test]
308    async fn cookie_backed_requests_require_a_trusted_origin() {
309        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
310        let req = make_request("/sign-out", Some("http://evil.com"), true, &[]);
311        let message = forbidden_message(mw.before_request(&req).await.unwrap()).await;
312        assert_eq!(message, INVALID_ORIGIN);
313    }
314
315    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
316    #[tokio::test]
317    async fn cookie_backed_requests_require_origin_or_referer() {
318        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
319        let req = make_request("/sign-out", None, true, &[]);
320        let message = forbidden_message(mw.before_request(&req).await.unwrap()).await;
321        assert_eq!(message, MISSING_OR_NULL_ORIGIN);
322    }
323
324    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
325    #[tokio::test]
326    async fn sign_in_allows_same_origin_fetch_metadata_requests() {
327        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
328        let req = make_request(
329            "/sign-in/email",
330            Some("http://localhost:3000"),
331            false,
332            &[
333                ("sec-fetch-site", "same-origin"),
334                ("sec-fetch-mode", "cors"),
335            ],
336        );
337        assert!(mw.before_request(&req).await.unwrap().is_none());
338    }
339
340    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
341    #[tokio::test]
342    async fn sign_in_blocks_cross_site_navigation_login_attempts() {
343        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
344        let req = make_request(
345            "/sign-in/email",
346            Some("http://evil.com"),
347            false,
348            &[
349                ("sec-fetch-site", "cross-site"),
350                ("sec-fetch-mode", "navigate"),
351            ],
352        );
353        let message = forbidden_message(mw.before_request(&req).await.unwrap()).await;
354        assert_eq!(message, CROSS_SITE_NAVIGATION_LOGIN_BLOCKED);
355    }
356
357    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
358    #[tokio::test]
359    async fn sign_up_allows_legacy_first_login_requests_without_metadata() {
360        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
361        let req = make_request("/sign-up/email", Some("http://evil.com"), false, &[]);
362        assert!(mw.before_request(&req).await.unwrap().is_none());
363    }
364
365    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
366    #[tokio::test]
367    async fn callback_targets_must_be_relative_or_trusted() {
368        let mw = CsrfMiddleware::new(CsrfConfig::new(), test_auth_config(vec![]));
369        let mut req = make_request("/sign-in/social", None, false, &[]);
370        req.body = Some(
371            serde_json::json!({
372                "provider": "google",
373                "callbackURL": "http://evil.com/dashboard"
374            })
375            .to_string()
376            .into_bytes(),
377        );
378
379        let message = forbidden_message(mw.before_request(&req).await.unwrap()).await;
380        assert_eq!(message, INVALID_CALLBACK_URL);
381    }
382
383    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
384    #[tokio::test]
385    async fn csrf_can_be_disabled_explicitly() {
386        let mw = CsrfMiddleware::new(CsrfConfig::new().enabled(false), test_auth_config(vec![]));
387        let req = make_request("/sign-out", Some("http://evil.com"), true, &[]);
388        assert!(mw.before_request(&req).await.unwrap().is_none());
389    }
390
391    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
392    #[tokio::test]
393    async fn advanced_disable_origin_check_skips_callback_url_validation() {
394        let mut config = AuthConfig::new("test-secret-key-that-is-at-least-32-characters-long")
395            .base_url("http://localhost:3000")
396            .disable_origin_check(true);
397        config.trusted_origins = vec![];
398        let mw = CsrfMiddleware::new(CsrfConfig::new(), Arc::new(config));
399        let mut req = make_request("/sign-in/social", None, false, &[]);
400        req.body = Some(
401            serde_json::json!({
402                "provider": "google",
403                "callbackURL": "http://evil.com/dashboard"
404            })
405            .to_string()
406            .into_bytes(),
407        );
408
409        assert!(mw.before_request(&req).await.unwrap().is_none());
410    }
411
412    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
413    #[test]
414    fn extract_origin_still_handles_paths() {
415        assert_eq!(
416            extract_origin("https://example.com/path"),
417            Some("https://example.com".to_string())
418        );
419        assert_eq!(
420            extract_origin("http://localhost:3000"),
421            Some("http://localhost:3000".to_string())
422        );
423        assert_eq!(extract_origin("not-a-url"), None);
424    }
425}