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