use std::collections::HashSet;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{StatusCode, header::ORIGIN};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
#[derive(Debug, Clone, Default)]
pub struct SelfOrigins(Arc<HashSet<SocketAddr>>);
impl FromIterator<SocketAddr> for SelfOrigins {
fn from_iter<T: IntoIterator<Item = SocketAddr>>(iter: T) -> Self {
Self(Arc::new(iter.into_iter().collect()))
}
}
impl SelfOrigins {
pub fn from_bind_addrs(addrs: &[SocketAddr]) -> Self {
addrs
.iter()
.copied()
.filter(|a| !a.ip().is_loopback())
.collect()
}
}
pub fn origin_is_loopback(origin: &str) -> bool {
match parse_origin_authority(origin) {
Some((host, _port)) => {
host == "localhost"
|| IpAddr::from_str(host)
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
None => false,
}
}
pub fn origin_matches_self(origin: &str, self_origins: &SelfOrigins) -> bool {
let Some((host, port)) = parse_origin_authority(origin) else {
return false;
};
let Ok(ip) = IpAddr::from_str(host) else {
return false;
};
let Some(port) = port.and_then(|p| p.parse::<u16>().ok()) else {
return false;
};
self_origins.0.contains(&SocketAddr::new(ip, port))
}
fn parse_origin_authority(origin: &str) -> Option<(&str, Option<&str>)> {
let after_scheme = origin.split_once("://").map(|(_, rest)| rest)?;
let authority = after_scheme.split('/').next().unwrap_or("");
if let Some(rest) = authority.strip_prefix('[') {
let (host, remainder) = rest.split_once(']')?;
let port = remainder.strip_prefix(':');
Some((host, port))
} else {
match authority.split_once(':') {
Some((h, p)) => Some((h, Some(p))),
None => Some((authority, None)),
}
}
}
pub async fn guard_write_origin(
State(self_origins): State<SelfOrigins>,
req: Request,
next: Next,
) -> Response {
if !req.method().is_safe()
&& let Some(origin) = req.headers().get(ORIGIN)
{
match origin.to_str() {
Ok(value)
if !origin_is_loopback(value) && !origin_matches_self(value, &self_origins) =>
{
tracing::warn!(
origin = %value,
"daemon write route rejected cross-origin request (same-origin guard)"
);
return (
StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({
"error": "cross-origin write requests are not allowed",
})),
)
.into_response();
}
Ok(_) => {}
Err(_) => {
tracing::warn!("daemon write route rejected request with non-UTF-8 Origin header");
return (
StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({
"error": "malformed Origin header",
})),
)
.into_response();
}
}
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn origin_is_loopback_accepts_local_hosts() {
assert!(origin_is_loopback("http://127.0.0.1:7070"));
assert!(origin_is_loopback("http://127.0.0.1"));
assert!(origin_is_loopback("http://localhost:7070"));
assert!(origin_is_loopback("http://localhost"));
assert!(origin_is_loopback("http://127.5.6.7:9000"));
assert!(origin_is_loopback("http://[::1]:7070"));
assert!(origin_is_loopback("https://localhost:443"));
}
#[test]
fn origin_is_loopback_rejects_remote_hosts() {
assert!(!origin_is_loopback("http://evil.example.com"));
assert!(!origin_is_loopback("https://evil.example.com:8443"));
assert!(!origin_is_loopback("http://10.0.0.5:7070"));
assert!(!origin_is_loopback("http://100.64.1.2:7070")); assert!(!origin_is_loopback("http://127evil.com")); }
#[test]
fn origin_is_loopback_rejects_ip_prefixed_dns_names() {
assert!(!origin_is_loopback("http://127.0.0.1.evil.com"));
assert!(!origin_is_loopback("http://127.0.0.1.evil.com:7070"));
assert!(!origin_is_loopback("http://127.attacker.com"));
assert!(!origin_is_loopback("http://127.0.0.1x.com"));
assert!(!origin_is_loopback("http://localhost.evil.com"));
assert!(origin_is_loopback("http://[::1]"));
}
#[test]
fn origin_is_loopback_rejects_malformed() {
assert!(!origin_is_loopback("127.0.0.1:7070")); assert!(!origin_is_loopback(""));
assert!(!origin_is_loopback("garbage"));
}
#[test]
fn origin_matches_self_trusts_bind_derived_tailscale_addr() {
let addrs = [
SocketAddr::from(([127, 0, 0, 1], 7070)),
SocketAddr::from(([100, 64, 1, 2], 7070)),
];
let self_origins = SelfOrigins::from_bind_addrs(&addrs);
assert!(origin_matches_self("http://100.64.1.2:7070", &self_origins));
}
#[test]
fn origin_matches_self_rejects_other_hosts_in_cgnat_range() {
let addrs = [SocketAddr::from(([100, 64, 1, 2], 7070))];
let self_origins = SelfOrigins::from_bind_addrs(&addrs);
assert!(!origin_matches_self(
"http://100.64.9.9:7070",
&self_origins
));
}
#[test]
fn origin_matches_self_rejects_wrong_port() {
let addrs = [SocketAddr::from(([100, 64, 1, 2], 7070))];
let self_origins = SelfOrigins::from_bind_addrs(&addrs);
assert!(!origin_matches_self(
"http://100.64.1.2:9999",
&self_origins
));
}
#[test]
fn origin_matches_self_empty_allowlist_matches_nothing() {
let self_origins = SelfOrigins::default();
assert!(!origin_matches_self(
"http://100.64.1.2:7070",
&self_origins
));
assert!(!origin_matches_self("http://127.0.0.1:7070", &self_origins));
}
#[test]
fn self_origins_from_bind_addrs_drops_loopback() {
let addrs = [
SocketAddr::from(([127, 0, 0, 1], 7070)),
SocketAddr::from(([100, 64, 1, 2], 7070)),
];
let self_origins = SelfOrigins::from_bind_addrs(&addrs);
assert_eq!(self_origins.0.len(), 1);
}
}