Skip to main content

trillium_csrf/
lib.rs

1//! Cross-site request forgery (CSRF) protection for [trillium](https://trillium.rs).
2//!
3//! This handler rejects state-changing cross-origin requests using metadata that browsers attach
4//! to every request. It needs no tokens, no cookies, and no configuration to protect an app whose
5//! frontend and api share an origin:
6//!
7//! ```
8//! use trillium_csrf::csrf;
9//!
10//! let app = (
11//!     csrf(),
12//!     |conn: trillium::Conn| async move { conn.ok("hello") },
13//! );
14//! ```
15//!
16//! For each request, in order:
17//!
18//! - GET, HEAD, and OPTIONS requests are always allowed.
19//! - If the request has a `Sec-Fetch-Site` header, it is allowed when the value is `same-origin` or
20//!   `none` (a user-initiated request such as a bookmark or a typed address) and otherwise
21//!   rejected, unless the `Origin` header is trusted (see [`Csrf::with_trusted_origins`]).
22//! - Without `Sec-Fetch-Site` but with an `Origin` header, the request is allowed when the origin's
23//!   host and port match the request's own host and otherwise rejected, unless the origin is
24//!   trusted. Schemes are not compared, so this behaves correctly behind a tls-terminating reverse
25//!   proxy.
26//! - A request with neither header is allowed: it did not come from a browser, so it cannot carry a
27//!   browser's ambient credentials, and cross-site request forgery does not apply.
28//!
29//! Rejections halt the conn with a 403 status and log the check that failed along with the
30//! configuration that would allow the request if it was legitimate.
31//!
32//! The allowed-method list is exactly GET, HEAD, and OPTIONS, and is not configurable. It exists
33//! because browsers send those methods ambiently — navigations, images, plain forms — without a
34//! CORS preflight, so rejecting them cross-origin would break ordinary links to your site. Other
35//! methods that http defines as safe, such as QUERY, stay protected: a browser only sends them
36//! cross-origin after a preflight your server already controls, so exempting them here would
37//! trust every handler's implementation without enabling any request that works today.
38//!
39//! # Exempting a route
40//!
41//! Webhook endpoints don't need an exemption: webhook senders are not browsers, send neither
42//! header, and are allowed. If a route must accept browser requests from origins you can't
43//! enumerate — a multi-tenant single-sign-on callback, say — run this handler conditionally by
44//! wrapping it:
45//!
46//! ```
47//! use trillium::{Conn, Handler};
48//! use trillium_csrf::{Csrf, csrf};
49//!
50//! struct ExemptSsoCallback(Csrf);
51//!
52//! impl Handler for ExemptSsoCallback {
53//!     async fn run(&self, conn: Conn) -> Conn {
54//!         if conn.path() == "/sso/callback" {
55//!             conn
56//!         } else {
57//!             self.0.run(conn).await
58//!         }
59//!     }
60//! }
61//!
62//! let handler = ExemptSsoCallback(csrf());
63//! ```
64//!
65//! # What this does not cover
66//!
67//! Browsers released before roughly 2019 may send neither `Sec-Fetch-Site` nor `Origin` on
68//! cross-site form submissions, and this handler allows those requests. Protecting that
69//! population requires request tokens, which this crate does not provide. For the reasoning
70//! behind header-based protection, see [Cross-Site Request
71//! Forgery](https://words.filippo.io/csrf/). If you need token support, open an issue.
72//!
73//! Apis authenticated exclusively by a bearer token or other explicit request header don't need
74//! this crate: cross-site request forgery is only possible when authentication is ambient, as
75//! with cookies or network position.
76#![forbid(unsafe_code)]
77#![deny(
78    clippy::dbg_macro,
79    missing_copy_implementations,
80    rustdoc::missing_crate_level_docs,
81    missing_debug_implementations,
82    nonstandard_style,
83    unused_qualifications
84)]
85#![warn(missing_docs, clippy::pedantic, clippy::nursery, clippy::cargo)]
86#![allow(
87    clippy::must_use_candidate,
88    clippy::module_name_repetitions,
89    clippy::multiple_crate_versions
90)]
91
92use trillium::{
93    Conn, Handler,
94    KnownHeaderName::{Origin, SecFetchSite},
95    Method,
96    Status::Forbidden,
97};
98use url::Url;
99
100/// A [`Handler`] that rejects state-changing cross-origin requests.
101///
102/// See the [crate-level docs](crate) for the exact decision sequence. Construct with [`csrf`] or
103/// [`Csrf::new`] and place it in the handler tuple before any handler with side effects.
104#[derive(Debug)]
105pub struct Csrf {
106    trusted_origins: Vec<url::Origin>,
107}
108
109/// Constructs a new [`Csrf`] handler with no trusted origins.
110pub const fn csrf() -> Csrf {
111    Csrf::new()
112}
113
114impl Csrf {
115    /// Constructs a new [`Csrf`] handler with no trusted origins.
116    #[must_use]
117    pub const fn new() -> Self {
118        Self {
119            trusted_origins: Vec::new(),
120        }
121    }
122
123    /// Allows cross-origin requests from these origins.
124    ///
125    /// Each entry must be a full origin — scheme, host, and optional port, such as
126    /// `"https://app.example.com"` — with nothing else. Requests are compared by exact origin:
127    /// no wildcards, and subdomains of a trusted origin are not trusted.
128    ///
129    /// ```
130    /// use trillium_csrf::csrf;
131    /// let handler = csrf().with_trusted_origins(["https://app.example.com"]);
132    /// ```
133    ///
134    /// # Panics
135    ///
136    /// Panics if an entry is not parseable as an origin, is not http or https, or contains a
137    /// path, query, or credentials. A path would be silently ignored during comparison, so an
138    /// entry like `"https://example.com/app"` is rejected rather than matching more broadly
139    /// than it reads.
140    #[must_use]
141    pub fn with_trusted_origins<I>(mut self, origins: I) -> Self
142    where
143        I: IntoIterator,
144        I::Item: AsRef<str>,
145    {
146        self.trusted_origins.extend(
147            origins
148                .into_iter()
149                .map(|origin| parse_trusted_origin(origin.as_ref())),
150        );
151        self
152    }
153
154    fn is_trusted(&self, origin: &str) -> bool {
155        !self.trusted_origins.is_empty()
156            && Url::parse(origin).is_ok_and(|url| self.trusted_origins.contains(&url.origin()))
157    }
158
159    fn deny_reason(&self, conn: &Conn) -> Option<String> {
160        if matches!(conn.method(), Method::Get | Method::Head | Method::Options) {
161            return None;
162        }
163
164        let origin = conn
165            .request_headers()
166            .get_str(Origin)
167            .filter(|origin| !origin.is_empty());
168
169        let sec_fetch_site = conn
170            .request_headers()
171            .get_str(SecFetchSite)
172            .filter(|value| !value.is_empty());
173
174        match sec_fetch_site {
175            Some("same-origin" | "none") => None,
176
177            Some(sec_fetch_site) => {
178                if origin.is_some_and(|origin| self.is_trusted(origin)) {
179                    None
180                } else {
181                    Some(format!(
182                        "sec-fetch-site was `{sec_fetch_site}` and origin ({origin:?}) was not \
183                         trusted; if this cross-origin request is legitimate, add the origin with \
184                         with_trusted_origins or run this handler conditionally for the route"
185                    ))
186                }
187            }
188
189            None => {
190                let origin = origin?;
191                if origin_matches_host(origin, conn.host()) || self.is_trusted(origin) {
192                    None
193                } else {
194                    Some(format!(
195                        "origin `{origin}` did not match request host ({:?}) and was not trusted; \
196                         if this cross-origin request is legitimate, add the origin with \
197                         with_trusted_origins or run this handler conditionally for the route",
198                        conn.host()
199                    ))
200                }
201            }
202        }
203    }
204}
205
206impl Default for Csrf {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212impl Handler for Csrf {
213    #[allow(
214        clippy::unused_async_trait_impl,
215        reason = "the decision needs no io; async is the trait's signature, not this impl's"
216    )]
217    async fn run(&self, conn: Conn) -> Conn {
218        match self.deny_reason(&conn) {
219            None => conn,
220            Some(reason) => {
221                log::warn!(
222                    "blocked a {} request to {}: {reason}",
223                    conn.method(),
224                    conn.path()
225                );
226                conn.with_status(Forbidden)
227                    .with_body("cross-origin request forbidden")
228                    .halt()
229            }
230        }
231    }
232}
233
234fn parse_trusted_origin(origin: &str) -> url::Origin {
235    let url = Url::parse(origin)
236        .unwrap_or_else(|error| panic!("could not parse trusted origin `{origin}`: {error}"));
237
238    assert!(
239        matches!(url.scheme(), "http" | "https"),
240        "trusted origin `{origin}` must be http or https"
241    );
242
243    assert!(
244        url.path() == "/"
245            && url.query().is_none()
246            && url.fragment().is_none()
247            && url.username().is_empty()
248            && url.password().is_none(),
249        "trusted origin `{origin}` must be a bare origin (scheme://host[:port]) with no path, \
250         query, or credentials"
251    );
252
253    url.origin()
254}
255
256fn origin_matches_host(origin: &str, request_host: Option<&str>) -> bool {
257    let Some(request_host) = request_host else {
258        return false;
259    };
260    let Ok(url) = Url::parse(origin) else {
261        return false;
262    };
263    let (Some(origin_host), Some(origin_port)) = (url.host_str(), url.port_or_known_default())
264    else {
265        return false;
266    };
267
268    let (request_host, request_port) = split_host_port(request_host);
269
270    request_host.eq_ignore_ascii_case(origin_host)
271        && request_port.map_or(
272            // a portless Host implies the browser-default port, but the request's scheme isn't
273            // knowable behind a tls-terminating proxy, so either default is accepted
274            origin_port == 80 || origin_port == 443,
275            |request_port| request_port == origin_port,
276        )
277}
278
279fn split_host_port(host: &str) -> (&str, Option<u16>) {
280    if host.starts_with('[') {
281        if let Some(close) = host.find(']') {
282            let port = host[close + 1..]
283                .strip_prefix(':')
284                .and_then(|port| port.parse().ok());
285            return (&host[..=close], port);
286        }
287        return (host, None);
288    }
289
290    match host.rsplit_once(':') {
291        Some((bare_host, port)) => port
292            .parse()
293            .ok()
294            .map_or((host, None), |port| (bare_host, Some(port))),
295        None => (host, None),
296    }
297}
298
299// Compile the README as a doctest so its examples stay in sync with the crate.
300#[cfg(doctest)]
301#[doc = include_str!("../README.md")]
302mod readme {}