use topcoat_core::context::{Cx, CxBuilder};
use topcoat_router::{
Body, Layer, LayerFuture, Method, Next, Path,
error::{ForbiddenError, forbidden},
header, headers, method, uri,
};
use crate::config;
pub fn verify_origin(cx: &Cx) -> Result<(), ForbiddenError> {
let headers = headers(cx);
let sec_fetch_site = headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok());
let origin = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok());
#[allow(clippy::redundant_closure_for_method_calls)]
let host = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.or_else(|| uri(cx).authority().map(|authority| authority.as_str()));
if allowed(
method(cx),
sec_fetch_site,
origin,
host,
&config(cx).trusted_origins,
) {
Ok(())
} else {
Err(forbidden())
}
}
fn allowed(
method: &Method,
sec_fetch_site: Option<&str>,
origin: Option<&str>,
host: Option<&str>,
trusted_origins: &[String],
) -> bool {
if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
return true;
}
if origin.is_some_and(|origin| {
trusted_origins
.iter()
.any(|trusted| trusted.eq_ignore_ascii_case(origin))
}) {
return true;
}
if let Some(site) = sec_fetch_site {
return matches!(site, "same-origin" | "none");
}
if let Some(origin) = origin {
return origin_host(origin)
.zip(host)
.is_some_and(|(origin_host, host)| origin_host.eq_ignore_ascii_case(host));
}
true
}
fn origin_host(origin: &str) -> Option<&str> {
origin.split_once("://").map(|(_, host)| host)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct OriginLayer;
impl OriginLayer {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Layer for OriginLayer {
fn path(&self) -> &Path {
Path::new("/")
}
fn handle<'a>(&'a self, cx: &'a mut CxBuilder, body: Body, next: Next<'a>) -> LayerFuture<'a> {
match verify_origin(cx) {
Ok(()) => next.run(cx, body),
Err(error) => Box::pin(async move { Err(error.into()) }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const NO_TRUST: &[String] = &[];
fn post_allowed(
sec_fetch_site: Option<&str>,
origin: Option<&str>,
host: Option<&str>,
) -> bool {
allowed(&Method::POST, sec_fetch_site, origin, host, NO_TRUST)
}
#[test]
fn safe_methods_pass_even_cross_site() {
for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
assert!(allowed(
&method,
Some("cross-site"),
Some("https://evil.example"),
Some("app.example"),
NO_TRUST,
));
}
}
#[test]
fn same_origin_and_direct_navigation_pass() {
assert!(post_allowed(Some("same-origin"), None, Some("app.example")));
assert!(post_allowed(Some("none"), None, Some("app.example")));
}
#[test]
fn same_site_and_cross_site_are_rejected() {
assert!(!post_allowed(
Some("same-site"),
Some("https://evil.app.example"),
Some("app.example"),
));
assert!(!post_allowed(
Some("cross-site"),
Some("https://evil.example"),
Some("app.example"),
));
}
#[test]
fn trusted_origins_pass_even_cross_site() {
let trusted = vec!["https://accounts.example".to_owned()];
assert!(allowed(
&Method::POST,
Some("cross-site"),
Some("https://accounts.example"),
Some("app.example"),
&trusted,
));
assert!(!allowed(
&Method::POST,
Some("cross-site"),
None,
Some("app.example"),
&trusted,
));
}
#[test]
fn origin_fallback_compares_hosts() {
assert!(post_allowed(
None,
Some("https://app.example"),
Some("app.example"),
));
assert!(post_allowed(
None,
Some("https://App.Example:8443"),
Some("app.example:8443"),
));
assert!(!post_allowed(
None,
Some("https://evil.example"),
Some("app.example"),
));
assert!(!post_allowed(
None,
Some("https://app.example:8443"),
Some("app.example"),
));
}
#[test]
fn opaque_origin_is_rejected() {
assert!(!post_allowed(None, Some("null"), Some("app.example")));
}
#[test]
fn origin_without_a_request_host_is_rejected() {
assert!(!post_allowed(None, Some("https://app.example"), None));
}
#[test]
fn non_browser_requests_pass() {
assert!(post_allowed(None, None, Some("app.example")));
assert!(post_allowed(None, None, None));
}
}