//! Host-header allowlist for the sensing-server HTTP + WS surface. //! //! Defense against DNS rebinding: when the server is bound to loopback //! (default `127.0.0.1`), a foreign page (e.g. `evil.com`) can lower its DNS //! TTL and re-resolve to `127.0.0.1` after the browser has already accepted //! the origin. From the browser's point of view the request is same-origin //! against `evil.com`, so it reads the response — even though the bytes come //! from the local sensing-server. Without `Host`-header validation the server //! happily serves the request because every other axum layer treats it as a //! normal connection. //! //! For RuView this means any website the user visits can stream live pose, //! breathing rate, and heart-rate data out of the sensing-server (`/ws/sensing`, //! `/api/v1/pose/current`, `/api/v1/vital-signs`, …), and trigger state-mutating //! POSTs (`/api/v1/recording/start`, `/api/v1/models/load`, …) when bearer-auth //! is not configured (the default LAN-only deployment posture from #443). //! //! The middleware here rejects any request whose `Host` header is not in the //! configured allowlist with `421 Misdirected Request`. Defaults cover the //! common local-only deployment (`localhost`, `127.0.0.1`, `[::1]` with or //! without `:PORT`). Operators who bind to a routable address (`--bind-addr //! 0.0.0.0` or a LAN IP) extend the allowlist with `--allowed-host` flags or //! the `SENSING_ALLOWED_HOSTS` env var. use std::collections::HashSet; use std::sync::Arc; use axum::{ extract::{Request, State}, http::{header::HOST, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; /// Environment variable that supplies additional allowed hosts /// (comma-separated). Whitespace around each entry is trimmed; empty entries /// are ignored. pub const ALLOWED_HOSTS_ENV: &str = "SENSING_ALLOWED_HOSTS"; /// Built-in allowlist entries. Each entry is also accepted with an optional /// trailing `:PORT` (any port). const DEFAULT_LOOPBACK_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"]; /// Cheap, cloneable handle to the configured Host allowlist. #[derive(Debug, Clone, Default)] pub struct HostAllowlist { /// Lower-cased exact-match hostnames (with or without `:PORT` already /// baked in). Empty set ⇒ middleware accepts everything and is a no-op, /// matching the historical behaviour for callers that want to opt out. entries: Arc>, } impl HostAllowlist { /// Build an allowlist with only the default loopback names (bare and /// with any `:PORT`). Use this when the server is bound to loopback and /// no operator overrides have been supplied. pub fn loopback_only() -> Self { let mut entries: HashSet = HashSet::new(); for h in DEFAULT_LOOPBACK_HOSTS { entries.insert((*h).to_string()); } HostAllowlist { entries: Arc::new(entries), } } /// Build an allowlist from an iterator of additional hostnames (each may /// optionally include a `:PORT` suffix). The default loopback set is /// always included so `--bind-addr 0.0.0.0` deployments do not lock out /// local browsers on `http://localhost:8080/…`. pub fn with_extra(extras: I) -> Self where I: IntoIterator, S: AsRef, { let mut entries: HashSet = 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), } } /// Build an allowlist by joining (a) the default loopback set, (b) any /// CLI-supplied extras, and (c) the comma-separated `SENSING_ALLOWED_HOSTS` /// env var. Order of precedence does not matter — the result is a set. pub fn from_cli_and_env(cli_extras: I) -> Self where I: IntoIterator, S: AsRef, { let env_extras: Vec = 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 = cli_extras .into_iter() .map(|s| s.as_ref().to_string()) .collect(); HostAllowlist::with_extra(cli_vec.into_iter().chain(env_extras)) } /// Disable host-header validation entirely. Provided as an explicit escape /// hatch for operators who deploy the server behind a reverse proxy that /// already canonicalises `Host`, or for unit tests that need to bypass /// the layer. pub fn disabled() -> Self { HostAllowlist::default() } /// True if the middleware will enforce host validation. `false` ⇒ no-op. pub fn is_enabled(&self) -> bool { !self.entries.is_empty() } /// Test-only accessor returning a sorted, lower-cased copy of the /// configured allowlist. Exposed via the `pub(crate)` boundary so we can /// unit-test the env-var parsing without reaching into the `Arc`. pub fn entries_for_test(&self) -> Vec { let mut v: Vec = self.entries.iter().cloned().collect(); v.sort(); v } /// Check whether `host` (the raw `Host` header value, e.g. /// `127.0.0.1:8080` or `[::1]`) is permitted. Comparison is case-insensitive /// on the host part; ports are matched verbatim if the allowlist entry /// pins one, otherwise the port is ignored. 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; } // Exact match (e.g. allowlist contains `127.0.0.1:8080` and request // sent `Host: 127.0.0.1:8080`). if self.entries.contains(&host) { return true; } // Match on host-only when the allowlist entry has no port and the // request includes a port. Handles `Host: 127.0.0.1:8080` against // `127.0.0.1` in the allowlist, and `Host: [::1]:8080` against // `[::1]`. let host_only = strip_port(&host); if self.entries.contains(host_only) { return true; } false } } /// Strip a `:PORT` suffix from `host`, leaving the host portion. IPv6 literals /// are wrapped in brackets (`[::1]:PORT`) so the last `:` is the port /// separator; bracketed IPv6 without a port stays intact. fn strip_port(host: &str) -> &str { if let Some(close) = host.strip_prefix('[').and_then(|_| host.find(']')) { // Bracketed IPv6: `[::1]` or `[::1]:8080`. 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, } } /// Axum middleware: rejects any request whose `Host` header is not in the /// configured allowlist. Use with [`axum::middleware::from_fn_with_state`]. /// /// Behaviour: /// * No `Host` header → `400 Bad Request` (HTTP/1.1 requires one; HTTP/2 /// synthesises it from `:authority`, so a missing value is a real protocol /// violation, not a rebinding signal). /// * `Host` header present but not in the allowlist → `421 Misdirected Request`. /// * Empty allowlist → no-op (the operator explicitly opted out). pub async fn require_allowed_host( State(allowlist): State, 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 or SENSING_ALLOWED_HOSTS= \ 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() { // The whole router is fronted by the middleware — there is no // bypass for `/health` or `/ws/*`, because rebinding doesn't care // which route it targets, it cares about what bytes flow back. 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, ); // Loopback defaults are still in: assert_eq!( status(r.clone(), "/api/v1/pose/current", Some("127.0.0.1")).await, StatusCode::OK, ); // Foreign hosts still rejected: 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]"); // No `:` at all 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() { // Calling `with_extra` with no extras (e.g. operator passed no // `--allowed-host` flags) must keep the loopback defaults so a fresh // 127.0.0.1 deployment isn't bricked. let entries: Vec = 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"); } }