use crate::http::response::Body;
use crate::routing::middleware::Next;
use hyper::header::{self, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_LOCATION, HeaderMap, LOCATION};
#[cfg(feature = "cookies")]
use hyper::header::{HeaderValue, SET_COOKIE};
use hyper::{Request, Response, StatusCode};
pub async fn anonymity_guard<S>(req: Request<Body>, next: Next<S>) -> Response<Body>
where
S: Send + Sync + 'static,
{
let request_host = req
.headers()
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.map(|h| host_without_port(h).to_ascii_lowercase());
let mut resp = next.run(req).await;
strip_fingerprinting_headers(resp.headers_mut());
harden_set_cookie_headers(resp.headers_mut());
if let Some(host) = request_host.as_deref()
&& looks_like_anonymity_host(host)
&& let Err((leaked_header, leaked_value)) = check_no_clearnet_leak(resp.headers(), host)
{
tracing::error!(
header = leaked_header,
value = %leaked_value,
request_host = host,
"anonymity_guard: response header pointed at what looks like a clearnet origin on \
a `.onion`/`.i2p` request — replacing the response with 500 instead of letting it \
reach the client; fix the handler/middleware that set this header"
);
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::full(bytes::Bytes::from_static(
b"Internal Server Error",
)))
.unwrap_or_else(|_| Response::new(Body::empty()));
}
resp
}
fn host_without_port(host: &str) -> &str {
host.split(':').next().unwrap_or(host)
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn looks_like_anonymity_host(host: &str) -> bool {
host.ends_with(".onion") || host.ends_with(".i2p")
}
fn strip_fingerprinting_headers(headers: &mut HeaderMap) {
let _ = headers.remove(header::DATE);
let _ = headers.remove(header::SERVER);
let _ = headers.remove("x-powered-by");
}
#[cfg(feature = "cookies")]
fn harden_set_cookie_headers(headers: &mut HeaderMap) {
let original: Vec<HeaderValue> = headers.get_all(SET_COOKIE).iter().cloned().collect();
if original.is_empty() {
return;
}
headers.remove(SET_COOKIE);
for value in original {
let hardened = value.to_str().ok().and_then(|s| {
let mut cookie = cookie::Cookie::parse(s.to_string()).ok()?;
cookie.set_same_site(cookie::SameSite::Strict);
HeaderValue::from_str(&cookie.to_string()).ok()
});
match hardened {
Some(hv) => {
headers.append(SET_COOKIE, hv);
}
None => {
tracing::warn!(
"anonymity_guard: dropped a Set-Cookie header that couldn't be parsed and \
hardened to SameSite=Strict"
);
}
}
}
}
#[cfg(not(feature = "cookies"))]
const fn harden_set_cookie_headers(_headers: &mut HeaderMap) {}
fn check_no_clearnet_leak(
headers: &HeaderMap,
request_host: &str,
) -> Result<(), (&'static str, String)> {
for (name, header_name) in [
("location", &LOCATION),
("content-location", &CONTENT_LOCATION),
("access-control-allow-origin", &ACCESS_CONTROL_ALLOW_ORIGIN),
] {
for value in headers.get_all(header_name) {
let Ok(s) = value.to_str() else { continue };
let Some(host) = absolute_url_host(s) else {
continue;
};
let host = host.to_ascii_lowercase();
if host != request_host && !looks_like_anonymity_host(&host) {
return Err((name, s.to_string()));
}
}
}
Ok(())
}
fn absolute_url_host(value: &str) -> Option<&str> {
let after_scheme = value.split_once("://")?.1;
let host_and_rest = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(after_scheme);
let host_and_port = host_and_rest.rsplit('@').next().unwrap_or(host_and_rest);
let host = host_and_port.split(':').next().unwrap_or(host_and_port);
(!host.is_empty()).then_some(host)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::routing::{Router, get};
fn build_app<H, T>(handler: H) -> Router
where
H: crate::routing::handler::Handler<T, ()> + Clone + Send + Sync + 'static,
T: Send + Sync + 'static,
{
Router::new().route("/", get(handler)).hoop(anonymity_guard)
}
fn req_with_host(host: &str) -> Request<Body> {
Request::builder()
.uri("/")
.header(header::HOST, host)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn strips_date_and_server_headers() {
async fn handler() -> Response<Body> {
Response::builder()
.header(header::DATE, "Mon, 01 Jan 2024 00:00:00 GMT")
.header(header::SERVER, "tachyon-web")
.body(Body::empty())
.unwrap()
}
let app = build_app(handler);
let resp = app.handle_request(req_with_host("abc.onion")).await;
assert!(resp.headers().get(header::DATE).is_none());
assert!(resp.headers().get(header::SERVER).is_none());
}
#[tokio::test]
async fn forces_samesite_strict_on_cookies() {
async fn handler() -> Response<Body> {
Response::builder()
.header(header::SET_COOKIE, "session=abc; Path=/")
.body(Body::empty())
.unwrap()
}
let app = build_app(handler);
let resp = app.handle_request(req_with_host("abc.onion")).await;
let cookie = resp
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(cookie.contains("SameSite=Strict"), "cookie: {cookie}");
}
#[tokio::test]
async fn blocks_response_leaking_a_clearnet_redirect() {
async fn handler() -> Response<Body> {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "https://my-real-site.example/dashboard")
.body(Body::empty())
.unwrap()
}
let app = build_app(handler);
let resp = app.handle_request(req_with_host("abc.onion")).await;
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(resp.headers().get(header::LOCATION).is_none());
}
#[tokio::test]
async fn allows_relative_redirects() {
async fn handler() -> Response<Body> {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.body(Body::empty())
.unwrap()
}
let app = build_app(handler);
let resp = app.handle_request(req_with_host("abc.onion")).await;
assert_eq!(resp.status(), StatusCode::FOUND);
}
#[tokio::test]
async fn allows_redirects_to_the_same_onion_host() {
async fn handler() -> Response<Body> {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "http://abc.onion/dashboard")
.body(Body::empty())
.unwrap()
}
let app = build_app(handler);
let resp = app.handle_request(req_with_host("abc.onion")).await;
assert_eq!(resp.status(), StatusCode::FOUND);
}
#[tokio::test]
async fn does_not_check_leaks_on_a_non_anonymity_host() {
async fn handler() -> Response<Body> {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "https://example.com/dashboard")
.body(Body::empty())
.unwrap()
}
let app = build_app(handler);
let resp = app.handle_request(req_with_host("clearnet.example")).await;
assert_eq!(resp.status(), StatusCode::FOUND);
}
#[test]
fn absolute_url_host_examples() {
assert_eq!(
absolute_url_host("https://example.com/foo"),
Some("example.com")
);
assert_eq!(
absolute_url_host("http://abc.onion:8080/foo"),
Some("abc.onion")
);
assert_eq!(absolute_url_host("/relative/path"), None);
assert_eq!(absolute_url_host("*"), None);
assert_eq!(absolute_url_host("null"), None);
}
}