Files
ruvnet--RuView/v2/crates/wifi-densepose-sensing-server/src/host_validation.rs
T
rUv 42dcf49f4d fix(adr): resolve duplicate ADR numbers + close ADR-080 security + ADR-154 M1 signal backlog (#1051)
* fix(signal): circular phase variance for ghost-tap guard (ADR-154 §7.4 #1)

`phase_variance` computed a LINEAR sample variance over phase angles that
wrap at ±π, so a tightly-clustered set straddling the branch cut reported
spuriously HIGH dispersion — false-tripping the `> TAU` ghost-tap guard on
real, tightly-clustered CIR taps.

Replace with Mardia's circular variance V = 1 − R̄, bounded [0,1] and
invariant to where the cluster sits on the circle. Re-derive the guard
against the bounded metric via a named const
`GHOST_TAP_CIRCULAR_VARIANCE_MAX` (the old TAU-scaled threshold is
meaningless on [0,1]).

Grade: metric fix MEASURED; threshold value DATA-GATED — a clean single-path
ramp also sweeps the circle, so V alone cannot separate clean from
unsanitized without labelled frames. Conservative default (0.99) errs toward
never false-rejecting, strictly more permissive at the wrap boundary than the
buggy linear guard.

Fails-on-old test: `phase_variance_circular_not_fooled_by_branch_cut` —
inlines the old linear variance to show it exceeds TAU on wrap-straddling
phases while circular V≈0 and the guard no longer trips. Plus
`phase_variance_circular_is_bounded_and_extremal` (V∈[0,1], V≈0 identical,
V≈1 uniform).

cargo test -p wifi-densepose-signal --no-default-features --features cir --lib
→ 432 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(signal): pin Welford n=0/n=1 finiteness guard (ADR-154 §7.4 #10)

The shared `WelfordStats` (field_model.rs, used by longitudinal.rs and others)
relies on `count < 2` guards in `variance`/`sample_variance`/`std_dev`/
`z_score` to stay finite at the boundaries. The guards existed but the n=0
boundary was UNTESTED — exactly the §4 divide-by-(n−1) family the ADR groups
this with.

Add `welford_finite_at_n0_and_n1` asserting every statistic is finite and
returns the documented sentinel (0.0) at n=0 and n=1, plus load-bearing doc
comments on the two guards.

Fails-on-old proof: with the `sample_variance` guard removed, the test FAILS
with "attempt to subtract with overflow" at the `(self.count - 1)` underflow
(0usize − 1); `variance` would similarly yield 0.0/0.0 = NaN. The guard is
restored; the test pins it so a future regression is caught.

Grade: MEASURED (boundary finiteness is asserted; the guard is the §4-family
fix made testable).

cargo test -p wifi-densepose-signal --no-default-features --lib field_model
→ 22 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic adversarial thresholds + boundary tests (ADR-154 §7.4 #13)

Lift the bare numeric literals buried in `check`/`check_consistency` into
named, documented module consts (FIELD_MODEL_GINI_VIOLATION=0.8,
ENERGY_RATIO_HIGH_VIOLATION=2.0, ENERGY_RATIO_LOW_VIOLATION=0.1,
CONSISTENCY_ACTIVE_FRACTION_OF_MEAN=0.1, SCORE_W_* weights). VALUES UNCHANGED —
each const equals the original literal; only names + pinning tests are new.

Grade: DATA-GATED. The operating values stay empirical (defensible values need
labelled spoofed/clean CSI — Wi-Spoof, §6.2/§7.3). The de-magicking +
characterization tests are MEASURED: `tuning_consts_unchanged_from_literals`,
`energy_ratio_high_boundary`, `energy_ratio_low_boundary`,
`field_model_gini_boundary`, `consistency_active_fraction_boundary` pin the
decision boundaries at/just-below/just-above each threshold, so a future
data-driven retune is a visible, tested change.

Fails-on-change proof: bumping ENERGY_RATIO_HIGH_VIOLATION 2.0→3.0 makes
`energy_ratio_high_boundary` FAIL (restored). Operating values explicitly
NOT changed.

cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::adversarial
→ 20 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic coherence drift/gate thresholds (ADR-154 §7.4 #9)

Lift the bare detection literals in `coherence.rs::classify_drift`
(DRIFT_STABLE_SCORE=0.85, DRIFT_STEP_CHANGE_MAX_STALE=10) and the
`coherence_gate.rs` Default impl (DEFAULT_ACCEPT_THRESHOLD=0.85,
DEFAULT_REJECT_THRESHOLD=0.5, DEFAULT_MAX_STALE_FRAMES=200,
DEFAULT_PREDICT_ONLY_NOISE=3.0) into named, documented consts. VALUES
UNCHANGED. The gate already exposed these via GatePolicyConfig (config seam);
this names + pins the defaults.

Grade: DATA-GATED. Operating values stay empirical (defensible Z-score
thresholds need labelled stable/drifting coherence traces). De-magicking +
boundary tests are MEASURED: `classify_drift_stable_score_boundary`,
`classify_drift_stale_count_boundary` pin the at/just-below/just-above
decisions; `drift_consts_unchanged_from_literals` /
`gate_default_consts_unchanged_from_literals` pin the values. Operating values
explicitly NOT changed.

cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::coherence
→ 40 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-154): mark §7.4 P1 backlog cleared — Milestone-1 (#1,#10 RESOLVED; #9,#13 DATA-GATED)

Update ADR-154 §7.4 backlog rows #1, #9, #10, #13 with commit refs + grades,
the §7.4 intro count (four P1 items cleared, ~41 P2/P3 remain), the
Horizon-ledger one-liner (Milestone-1 DONE), and the §8 honest-limits #1 line
(metric now correct; threshold still DATA-GATED). Add CHANGELOG [Unreleased]
entry.

Grades: #1 RESOLVED (MEASURED metric / DATA-GATED threshold), #10 RESOLVED
(MEASURED), #9 & #13 RESOLVED-PARTIAL (DATA-GATED — de-magicked + boundary
tested, operating values unchanged).

Validation: cargo test --workspace --no-default-features → 2057 passed, 0
failed; wifi-densepose-signal lib → 442 passed (no-default + --features cir);
python archive/v1/data/proof/verify.py → VERDICT: PASS, hash f8e76f21…46f7a
UNCHANGED (CIR ghost-tap guard is not on the deterministic proof path).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(sensing-server): stop leaking internal errors in HTTP responses (ADR-080 #2)

Six handlers in `main.rs` serialized the internal error `Display` straight
into the JSON response body, leaking server internals to any client (ADR-080
finding #2, CWE-209; reframed onto the Rust boundary by ADR-164 G11):

  - edge_registry_endpoint: a panicked spawn_blocking `JoinError`
    ("task … panicked") in a 500, and the raw upstream error in a 503
  - delete_model / delete_recording / start_recording: std::io::Error
    strings carrying OS detail / filesystem paths
  - calibration_start / calibration_stop: the FieldModel error chain

New `error_response` module: `internal_error` / `internal_error_json` /
`upstream_unavailable` log the full detail server-side only (tagged with a
correlation id) and return a generic body
(`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file
paths, no Debug chain. The correlation id lets an operator join a client
report to the exact server log line without ever shipping the detail.

Pinned by 5 error_response tests, incl. a leak-substring guard
(internal_error_body_does_not_leak_detail) verified to FAIL on the reverted
old body (returns the panic message / path / "os error"). The HOMECORE sweep
(ADR-161) covered homecore-server, not this crate.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(sensing-server): pin XFF-immunity + no-query-token (ADR-080 #1, #3)

Findings #1 (XFF-spoofing bypass) and #3 (JWT-in-URL, CWE-598) were logged
against the Python v1 API but are VERIFIED ABSENT on the current Rust
sensing-server, so they get regression tests rather than redundant fixes:

  - #1 XFF: there is no IP-based rate-limiter or IP-allowlist to bypass, and
    neither security middleware reads a forwarded header. Added
    bearer_auth::xff_header_never_affects_auth_decision (spoofed
    X-Forwarded-For never flips a 401<->200 decision) and
    host_validation::forwarded_headers_never_bypass_host_allowlist (spoofed
    X-Forwarded-Host: localhost never lets Host: evil.com past the allowlist).

  - #3 JWT-in-URL: require_bearer reads the token only from the Authorization
    header; WS handlers take no query token; the sole Query extractor
    (EdgeRegistryParams) is a non-secret refresh flag. Added
    bearer_auth::query_string_token_is_never_accepted — ?token= / ?access_token=
    in the URL never authenticates (stays 401) while the header path still 200s.
    Verified to FAIL when a query-token path is injected into require_bearer.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-080): mark P0 security findings #1-#3 RESOLVED; close ADR-164 G11

- ADR-080: Status note + per-finding closure (#1 XFF and #3 JWT-in-URL
  verified absent + regression-pinned; #2 leaked errors fixed via the
  error_response module). Records the v1-vs-Rust boundary distinction
  explicitly: v1 paths remain archived; this closure governs the shipped
  Rust sensing-server.
- ADR-164: Gap Register G11 and the Open/Gated Backlog entry marked
  RESOLVED with the fix + branch reference.
- CHANGELOG: [Unreleased] -> ### Security entry covering all three findings.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): renumber 6 displaced ADRs to resolve duplicate-number collisions (ADR-164 G1)

Resolves the 5 duplicate ADR numbers (6 displaced files) flagged by ADR-164
Gap Register item G1. Canonical keeper per number = first file committed at
that number (date tie-broken by inbound cross-reference count / parent-appendix
relationship). Displaced files renumbered to the next free numbers (166-171):

  050 keeps provisioning-tool-enhancements (5 refs vs 1)
    -> ADR-166-quality-engineering-security-hardening
  052 keeps tauri-desktop-frontend (parent ADR)
    -> ADR-167-ddd-bounded-contexts (its appendix)
  147 keeps nvidia-cosmos/OccWorld (the actual ADR, has Status header)
    -> ADR-168-benchmark-proof (proof companion, no Status)
    -> ADR-169-adam-mode-light-theme (was untracked)
  148 keeps drone-swarm-control-system (committed #862)
    -> ADR-170-yoga-mode-pose-system (was untracked)
  149 keeps public-community-leaderboard-huggingface (committed 16:47 vs 17:38)
    -> ADR-171-swarm-benchmarking-evaluation-methodology

Updates in-file `# ADR-NNN` headers and intra-file self-references (yoga-modes

* docs(adr): repoint inbound cross-references to renumbered ADRs (166-171)

Follow-up to the ADR renumbering (ADR-164 G1). Updates every inbound reference
that pointed at a displaced ADR, disambiguating shared numbers by title/slug so
only references to the DISPLACED topic move and keeper references stay put.

ADR-168 (was 147 benchmark-proof): README, CHANGELOG, user-guide,
  proof-of-capabilities, research docs 00/03 — all path/label refs updated.
ADR-169 (was 147 adam-mode) / ADR-170 (was 148 yoga-mode): docs/adr/README index.
ADR-171 (was 149 swarm-benchmarking): all ruview-swarm eval code+docs
  (Cargo.toml, evals/, eval_swarm.rs, metrics/mod/report/runner.rs), research
  doc 03 (every §-ref matched ADR-171 sections, not AetherArena), 00-system-review,
  series README, CHANGELOG, and ADR-148's forward/"open issues" pointers.
ADR-166 (was 050 quality-engineering / security-hardening): disambiguated from the
  ADR-050 provisioning KEEPER by topic. The HMAC/secure_tdm, directory-traversal,
  bind-address, and OTA-PSK-auth references in code comments
  (wifi-densepose-hardware Cargo.toml + secure_tdm.rs, sensing-server main.rs) and
  in ADR-052-tauri / ADR-167 all describe the security-hardening ADR -> ADR-166.
ADR-167 (was 052 ddd-appendix): inbound appendix references.

Index/registry updates: docs/adr/README.md, gap-analysis/census.md (rows +
header count), gap-analysis/lens-findings.md (collision table marked RESOLVED),
and ADR-164 Gap Register G1 marked RESOLVED with the full renumber map.

Keeper references deliberately untouched: all ADR-147 OccWorld code, all ADR-148
drone-swarm code/docs, all ADR-149 AetherArena refs (incl. ADR-150's SSL/resampling
refs, which ADR-150 explicitly binds to the AetherArena benchmark), ADR-050
provisioning refs, ADR-052 tauri refs. The frozen GitHub blob URLs in
docs/adr/.issue-177-body.md (pinned to an old branch) are left as historical.

Comment-only code edits; no behavior change. wifi-densepose-hardware compiles
clean; the sensing-server build's sole blocker is the pre-existing upstream
midstreamer-temporal-compare@0.2.1 registry crate, unrelated to these edits.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 14:31:38 -04:00

485 lines
18 KiB
Rust

//! 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))
}
/// 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,
);
}
/// REGRESSION (ADR-080 #1 — X-Forwarded-For / X-Forwarded-Host spoofing).
///
/// The DNS-rebinding allowlist must decide purely on the real `Host` header
/// and ignore any client-supplied forwarding headers. Otherwise an attacker
/// could spoof `X-Forwarded-Host: localhost` (or `X-Forwarded-For`) to slip a
/// foreign `Host` past the allowlist. This test sends a rejected `Host:
/// evil.com` *with* allowlisted forwarding headers and asserts the request is
/// still `421` — the forwarded headers must not bypass the control. It also
/// confirms an allowed `Host` stays `200` regardless of a hostile XFF.
#[tokio::test]
async fn forwarded_headers_never_bypass_host_allowlist() {
let r = router(HostAllowlist::loopback_only());
async fn with_forwarded(
router: Router,
host: &str,
xff: &str,
xfh: &str,
) -> StatusCode {
let req = Request::builder()
.method("GET")
.uri("/api/v1/pose/current")
.header(HOST, host)
.header("X-Forwarded-For", xff)
.header("X-Forwarded-Host", xfh)
.body(Body::empty())
.unwrap();
router.oneshot(req).await.unwrap().status()
}
// Foreign Host + spoofed allowlisted forwarding headers ⇒ still rejected.
assert_eq!(
with_forwarded(r.clone(), "evil.com", "127.0.0.1", "localhost").await,
StatusCode::MISDIRECTED_REQUEST,
"X-Forwarded-* must not let a foreign Host bypass the allowlist"
);
// Allowed Host + hostile forwarding headers ⇒ still allowed (forwarded
// headers are simply not consulted).
assert_eq!(
with_forwarded(r, "127.0.0.1:8080", "evil.com", "evil.com").await,
StatusCode::OK,
"the real Host header is the only signal; XFF/XFH are ignored"
);
}
#[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");
}
}