use std::collections::HashSet;
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{header::HOST, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
pub const ALLOWED_HOSTS_ENV: &str = "SENSING_ALLOWED_HOSTS";
const DEFAULT_LOOPBACK_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"];
#[derive(Debug, Clone, Default)]
pub struct HostAllowlist {
entries: Arc<HashSet<String>>,
}
impl HostAllowlist {
pub fn loopback_only() -> Self {
let mut entries: HashSet<String> = HashSet::new();
for h in DEFAULT_LOOPBACK_HOSTS {
entries.insert((*h).to_string());
}
HostAllowlist {
entries: Arc::new(entries),
}
}
pub fn with_extra<I, S>(extras: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut entries: HashSet<String> = HashSet::new();
for h in DEFAULT_LOOPBACK_HOSTS {
entries.insert((*h).to_string());
}
for h in extras {
let h = h.as_ref().trim();
if !h.is_empty() {
entries.insert(h.to_lowercase());
}
}
HostAllowlist {
entries: Arc::new(entries),
}
}
pub fn from_cli_and_env<I, S>(cli_extras: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let env_extras: Vec<String> = std::env::var(ALLOWED_HOSTS_ENV)
.ok()
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let cli_vec: Vec<String> = cli_extras
.into_iter()
.map(|s| s.as_ref().to_string())
.collect();
HostAllowlist::with_extra(cli_vec.into_iter().chain(env_extras))
}
pub fn disabled() -> Self {
HostAllowlist::default()
}
pub fn is_enabled(&self) -> bool {
!self.entries.is_empty()
}
pub fn entries_for_test(&self) -> Vec<String> {
let mut v: Vec<String> = self.entries.iter().cloned().collect();
v.sort();
v
}
pub fn is_allowed(&self, host: &str) -> bool {
if self.entries.is_empty() {
return true;
}
let host = host.trim().to_lowercase();
if host.is_empty() {
return false;
}
if self.entries.contains(&host) {
return true;
}
let host_only = strip_port(&host);
if self.entries.contains(host_only) {
return true;
}
false
}
}
fn strip_port(host: &str) -> &str {
if let Some(close) = host.strip_prefix('[').and_then(|_| host.find(']')) {
if let Some(after) = host.get(close + 1..) {
if after.starts_with(':') {
return &host[..=close];
}
}
return host;
}
match host.rfind(':') {
Some(idx) => &host[..idx],
None => host,
}
}
pub async fn require_allowed_host(
State(allowlist): State<HostAllowlist>,
request: Request,
next: Next,
) -> Response {
if !allowlist.is_enabled() {
return next.run(request).await;
}
let host_header = request
.headers()
.get(HOST)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let host_header = match host_header {
Some(h) => h,
None => {
return (StatusCode::BAD_REQUEST, "missing Host header\n").into_response();
}
};
if allowlist.is_allowed(&host_header) {
next.run(request).await
} else {
(
StatusCode::MISDIRECTED_REQUEST,
"Host header not in allowlist (DNS-rebinding defense). \
Set --allowed-host <name[:port]> or SENSING_ALLOWED_HOSTS=<comma-list> \
to permit this hostname.\n",
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use tower::ServiceExt;
fn router(allowlist: HostAllowlist) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/api/v1/pose/current", get(|| async { "ok" }))
.route("/ws/sensing", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
allowlist,
require_allowed_host,
))
}
async fn status(router: Router, path: &str, host: Option<&str>) -> StatusCode {
let mut req = Request::builder().method("GET").uri(path);
if let Some(h) = host {
req = req.header(HOST, h);
}
let req = req.body(Body::empty()).unwrap();
router.oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn loopback_only_allows_default_hosts_with_any_port() {
let r = router(HostAllowlist::loopback_only());
for h in [
"localhost",
"localhost:8080",
"127.0.0.1",
"127.0.0.1:8080",
"127.0.0.1:65535",
"[::1]",
"[::1]:8080",
] {
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
StatusCode::OK,
"host {h} should be allowed under loopback_only()"
);
}
}
#[tokio::test]
async fn loopback_only_rejects_foreign_hosts() {
let r = router(HostAllowlist::loopback_only());
for h in [
"evil.com",
"evil.com:8080",
"127.0.0.1.evil.com",
"192.168.1.10",
"192.168.1.10:8080",
"sensing.local",
] {
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
StatusCode::MISDIRECTED_REQUEST,
"host {h} should be rejected under loopback_only()"
);
}
}
#[tokio::test]
async fn rejects_missing_host_header() {
let r = router(HostAllowlist::loopback_only());
assert_eq!(
status(r, "/api/v1/pose/current", None).await,
StatusCode::BAD_REQUEST,
);
}
#[tokio::test]
async fn rejects_empty_host_header() {
let r = router(HostAllowlist::loopback_only());
assert_eq!(
status(r, "/api/v1/pose/current", Some("")).await,
StatusCode::MISDIRECTED_REQUEST,
);
}
#[tokio::test]
async fn rejection_applies_to_health_and_ws_routes_too() {
let r = router(HostAllowlist::loopback_only());
assert_eq!(
status(r.clone(), "/health", Some("evil.com")).await,
StatusCode::MISDIRECTED_REQUEST,
);
assert_eq!(
status(r, "/ws/sensing", Some("evil.com")).await,
StatusCode::MISDIRECTED_REQUEST,
);
}
#[tokio::test]
async fn extras_extend_loopback_set() {
let r = router(HostAllowlist::with_extra(["sensing.local", "192.168.1.10"]));
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some("sensing.local")).await,
StatusCode::OK,
);
assert_eq!(
status(
r.clone(),
"/api/v1/pose/current",
Some("sensing.local:8080")
)
.await,
StatusCode::OK,
);
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some("192.168.1.10:8080")).await,
StatusCode::OK,
);
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some("127.0.0.1")).await,
StatusCode::OK,
);
assert_eq!(
status(r, "/api/v1/pose/current", Some("evil.com")).await,
StatusCode::MISDIRECTED_REQUEST,
);
}
#[tokio::test]
async fn disabled_allowlist_is_no_op() {
let r = router(HostAllowlist::disabled());
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some("evil.com")).await,
StatusCode::OK,
);
assert_eq!(
status(r, "/api/v1/pose/current", None).await,
StatusCode::OK,
);
}
#[tokio::test]
async fn case_insensitive_host_match() {
let r = router(HostAllowlist::loopback_only());
for h in ["LOCALHOST", "LocalHost:8080", "127.0.0.1"] {
assert_eq!(
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
StatusCode::OK,
"host {h} should be allowed (case-insensitive)"
);
}
let r2 = router(HostAllowlist::with_extra(["Sensing.Local"]));
assert_eq!(
status(r2, "/api/v1/pose/current", Some("sensing.local:8080")).await,
StatusCode::OK,
);
}
#[test]
fn strip_port_handles_ipv4_ipv6_and_bare_hostnames() {
assert_eq!(strip_port("localhost"), "localhost");
assert_eq!(strip_port("localhost:8080"), "localhost");
assert_eq!(strip_port("127.0.0.1"), "127.0.0.1");
assert_eq!(strip_port("127.0.0.1:8080"), "127.0.0.1");
assert_eq!(strip_port("[::1]"), "[::1]");
assert_eq!(strip_port("[::1]:8080"), "[::1]");
assert_eq!(strip_port("sensing.local"), "sensing.local");
}
#[test]
fn with_extra_trims_whitespace_and_skips_empty() {
let allowlist = HostAllowlist::with_extra([" sensing.local ", "", "192.168.1.10"]);
let entries = allowlist.entries_for_test();
assert!(entries.contains(&"sensing.local".to_string()));
assert!(entries.contains(&"192.168.1.10".to_string()));
assert!(!entries.iter().any(|s| s.is_empty()));
}
#[test]
fn loopback_only_includes_all_three_defaults() {
let entries = HostAllowlist::loopback_only().entries_for_test();
assert!(entries.contains(&"localhost".to_string()));
assert!(entries.contains(&"127.0.0.1".to_string()));
assert!(entries.contains(&"[::1]".to_string()));
}
#[test]
fn empty_input_to_with_extra_still_includes_loopback_defaults() {
let entries: Vec<String> = Vec::new();
let allowlist = HostAllowlist::with_extra(entries);
assert!(allowlist.is_allowed("127.0.0.1"));
assert!(allowlist.is_allowed("127.0.0.1:8080"));
assert!(allowlist.is_allowed("localhost"));
assert!(!allowlist.is_allowed("evil.com"));
}
#[test]
fn env_constants_are_stable() {
assert_eq!(ALLOWED_HOSTS_ENV, "SENSING_ALLOWED_HOSTS");
}
}