mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
42dcf49f4d
* 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>
388 lines
14 KiB
Rust
388 lines
14 KiB
Rust
//! Opt-in bearer-token auth for the sensing-server HTTP API (#443).
|
|
//!
|
|
//! When the `RUVIEW_API_TOKEN` environment variable is set, every request
|
|
//! whose path begins with `/api/v1/` must carry a matching
|
|
//! `Authorization: Bearer <token>` header, otherwise the server responds with
|
|
//! `401 Unauthorized`. When the env var is unset (or empty), the middleware is
|
|
//! a no-op and the API stays unauthenticated — preserving the long-standing
|
|
//! LAN-only deployment posture documented in the issue. This is a binary,
|
|
//! deployment-time switch with **no default authentication change**.
|
|
//!
|
|
//! Endpoints outside `/api/v1/*` (`/health*`, `/ws/sensing`, the static `/ui/*`
|
|
//! mount, `/`) are intentionally **not** gated:
|
|
//! * `/health*` is the liveness/readiness probe that orchestrators hit
|
|
//! anonymously;
|
|
//! * `/ws/sensing` and `/ui/*` are served to local browsers that can't easily
|
|
//! inject headers — the sensitive control plane is the `/api/v1/*` tree, and
|
|
//! that is what this layer protects.
|
|
//!
|
|
//! The header check uses a length-then-byte constant-time compare to avoid
|
|
//! leaking the token through timing.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::{Request, State},
|
|
http::{header::AUTHORIZATION, StatusCode},
|
|
middleware::Next,
|
|
response::{IntoResponse, Response},
|
|
};
|
|
|
|
/// Environment variable that gates the middleware. Unset / empty ⇒ auth off.
|
|
pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
|
|
|
|
/// Path prefix the middleware protects when auth is enabled.
|
|
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
|
|
|
/// Cheap, cloneable handle to the configured token (or `None`).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AuthState {
|
|
/// The expected bearer token, if any. `None` ⇒ middleware is a no-op.
|
|
token: Option<Arc<String>>,
|
|
}
|
|
|
|
impl AuthState {
|
|
/// Build an [`AuthState`] from an explicit string. Empty ⇒ disabled.
|
|
pub fn from_token(t: impl Into<String>) -> Self {
|
|
let s = t.into();
|
|
if s.is_empty() {
|
|
AuthState { token: None }
|
|
} else {
|
|
AuthState {
|
|
token: Some(Arc::new(s)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read [`API_TOKEN_ENV`] from the process environment. Returns
|
|
/// `AuthState { token: None }` when the variable is unset or empty.
|
|
pub fn from_env() -> Self {
|
|
match std::env::var(API_TOKEN_ENV) {
|
|
Ok(s) if !s.is_empty() => AuthState::from_token(s),
|
|
_ => AuthState::default(),
|
|
}
|
|
}
|
|
|
|
/// Whether the middleware will enforce auth on `/api/v1/*` requests.
|
|
pub fn is_enabled(&self) -> bool {
|
|
self.token.is_some()
|
|
}
|
|
}
|
|
|
|
/// Constant-time byte slice equality. Returns `false` immediately on length
|
|
/// mismatch (lengths are not secret here — both sides are fixed tokens).
|
|
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
let mut diff = 0u8;
|
|
for (x, y) in a.iter().zip(b.iter()) {
|
|
diff |= x ^ y;
|
|
}
|
|
diff == 0
|
|
}
|
|
|
|
/// Axum middleware: enforces `Authorization: Bearer <token>` on `/api/v1/*`
|
|
/// requests when [`AuthState::is_enabled`] returns `true`. Wires up via
|
|
/// [`axum::middleware::from_fn_with_state`].
|
|
pub async fn require_bearer(
|
|
State(auth): State<AuthState>,
|
|
request: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let Some(expected) = auth.token.clone() else {
|
|
return next.run(request).await;
|
|
};
|
|
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
|
|
return next.run(request).await;
|
|
}
|
|
let supplied = request
|
|
.headers()
|
|
.get(AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme ("Bearer") is
|
|
// case-insensitive. Match it as such (and tolerate extra leading
|
|
// whitespace before the token) so a correct token isn't rejected
|
|
// just because a client sent `bearer`/`BEARER`. The token compare
|
|
// below stays exact + constant-time.
|
|
.and_then(|s| {
|
|
let (scheme, token) = s.split_once(' ')?;
|
|
scheme
|
|
.eq_ignore_ascii_case("Bearer")
|
|
.then(|| token.trim_start())
|
|
});
|
|
let ok = supplied
|
|
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
|
|
.unwrap_or(false);
|
|
if ok {
|
|
next.run(request).await
|
|
} else {
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
"missing or invalid bearer token (set Authorization: Bearer <RUVIEW_API_TOKEN>)\n",
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use tower::ServiceExt;
|
|
|
|
fn ok_handler() -> Router {
|
|
Router::new()
|
|
.route("/health", get(|| async { "ok" }))
|
|
.route("/api/v1/info", get(|| async { "ok" }))
|
|
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
|
|
.route("/ui/index.html", get(|| async { "<html/>" }))
|
|
}
|
|
|
|
fn wrap(auth: AuthState) -> Router {
|
|
ok_handler().layer(axum::middleware::from_fn_with_state(auth, require_bearer))
|
|
}
|
|
|
|
async fn status(router: Router, method: &str, path: &str, auth: Option<&str>) -> StatusCode {
|
|
let mut req = Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
if let Some(t) = auth {
|
|
req.headers_mut()
|
|
.insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap());
|
|
}
|
|
router.oneshot(req).await.unwrap().status()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn middleware_is_no_op_when_token_unset() {
|
|
let r = wrap(AuthState::default());
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/api/v1/info", None).await,
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
status(r.clone(), "POST", "/api/v1/sensitive", None).await,
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/health", None).await,
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
status(r, "GET", "/ui/index.html", None).await,
|
|
StatusCode::OK
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_blocks_api_without_bearer() {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/api/v1/info", None).await,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
status(r, "POST", "/api/v1/sensitive", None).await,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accepts_case_insensitive_bearer_scheme() {
|
|
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme is case-insensitive.
|
|
// A correct token must authenticate regardless of scheme casing or
|
|
// extra whitespace; a wrong token must still be rejected.
|
|
async fn req_status(auth_value: &str) -> StatusCode {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
let mut req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/info")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
req.headers_mut()
|
|
.insert(AUTHORIZATION, auth_value.parse().unwrap());
|
|
r.oneshot(req).await.unwrap().status()
|
|
}
|
|
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
|
|
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
|
|
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
|
|
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); // extra space
|
|
// Scheme leniency must NOT weaken the token check.
|
|
assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
|
|
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_blocks_api_with_wrong_bearer() {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/api/v1/info", Some("nope")).await,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
// Wrong scheme (Basic / token) — only "Bearer <token>" is accepted.
|
|
let mut req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/info")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
req.headers_mut()
|
|
.insert(AUTHORIZATION, "Basic s3cr3t".parse().unwrap());
|
|
assert_eq!(
|
|
r.oneshot(req).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_allows_api_with_correct_bearer() {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/api/v1/info", Some("s3cr3t")).await,
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
status(r, "POST", "/api/v1/sensitive", Some("s3cr3t")).await,
|
|
StatusCode::OK
|
|
);
|
|
}
|
|
|
|
/// REGRESSION (ADR-080 #3, CWE-598 — token in URL query string).
|
|
///
|
|
/// ADR-080 flagged "JWT in URL" as a HIGH finding (tokens in query strings
|
|
/// leak into logs, proxies, browser history, `Referer`). The current
|
|
/// sensing-server only ever reads the token from the `Authorization: Bearer`
|
|
/// header — there is no `?token=` / `?access_token=` query path in
|
|
/// `require_bearer` (see [`require_bearer`] above, which only inspects the
|
|
/// `AUTHORIZATION` header). This test pins that: a request carrying the
|
|
/// correct token *only* in the query string is still `401`, while the same
|
|
/// token in the header is `200`. If anyone ever re-introduces a query-string
|
|
/// token path, this fails.
|
|
#[tokio::test]
|
|
async fn query_string_token_is_never_accepted() {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
// Correct token, but supplied only in the URL — must NOT authenticate.
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/api/v1/info?token=s3cr3t", None).await,
|
|
StatusCode::UNAUTHORIZED,
|
|
"?token= in the query string must not authenticate (CWE-598)"
|
|
);
|
|
assert_eq!(
|
|
status(
|
|
r.clone(),
|
|
"GET",
|
|
"/api/v1/info?access_token=s3cr3t",
|
|
None
|
|
)
|
|
.await,
|
|
StatusCode::UNAUTHORIZED,
|
|
"?access_token= in the query string must not authenticate (CWE-598)"
|
|
);
|
|
// A query token must not "help" a request that also lacks the header,
|
|
// even combined with an unrelated param.
|
|
assert_eq!(
|
|
status(
|
|
r.clone(),
|
|
"GET",
|
|
"/api/v1/info?foo=bar&token=s3cr3t",
|
|
None
|
|
)
|
|
.await,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
// The header path is the only accepted channel — same token, header,
|
|
// succeeds. (Proves we didn't just break auth entirely.)
|
|
assert_eq!(
|
|
status(r, "GET", "/api/v1/info?token=s3cr3t", Some("s3cr3t")).await,
|
|
StatusCode::OK,
|
|
"the Authorization: Bearer header is the supported channel"
|
|
);
|
|
}
|
|
|
|
/// REGRESSION (ADR-080 #1 — X-Forwarded-For spoofing).
|
|
///
|
|
/// The bearer middleware authenticates on the token alone and must be
|
|
/// completely insensitive to a client-supplied `X-Forwarded-For` header:
|
|
/// an attacker cannot flip an auth decision by spoofing XFF. A wrong token
|
|
/// stays `401` and a right token stays `200` regardless of XFF. (The
|
|
/// sensing-server has no IP-based rate-limit / allowlist that XFF could
|
|
/// bypass; this locks in that auth itself never consults XFF.)
|
|
#[tokio::test]
|
|
async fn xff_header_never_affects_auth_decision() {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
async fn with_xff(router: Router, token: Option<&str>, xff: &str) -> StatusCode {
|
|
let mut req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/info")
|
|
.header("X-Forwarded-For", xff)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
if let Some(t) = token {
|
|
req.headers_mut()
|
|
.insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap());
|
|
}
|
|
router.oneshot(req).await.unwrap().status()
|
|
}
|
|
// Spoofed XFF + no/ wrong token ⇒ still rejected.
|
|
assert_eq!(
|
|
with_xff(r.clone(), None, "127.0.0.1").await,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
with_xff(r.clone(), Some("nope"), "10.0.0.1, 127.0.0.1").await,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
// Spoofed XFF + correct token ⇒ still accepted (XFF is irrelevant).
|
|
assert_eq!(
|
|
with_xff(r, Some("s3cr3t"), "evil-proxy").await,
|
|
StatusCode::OK
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_never_gates_paths_outside_api_v1() {
|
|
let r = wrap(AuthState::from_token("s3cr3t"));
|
|
// Even with auth ON, `/health` and `/ui/*` are reachable without a token:
|
|
// orchestrator probes and the local UI need to load unchallenged.
|
|
assert_eq!(
|
|
status(r.clone(), "GET", "/health", None).await,
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
status(r, "GET", "/ui/index.html", None).await,
|
|
StatusCode::OK
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ct_eq_basics() {
|
|
assert!(ct_eq(b"abc", b"abc"));
|
|
assert!(!ct_eq(b"abc", b"abd"));
|
|
assert!(!ct_eq(b"abc", b"ab")); // length mismatch
|
|
assert!(!ct_eq(b"", b"x"));
|
|
assert!(ct_eq(b"", b""));
|
|
}
|
|
|
|
#[test]
|
|
fn from_env_treats_empty_as_disabled() {
|
|
// Avoid touching the real env in a thread-shared test — exercise the
|
|
// string ctor directly with the same trim logic.
|
|
assert!(!AuthState::from_token("").is_enabled());
|
|
assert!(AuthState::from_token("x").is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn protected_prefix_and_env_constants_are_stable() {
|
|
// These are documented in the issue body and the README; keep them locked.
|
|
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
|
|
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
|
|
}
|
|
}
|