1use std::fmt::{self, Debug, Formatter};
63
64use crate::Method;
65use rama_core::extensions::Extension;
66use rama_net::uri::Uri;
67
68mod layer;
69mod origin;
70mod response;
71mod service;
72
73pub use self::layer::CsrfLayer;
74pub use self::response::{DefaultResponseForProtectionError, ResponseForProtectionError};
75pub use self::service::Csrf;
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum ConfigError {
81 InvalidOrigin {
83 origin: Box<str>,
85 message: Box<str>,
87 },
88
89 InvalidOriginComponents {
92 origin: Box<str>,
94 },
95
96 OpaqueOrigin {
99 origin: Box<str>,
101 },
102}
103
104impl fmt::Display for ConfigError {
105 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
106 match self {
107 Self::InvalidOrigin { origin, message } => {
108 write!(f, "invalid origin {origin:?}: {message}")
109 }
110 Self::InvalidOriginComponents { origin } => write!(
111 f,
112 "invalid origin {origin:?}: userinfo, path, query, and fragment are not allowed"
113 ),
114 Self::OpaqueOrigin { origin } => {
115 write!(f, "invalid origin {origin:?}: scheme must be http or https")
116 }
117 }
118 }
119}
120
121impl std::error::Error for ConfigError {}
122
123#[derive(Clone, Debug, Extension)]
134#[extension(tags(http))]
135pub struct ProtectionError {
136 kind: ProtectionErrorKind,
137}
138
139impl ProtectionError {
140 pub(crate) fn new(kind: ProtectionErrorKind) -> Self {
141 Self { kind }
142 }
143
144 #[must_use]
146 pub fn kind(&self) -> ProtectionErrorKind {
147 self.kind
148 }
149}
150
151impl fmt::Display for ProtectionError {
152 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
153 match self.kind {
154 ProtectionErrorKind::CrossOriginRequest => f.write_str("cross-origin request detected"),
155 ProtectionErrorKind::CrossOriginRequestFromOldBrowser => {
156 f.write_str("cross-origin request from old browser detected")
157 }
158 }
159 }
160}
161
162impl std::error::Error for ProtectionError {}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ProtectionErrorKind {
168 CrossOriginRequest,
170
171 CrossOriginRequestFromOldBrowser,
175}
176
177type BypassFn = dyn Fn(&Method, &Uri) -> bool + Send + Sync + 'static;
178
179struct DebugFn;
180
181impl Debug for DebugFn {
182 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
183 f.write_str("<fn>")
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use std::{convert::Infallible, sync::OnceLock};
190
191 use super::*;
192 use crate::{Body, Request, Response, StatusCode, body::util::BodyExt, header};
193 use rama_core::extensions::ExtensionsRef;
194 use rama_core::{Layer, Service, service::service_fn};
195 use rama_net::uri::PathRouter;
196
197 impl PartialEq for ProtectionError {
198 fn eq(&self, other: &Self) -> bool {
199 self.kind == other.kind
200 }
201 }
202
203 fn echo() -> impl Service<Request, Output = Response, Error = Infallible> + Clone {
204 service_fn(async |req: Request| {
205 static ROUTES: OnceLock<PathRouter<&'static str>> = OnceLock::new();
206 let routes = ROUTES.get_or_init(|| {
207 let mut routes = PathRouter::new();
208 routes.insert_prefix("/foo", "foo");
209 routes.insert_prefix("/bar", "bar");
210 routes
211 });
212
213 let path = req.uri().path_ref_or_root();
214 let body = routes
215 .match_exact(path)
216 .map(|matched| Body::from(*matched.value()))
217 .unwrap_or_else(Body::empty);
218
219 Ok::<_, Infallible>(Response::new(body))
220 })
221 }
222
223 async fn body_string(res: Response) -> String {
224 let bytes = res.into_body().collect().await.unwrap().to_bytes();
225 String::from_utf8(bytes.to_vec()).unwrap()
226 }
227
228 #[tokio::test]
229 async fn allows_safe_method() {
230 let svc = CsrfLayer::new()
231 .add_trusted_origin("https://example.com")
232 .unwrap()
233 .into_layer(echo());
234 let req = Request::builder()
235 .method("GET")
236 .uri("/foo")
237 .body(Body::empty())
238 .unwrap();
239 let res = svc.serve(req).await.unwrap();
240 assert_eq!(res.status(), StatusCode::OK);
241 assert_eq!(body_string(res).await, "foo");
242 }
243
244 #[tokio::test]
245 async fn allows_post_from_trusted_origin() {
246 let svc = CsrfLayer::new()
247 .add_trusted_origin("https://example.com")
248 .unwrap()
249 .into_layer(echo());
250 let req = Request::builder()
251 .method("POST")
252 .uri("/bar")
253 .header(header::ORIGIN, "https://example.com")
254 .header("sec-fetch-site", "cross-site")
255 .body(Body::empty())
256 .unwrap();
257 let res = svc.serve(req).await.unwrap();
258 assert_eq!(res.status(), StatusCode::OK);
259 assert_eq!(body_string(res).await, "bar");
260 }
261
262 #[tokio::test]
263 async fn rejects_post_from_untrusted_origin() {
264 let svc = CsrfLayer::new()
265 .add_trusted_origin("https://example.com")
266 .unwrap()
267 .into_layer(echo());
268 let req = Request::builder()
269 .method("POST")
270 .uri("/bar")
271 .header(header::HOST, "example.com")
272 .header(header::ORIGIN, "https://malicious.example")
273 .body(Body::empty())
274 .unwrap();
275 let res = svc.serve(req).await.unwrap();
276 assert_eq!(res.status(), StatusCode::FORBIDDEN);
277 assert_eq!(
278 res.extensions()
279 .get_ref::<ProtectionError>()
280 .map(|e| e.kind()),
281 Some(ProtectionErrorKind::CrossOriginRequestFromOldBrowser),
282 );
283 }
284
285 #[tokio::test]
286 async fn uses_custom_rejection_response() {
287 let svc = CsrfLayer::new()
288 .with_rejection_response(|_err: ProtectionError| {
289 let mut res = Response::new(Body::from("denied"));
290 *res.status_mut() = StatusCode::IM_A_TEAPOT;
291 res
292 })
293 .into_layer(echo());
294 let req = Request::builder()
295 .method("POST")
296 .uri("/bar")
297 .header(header::ORIGIN, "https://malicious.example")
298 .header(header::HOST, "example.com")
299 .body(Body::empty())
300 .unwrap();
301 let res = svc.serve(req).await.unwrap();
302 assert_eq!(res.status(), StatusCode::IM_A_TEAPOT);
303 assert_eq!(
305 res.extensions()
306 .get_ref::<ProtectionError>()
307 .map(|e| e.kind()),
308 Some(ProtectionErrorKind::CrossOriginRequestFromOldBrowser),
309 );
310 assert_eq!(body_string(res).await, "denied");
311 }
312
313 #[tokio::test]
314 async fn custom_rejection_response_not_invoked_when_allowed() {
315 let svc = CsrfLayer::new()
316 .add_trusted_origin("https://example.com")
317 .unwrap()
318 .with_rejection_response(|_err: ProtectionError| {
319 let mut res = Response::new(Body::from("denied"));
320 *res.status_mut() = StatusCode::IM_A_TEAPOT;
321 res
322 })
323 .into_layer(echo());
324 let req = Request::builder()
325 .method("POST")
326 .uri("/bar")
327 .header(header::ORIGIN, "https://example.com")
328 .body(Body::empty())
329 .unwrap();
330 let res = svc.serve(req).await.unwrap();
331 assert_eq!(res.status(), StatusCode::OK);
332 assert!(res.extensions().get_ref::<ProtectionError>().is_none());
333 assert_eq!(body_string(res).await, "bar");
334 }
335
336 #[test]
337 fn layer_add_trusted_origin() {
338 let _layer = CsrfLayer::new()
339 .add_trusted_origin("https://example.com")
340 .unwrap();
341 assert!(matches!(
342 CsrfLayer::new().add_trusted_origin("not a valid url"),
343 Err(ConfigError::InvalidOrigin { .. })
344 ));
345 }
346
347 #[test]
348 fn middleware_bypass() {
349 let middleware = CsrfLayer::new()
350 .with_insecure_bypass(|_method, uri| uri.path_ref_or_root() == "/bypass")
351 .into_layer(());
352
353 struct Test {
354 name: &'static str,
355 path: &'static str,
356 sec_fetch_site: Option<&'static str>,
357 result: Result<(), ProtectionError>,
358 }
359
360 let tests = [
361 Test {
362 name: "bypass path without sec-fetch-site",
363 path: "/bypass",
364 sec_fetch_site: None,
365 result: Ok(()),
366 },
367 Test {
368 name: "bypass path with cross-site",
369 path: "/bypass",
370 sec_fetch_site: Some("cross-site"),
371 result: Ok(()),
372 },
373 Test {
374 name: "non-bypass path without sec-fetch-site",
375 path: "/api",
376 sec_fetch_site: None,
377 result: Err(ProtectionError::new(
378 ProtectionErrorKind::CrossOriginRequestFromOldBrowser,
379 )),
380 },
381 Test {
382 name: "non-bypass path with cross-site",
383 path: "/api",
384 sec_fetch_site: Some("cross-site"),
385 result: Err(ProtectionError::new(
386 ProtectionErrorKind::CrossOriginRequest,
387 )),
388 },
389 ];
390
391 for test in tests {
392 let mut req = Request::builder()
393 .method("POST")
394 .header(header::HOST, "example.com")
395 .header(header::ORIGIN, "https://attacker.example")
396 .uri(format!("https://example.com{}", test.path));
397 if let Some(sfs) = test.sec_fetch_site {
398 req = req.header("sec-fetch-site", sfs);
399 }
400 let req = req.body(Body::empty()).unwrap();
401 assert_eq!(middleware.verify(&req), test.result, "{}", test.name);
402 }
403 }
404
405 #[test]
406 fn middleware_sec_fetch_site() {
407 let middleware: Csrf<()> = Csrf::default();
408
409 struct Test {
410 name: &'static str,
411 method: &'static str,
412 sec_fetch_site: Option<&'static str>,
413 origin: Option<&'static str>,
414 result: Result<(), ProtectionError>,
415 }
416
417 let tests = [
418 Test {
419 name: "same-origin allowed",
420 method: "GET",
421 sec_fetch_site: Some("same-origin"),
422 origin: None,
423 result: Ok(()),
424 },
425 Test {
426 name: "none allowed",
427 method: "POST",
428 sec_fetch_site: Some("none"),
429 origin: None,
430 result: Ok(()),
431 },
432 Test {
433 name: "cross-site blocked",
434 method: "POST",
435 sec_fetch_site: Some("cross-site"),
436 origin: None,
437 result: Err(ProtectionError::new(
438 ProtectionErrorKind::CrossOriginRequest,
439 )),
440 },
441 Test {
442 name: "same-site blocked",
443 method: "POST",
444 sec_fetch_site: Some("same-site"),
445 origin: None,
446 result: Err(ProtectionError::new(
447 ProtectionErrorKind::CrossOriginRequest,
448 )),
449 },
450 Test {
451 name: "no header with no origin",
452 method: "POST",
453 sec_fetch_site: None,
454 origin: None,
455 result: Ok(()),
456 },
457 Test {
458 name: "no header with matching origin",
459 method: "POST",
460 sec_fetch_site: None,
461 origin: Some("https://example.com"),
462 result: Ok(()),
463 },
464 Test {
465 name: "no header with mismatched origin",
466 method: "POST",
467 sec_fetch_site: None,
468 origin: Some("https://attacker.example"),
469 result: Err(ProtectionError::new(
470 ProtectionErrorKind::CrossOriginRequestFromOldBrowser,
471 )),
472 },
473 Test {
474 name: "no header with null origin",
475 method: "POST",
476 sec_fetch_site: None,
477 origin: Some("null"),
478 result: Err(ProtectionError::new(
479 ProtectionErrorKind::CrossOriginRequestFromOldBrowser,
480 )),
481 },
482 Test {
483 name: "GET allowed",
484 method: "GET",
485 sec_fetch_site: Some("cross-site"),
486 origin: None,
487 result: Ok(()),
488 },
489 Test {
490 name: "OPTIONS allowed",
491 method: "OPTIONS",
492 sec_fetch_site: Some("cross-site"),
493 origin: None,
494 result: Ok(()),
495 },
496 Test {
497 name: "PUT blocked",
498 method: "PUT",
499 sec_fetch_site: Some("cross-site"),
500 origin: None,
501 result: Err(ProtectionError::new(
502 ProtectionErrorKind::CrossOriginRequest,
503 )),
504 },
505 Test {
506 name: "empty origin without sec-fetch-site allowed",
507 method: "POST",
508 sec_fetch_site: None,
509 origin: Some(""),
510 result: Ok(()),
511 },
512 ];
513
514 for test in tests {
515 let mut req = Request::builder()
516 .method(test.method)
517 .header(header::HOST, "example.com");
518 if let Some(sfs) = test.sec_fetch_site {
519 req = req.header("sec-fetch-site", sfs);
520 }
521 if let Some(origin) = test.origin {
522 req = req.header(header::ORIGIN, origin);
523 }
524 let req = req.body(Body::empty()).unwrap();
525 assert_eq!(middleware.verify(&req), test.result, "{}", test.name);
526 }
527 }
528
529 #[test]
530 fn middleware_origin_host_match_is_structural() {
531 let middleware: Csrf<()> = Csrf::default();
532
533 struct Test {
534 name: &'static str,
535 uri: &'static str,
536 host: Option<&'static str>,
537 origin: &'static str,
538 result: Result<(), ProtectionError>,
539 }
540
541 let cross_origin = || {
542 Err(ProtectionError::new(
543 ProtectionErrorKind::CrossOriginRequestFromOldBrowser,
544 ))
545 };
546
547 let tests = [
548 Test {
549 name: "default port both sides",
550 uri: "/",
551 host: Some("example.com"),
552 origin: "https://example.com",
553 result: Ok(()),
554 },
555 Test {
556 name: "same non-default port both sides",
557 uri: "/",
558 host: Some("example.com:8443"),
559 origin: "https://example.com:8443",
560 result: Ok(()),
561 },
562 Test {
563 name: "origin explicit default, host implicit",
565 uri: "/",
566 host: Some("example.com"),
567 origin: "https://example.com:443",
568 result: Ok(()),
569 },
570 Test {
571 name: "host explicit default, origin implicit",
572 uri: "/",
573 host: Some("example.com:443"),
574 origin: "https://example.com",
575 result: Ok(()),
576 },
577 Test {
578 name: "mismatched non-default ports",
579 uri: "/",
580 host: Some("example.com:8443"),
581 origin: "https://example.com:8444",
582 result: cross_origin(),
583 },
584 Test {
585 name: "request-target authority wins over host header (match)",
587 uri: "https://example.com/path",
588 host: Some("other.example"),
589 origin: "https://example.com",
590 result: Ok(()),
591 },
592 Test {
593 name: "origin matches host header but not winning authority is rejected",
594 uri: "https://example.com/path",
595 host: Some("other.example"),
596 origin: "https://other.example",
597 result: cross_origin(),
598 },
599 Test {
600 name: "missing host, uri carries authority (match)",
601 uri: "https://example.com/path",
602 host: None,
603 origin: "https://example.com",
604 result: Ok(()),
605 },
606 Test {
607 name: "scheme-less origin does not match host",
608 uri: "/",
609 host: Some("example.com:8443"),
610 origin: "example.com:8443",
611 result: cross_origin(),
612 },
613 Test {
614 name: "non-http origin scheme does not enter host fallback",
615 uri: "/",
616 host: Some("example.com:8443"),
617 origin: "ftp://example.com:8443",
618 result: cross_origin(),
619 },
620 ];
621
622 for test in tests {
623 let mut req = Request::builder().method("POST").uri(test.uri);
624 if let Some(host) = test.host {
625 req = req.header(header::HOST, host);
626 }
627 let req = req
628 .header(header::ORIGIN, test.origin)
629 .body(Body::empty())
630 .unwrap();
631 assert_eq!(middleware.verify(&req), test.result, "{}", test.name);
632 }
633 }
634
635 #[test]
636 fn middleware_trusted_origin_match_is_structural() {
637 struct Test {
639 name: &'static str,
640 trusted: &'static str,
641 origin: &'static str,
642 result: Result<(), ProtectionError>,
643 }
644
645 let tests = [
646 Test {
647 name: "exact match trusted",
648 trusted: "https://example.com",
649 origin: "https://example.com",
650 result: Ok(()),
651 },
652 Test {
653 name: "non-default port match",
654 trusted: "https://example.com:8443",
655 origin: "https://example.com:8443",
656 result: Ok(()),
657 },
658 Test {
659 name: "host case is normalized",
660 trusted: "https://Example.COM",
661 origin: "https://example.com",
662 result: Ok(()),
663 },
664 Test {
665 name: "explicit default port trusted against bare origin",
666 trusted: "https://example.com:443",
667 origin: "https://example.com",
668 result: Ok(()),
669 },
670 Test {
671 name: "bare trusted matched by explicit-default-port origin",
672 trusted: "https://example.com",
673 origin: "https://example.com:443",
674 result: Ok(()),
675 },
676 Test {
677 name: "different host not trusted",
678 trusted: "https://example.com",
679 origin: "https://attacker.example",
680 result: Err(ProtectionError::new(
681 ProtectionErrorKind::CrossOriginRequest,
682 )),
683 },
684 ];
685
686 for test in tests {
687 let middleware = CsrfLayer::new()
688 .add_trusted_origin(test.trusted)
689 .unwrap_or_else(|e| panic!("{}: add_trusted_origin failed: {e}", test.name))
690 .into_layer(());
691 let req = Request::builder()
692 .method("POST")
693 .header(header::HOST, "other.example")
694 .header(header::ORIGIN, test.origin)
695 .header("sec-fetch-site", "cross-site")
696 .body(Body::empty())
697 .unwrap();
698 assert_eq!(middleware.verify(&req), test.result, "{}", test.name);
699 }
700 }
701}