wifi-densepose-sensing-server 0.3.2

Lightweight Axum server for WiFi sensing UI with RuVector signal processing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! 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,
        );
    }

    #[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");
    }
}