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#[derive(Debug, Clone)]
21pub struct CsrfConfig {
22 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#[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 #[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 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 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 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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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