better_auth_core/middleware/
csrf.rs1use 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#[derive(Debug, Clone)]
20pub struct CsrfConfig {
21 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
42pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}