#![forbid(unsafe_code)]
#![deny(
clippy::dbg_macro,
missing_copy_implementations,
rustdoc::missing_crate_level_docs,
missing_debug_implementations,
nonstandard_style,
unused_qualifications
)]
#![warn(missing_docs, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(
clippy::must_use_candidate,
clippy::module_name_repetitions,
clippy::multiple_crate_versions
)]
use trillium::{
Conn, Handler,
KnownHeaderName::{Origin, SecFetchSite},
Method,
Status::Forbidden,
};
use url::Url;
#[derive(Debug)]
pub struct Csrf {
trusted_origins: Vec<url::Origin>,
}
pub const fn csrf() -> Csrf {
Csrf::new()
}
impl Csrf {
#[must_use]
pub const fn new() -> Self {
Self {
trusted_origins: Vec::new(),
}
}
#[must_use]
pub fn with_trusted_origins<I>(mut self, origins: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>,
{
self.trusted_origins.extend(
origins
.into_iter()
.map(|origin| parse_trusted_origin(origin.as_ref())),
);
self
}
fn is_trusted(&self, origin: &str) -> bool {
!self.trusted_origins.is_empty()
&& Url::parse(origin).is_ok_and(|url| self.trusted_origins.contains(&url.origin()))
}
fn deny_reason(&self, conn: &Conn) -> Option<String> {
if matches!(conn.method(), Method::Get | Method::Head | Method::Options) {
return None;
}
let origin = conn
.request_headers()
.get_str(Origin)
.filter(|origin| !origin.is_empty());
let sec_fetch_site = conn
.request_headers()
.get_str(SecFetchSite)
.filter(|value| !value.is_empty());
match sec_fetch_site {
Some("same-origin" | "none") => None,
Some(sec_fetch_site) => {
if origin.is_some_and(|origin| self.is_trusted(origin)) {
None
} else {
Some(format!(
"sec-fetch-site was `{sec_fetch_site}` and origin ({origin:?}) was not \
trusted; if this cross-origin request is legitimate, add the origin with \
with_trusted_origins or run this handler conditionally for the route"
))
}
}
None => {
let origin = origin?;
if origin_matches_host(origin, conn.host()) || self.is_trusted(origin) {
None
} else {
Some(format!(
"origin `{origin}` did not match request host ({:?}) and was not trusted; \
if this cross-origin request is legitimate, add the origin with \
with_trusted_origins or run this handler conditionally for the route",
conn.host()
))
}
}
}
}
}
impl Default for Csrf {
fn default() -> Self {
Self::new()
}
}
impl Handler for Csrf {
#[allow(
clippy::unused_async_trait_impl,
reason = "the decision needs no io; async is the trait's signature, not this impl's"
)]
async fn run(&self, conn: Conn) -> Conn {
match self.deny_reason(&conn) {
None => conn,
Some(reason) => {
log::warn!(
"blocked a {} request to {}: {reason}",
conn.method(),
conn.path()
);
conn.with_status(Forbidden)
.with_body("cross-origin request forbidden")
.halt()
}
}
}
}
fn parse_trusted_origin(origin: &str) -> url::Origin {
let url = Url::parse(origin)
.unwrap_or_else(|error| panic!("could not parse trusted origin `{origin}`: {error}"));
assert!(
matches!(url.scheme(), "http" | "https"),
"trusted origin `{origin}` must be http or https"
);
assert!(
url.path() == "/"
&& url.query().is_none()
&& url.fragment().is_none()
&& url.username().is_empty()
&& url.password().is_none(),
"trusted origin `{origin}` must be a bare origin (scheme://host[:port]) with no path, \
query, or credentials"
);
url.origin()
}
fn origin_matches_host(origin: &str, request_host: Option<&str>) -> bool {
let Some(request_host) = request_host else {
return false;
};
let Ok(url) = Url::parse(origin) else {
return false;
};
let (Some(origin_host), Some(origin_port)) = (url.host_str(), url.port_or_known_default())
else {
return false;
};
let (request_host, request_port) = split_host_port(request_host);
request_host.eq_ignore_ascii_case(origin_host)
&& request_port.map_or(
origin_port == 80 || origin_port == 443,
|request_port| request_port == origin_port,
)
}
fn split_host_port(host: &str) -> (&str, Option<u16>) {
if host.starts_with('[') {
if let Some(close) = host.find(']') {
let port = host[close + 1..]
.strip_prefix(':')
.and_then(|port| port.parse().ok());
return (&host[..=close], port);
}
return (host, None);
}
match host.rsplit_once(':') {
Some((bare_host, port)) => port
.parse()
.ok()
.map_or((host, None), |port| (bare_host, Some(port))),
None => (host, None),
}
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
mod readme {}