Skip to main content

rama_http/layer/csrf/
mod.rs

1//! Modern protection against [cross-site request forgery] (CSRF) attacks.
2//!
3//! This middleware implements the stateless CSRF protection scheme [introduced in Go 1.25][go]
4//! and described in [Filippo Valsorda's blog post][filippo]. It relies on the [`Sec-Fetch-Site`]
5//! and [`Origin`] request headers and requires no per-request token state.
6//!
7//! Unlike the Go reference, rama compares origins **structurally** using
8//! [`rama_net::uri::Uri`]: hosts are matched case-insensitively and a default port (`80` for
9//! `http`, `443` for `https`) compares equal whether it is written out explicitly or omitted.
10//!
11//! Requests are allowed if any of the following hold:
12//!
13//! 1. The method is `GET`, `HEAD`, or `OPTIONS`.
14//! 2. [`Sec-Fetch-Site`] is `same-origin` or `none`.
15//! 3. The request's `Origin` matches an allow-listed trusted origin.
16//! 4. Neither a usable `Sec-Fetch-Site` nor a non-empty `Origin` is present.
17//! 5. The `Origin`'s authority (host + port) matches the request's effective host — the
18//!    request-target authority if present (RFC 7230 §5.3), else the `Host` header.
19//!
20//! Rejected requests receive a `403 Forbidden` response. The originating [`ProtectionError`] is
21//! attached to the response's extensions — on every rejection, including those from a custom
22//! builder — so surrounding layers can distinguish explicit cross-origin rejections from
23//! conservative fallback rejections (e.g. requests from old browsers without `Sec-Fetch-Site`).
24//! Use [`CsrfLayer::with_rejection_response`] to replace the rejection response.
25//!
26//! # Example
27//!
28//! ```
29//! use std::convert::Infallible;
30//!
31//! use rama_core::{Layer, service::service_fn};
32//! use rama_http::layer::csrf::CsrfLayer;
33//! use rama_http::{Body, Request, Response};
34//!
35//! async fn handle(_: Request) -> Result<Response, Infallible> {
36//!     Ok(Response::new(Body::empty()))
37//! }
38//!
39//! // Same-origin (and `https://app.example.com`) requests pass through; cross-origin
40//! // state-changing requests are rejected with `403 Forbidden`.
41//! let layer = CsrfLayer::new()
42//!     .add_trusted_origin("https://app.example.com")
43//!     .expect("valid trusted origin");
44//! let service = layer.into_layer(service_fn(handle));
45//! # let _ = service;
46//! ```
47//!
48//! # Deployment caveat
49//!
50//! The middleware trusts whatever `Origin` and `Host` reach it. Reverse proxies and load
51//! balancers that rewrite `Host` (e.g. to an internal hostname) or strip `Origin` silently
52//! degrade the protection: the `Origin`/`Host` fallback can no longer match and `Sec-Fetch-Site`
53//! becomes the only remaining line of defense. Configure intermediaries to forward both headers
54//! unchanged.
55//!
56//! [cross-site request forgery]: https://developer.mozilla.org/en-US/docs/Glossary/CSRF
57//! [filippo]: https://words.filippo.io/csrf/
58//! [go]: https://pkg.go.dev/net/http#CrossOriginProtection
59//! [`Sec-Fetch-Site`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site
60//! [`Origin`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
61
62use 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/// Errors that can occur while configuring [`CsrfLayer`].
78#[derive(Clone, Debug, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum ConfigError {
81    /// The origin string could not be parsed as a URI.
82    InvalidOrigin {
83        /// The offending origin string.
84        origin: Box<str>,
85        /// The parser error message.
86        message: Box<str>,
87    },
88
89    /// The trusted origin carried a userinfo, path, query, or fragment component; an origin is
90    /// `scheme://host[:port]` only.
91    InvalidOriginComponents {
92        /// The offending origin string.
93        origin: Box<str>,
94    },
95
96    /// The origin had a scheme other than `http`/`https`, or no host, so it can never match a
97    /// browser-supplied request `Origin`.
98    OpaqueOrigin {
99        /// The offending origin string.
100        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/// Reason a request was rejected by [`Csrf`].
124///
125/// Retrieve the category with [`ProtectionError::kind`]. [`Csrf`] attaches it to every
126/// `403 Forbidden` rejection response's extensions so surrounding layers can distinguish explicit
127/// cross-origin rejections from conservative fallback rejections.
128///
129/// This is an opaque struct rather than an enum so future variants can carry additional context
130/// without a breaking change; match on [`kind`] instead.
131///
132/// [`kind`]: ProtectionError::kind
133#[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    /// The category of rejection.
145    #[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/// The category of a [`ProtectionError`].
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ProtectionErrorKind {
168    /// A cross-origin request was detected via `Sec-Fetch-Site`.
169    CrossOriginRequest,
170
171    /// A request without a usable `Sec-Fetch-Site` failed the `Origin`/`Host` fallback check.
172    /// Modern browsers always send `Sec-Fetch-Site`, so this typically means the request came
173    /// from an old browser or a non-browser client.
174    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        // The middleware attaches the error even though a custom builder produced the response.
304        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                // Structural: an explicit default port equals an implicit one.
564                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                // RFC 7230 §5.3: request-target authority is the effective host; here it matches.
586                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        // Trusted origins are compared structurally: host case and default-port form do not matter.
638        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}