fix(security): host-header allowlist on sensing-server HTTP + WS — DNS rebinding (#580)
The sensing-server binds to 127.0.0.1 by default with no `Host` header validation on either router. A foreign page can lower its DNS TTL, re-resolve to 127.0.0.1 after the browser has accepted the origin, and then read live pose + vital signs from /api/v1/* + /ws/sensing as same-origin against the attacker's hostname. When `RUVIEW_API_TOKEN` is unset (the documented LAN-mode default from #443/#547) the attacker can also drive state-mutating POSTs (recording/start, models/load, adaptive/train, calibration/start, sona/activate). Defense: a small `host_validation` axum middleware that pins the `Host` header to a configurable allowlist. The loopback names (`localhost`, `127.0.0.1`, `[::1]`, each with or without a port) are always in the set, so default 127.0.0.1 deployments keep working from the local browser without any configuration change. Operators who bind to a routable address extend the set with one or more `--allowed-host` flags or a comma-separated `SENSING_ALLOWED_HOSTS` env var. Reverse-proxy deployments that already canonicalise `Host` opt out with `--disable-host-validation`. The layer is wired into both the dedicated WebSocket router on `--ws-port` (8765) and the main HTTP router on `--http-port` (8080), so /ws/sensing on either listener is covered. Rejection responses are `421 Misdirected Request` (the correct status for a request that arrived at a server that does not consider the supplied `Host` authoritative); missing `Host` is `400 Bad Request`. CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS). Severity: high. Tests: 13 new unit tests on the middleware (loopback defaults, case-insensitivity, IPv6 bracketing, port stripping, env-var/CLI merge, foreign-host rejection on /health + /ws/*, disabled-allowlist escape hatch). Full suite: 220/220 pass under `cargo test -p wifi-densepose-sensing-server --no-default-features`. Co-authored-by: Aeon <aeon@aaronjmars.com>
This commit is contained in:
parent
8a155e07ec
commit
3685d16a49
|
|
@ -0,0 +1,440 @@
|
|||
//! 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<HashSet<String>>,
|
||||
}
|
||||
|
||||
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<String> = 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<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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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.into_iter()))
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
let mut v: Vec<String> = 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<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() {
|
||||
// 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<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");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@
|
|||
//! - Vital sign detection from WiFi CSI amplitude data
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
|
||||
//! - Host-header allowlist / DNS-rebinding defense (`host_validation`)
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod vital_signs;
|
||||
pub mod rvf_container;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,21 @@ struct Args {
|
|||
#[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")]
|
||||
bind_addr: String,
|
||||
|
||||
/// Additional hostname (with or without `:PORT`) to permit in the `Host`
|
||||
/// header — defends loopback-bound deployments against DNS rebinding.
|
||||
/// Loopback names (`localhost`, `127.0.0.1`, `[::1]`) are always permitted
|
||||
/// implicitly. Pass multiple times to add several entries. Comma-separated
|
||||
/// values are also accepted via the `SENSING_ALLOWED_HOSTS` env var.
|
||||
#[arg(long = "allowed-host", value_name = "HOST")]
|
||||
allowed_hosts: Vec<String>,
|
||||
|
||||
/// Disable Host-header validation entirely. Use only when the server sits
|
||||
/// behind a reverse proxy that already canonicalises `Host` (e.g. nginx
|
||||
/// `proxy_set_header Host`) — bare deployments stay vulnerable to DNS
|
||||
/// rebinding without it.
|
||||
#[arg(long)]
|
||||
disable_host_validation: bool,
|
||||
|
||||
/// Data source: auto, wifi, esp32, simulate
|
||||
#[arg(long, default_value = "auto")]
|
||||
source: String,
|
||||
|
|
@ -4969,11 +4984,39 @@ async fn main() {
|
|||
);
|
||||
}
|
||||
|
||||
// DNS-rebinding defense: validate the `Host` header against an allowlist
|
||||
// before any handler runs. Default is loopback-only (`localhost`,
|
||||
// `127.0.0.1`, `[::1]`, each with or without a port). Operators extend
|
||||
// the set via `--allowed-host` flags or the `SENSING_ALLOWED_HOSTS` env
|
||||
// var; `--disable-host-validation` opts out entirely for reverse-proxy
|
||||
// setups that already canonicalise `Host`.
|
||||
let host_allowlist = if args.disable_host_validation {
|
||||
warn!(
|
||||
"Host-header validation DISABLED — server is reachable via any Host. \
|
||||
Only use this behind a reverse proxy that pins Host."
|
||||
);
|
||||
wifi_densepose_sensing_server::host_validation::HostAllowlist::disabled()
|
||||
} else {
|
||||
let allowlist =
|
||||
wifi_densepose_sensing_server::host_validation::HostAllowlist::from_cli_and_env(
|
||||
args.allowed_hosts.iter().cloned(),
|
||||
);
|
||||
info!(
|
||||
"Host-header validation ON ({} entries; loopback names always included)",
|
||||
allowlist.entries_for_test().len()
|
||||
);
|
||||
allowlist
|
||||
};
|
||||
|
||||
// WebSocket server on dedicated port (8765)
|
||||
let ws_state = state.clone();
|
||||
let ws_app = Router::new()
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
.route("/health", get(health))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
))
|
||||
.with_state(ws_state);
|
||||
|
||||
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
|
||||
|
|
@ -5066,6 +5109,14 @@ async fn main() {
|
|||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
// DNS-rebinding defense: applied last so it runs first on the request
|
||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||
// header is not in the allowlist before any handler — including
|
||||
// `/health` and `/ws/*` — observes the body.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from((bind_ip, args.http_port));
|
||||
|
|
|
|||
Loading…
Reference in New Issue