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 {}