use axum::extract::Request;
use axum::http::{Method, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
pub fn allowed(
method: &Method,
origin: Option<&str>,
site: Option<&str>,
ours: &[String],
) -> Result<(), String> {
if !matches!(
*method,
Method::POST | Method::PUT | Method::PATCH | Method::DELETE
) {
return Ok(());
}
if let Some(origin) = origin
&& !ours.iter().any(|o| o == origin)
{
return Err(format!(
"a request from {origin} may not change this store. This server answers the page \
it serves and programs on this machine, not other websites."
));
}
if let Some(site) = site
&& site != "same-origin"
&& site != "none"
{
return Err(format!(
"a {site} request may not change this store. This server answers the page it \
serves and programs on this machine, not other websites."
));
}
Ok(())
}
pub async fn guard(ours: std::sync::Arc<Vec<String>>, req: Request, next: Next) -> Response {
let (origin, site, method) = {
let headers = req.headers();
let owned = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
(
owned(header::ORIGIN.as_str()),
owned("sec-fetch-site"),
req.method().clone(),
)
};
match allowed(&method, origin.as_deref(), site.as_deref(), &ours) {
Ok(()) => next.run(req).await,
Err(why) => (
StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({
"error": { "code": "policy-refusal", "message": why, "exit_code": 3 }
})),
)
.into_response(),
}
}
pub fn hosts_of(origins: &[String]) -> Vec<String> {
let mut hosts: Vec<String> = origins
.iter()
.filter_map(|o| o.strip_prefix("http://"))
.map(str::to_ascii_lowercase)
.collect();
let ports: Vec<String> = hosts
.iter()
.filter_map(|h| h.rsplit_once(':').map(|(_, p)| p.to_string()))
.collect();
for p in ports {
let v6 = format!("[::1]:{p}");
if !hosts.contains(&v6) {
hosts.push(v6);
}
}
hosts
}
pub fn host_allowed(host: Option<&str>, ours: &[String]) -> Result<(), String> {
let Some(host) = host else {
return Ok(());
};
if ours.iter().any(|o| o.eq_ignore_ascii_case(host)) {
return Ok(());
}
Err(format!(
"this server answers to {} only, not to `{host}`. A page that reached it under \
another name is not this store's page.",
ours.join(", ")
))
}
pub async fn host_guard(ours: std::sync::Arc<Vec<String>>, req: Request, next: Next) -> Response {
let host = req
.headers()
.get(header::HOST)
.map(|v| v.to_str().unwrap_or("\u{fffd}").to_string())
.or_else(|| req.uri().authority().map(|a| a.as_str().to_string()));
match host_allowed(host.as_deref(), &ours) {
Ok(()) => next.run(req).await,
Err(why) => (
StatusCode::MISDIRECTED_REQUEST,
axum::Json(serde_json::json!({
"error": { "code": "policy-refusal", "message": why, "exit_code": 3 }
})),
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ours() -> Vec<String> {
vec![
"http://127.0.0.1:7777".to_string(),
"http://localhost:7777".to_string(),
]
}
#[test]
fn reading_is_never_refused_whoever_asks() {
for site in [None, Some("cross-site"), Some("same-site")] {
assert!(
allowed(&Method::GET, Some("https://evil.example"), site, &ours()).is_ok(),
"a cross-site read cannot see its own answer; refusing it protects nothing"
);
}
}
#[test]
fn our_own_page_may_change_things() {
assert!(
allowed(
&Method::POST,
Some("http://127.0.0.1:7777"),
Some("same-origin"),
&ours()
)
.is_ok()
);
assert!(allowed(&Method::POST, Some("http://localhost:7777"), None, &ours()).is_ok());
}
#[test]
fn a_form_on_another_website_may_not() {
let why = allowed(
&Method::POST,
Some("https://evil.example"),
Some("cross-site"),
&ours(),
)
.unwrap_err();
assert!(why.contains("evil.example"), "{why}");
}
#[test]
fn a_cross_site_request_without_an_origin_is_still_refused() {
let why = allowed(&Method::POST, None, Some("cross-site"), &ours()).unwrap_err();
assert!(why.contains("cross-site"), "{why}");
assert!(allowed(&Method::POST, None, Some("same-site"), &ours()).is_err());
}
#[test]
fn a_program_on_this_machine_is_admitted() {
assert!(allowed(&Method::POST, None, None, &ours()).is_ok());
assert!(allowed(&Method::DELETE, None, Some("none"), &ours()).is_ok());
}
#[test]
fn every_method_that_changes_something_is_checked() {
for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
assert!(
allowed(&method, Some("https://evil.example"), None, &ours()).is_err(),
"{method} was not checked"
);
}
}
#[test]
fn only_the_names_this_server_was_bound_under_are_answered() {
let hosts = hosts_of(&ours());
assert_eq!(hosts, ["127.0.0.1:7777", "localhost:7777", "[::1]:7777"]);
for ok in [
"127.0.0.1:7777",
"localhost:7777",
"LOCALHOST:7777",
"[::1]:7777",
] {
assert!(host_allowed(Some(ok), &hosts).is_ok(), "{ok}");
}
for bad in [
"attacker.example:7777",
"127.0.0.1:7778",
"127.0.0.1",
"localhost",
"localhost.attacker.example:7777",
"127.0.0.1:7777.attacker.example",
"",
] {
let why = host_allowed(Some(bad), &hosts).unwrap_err();
assert!(why.contains("127.0.0.1:7777"), "{why}");
}
assert!(host_allowed(None, &hosts).is_ok(), "a program without Host");
}
}