mirror of
https://github.com/ruvnet/RuView
synced 2026-08-08 20:11:43 +00:00
fix(auth): close /api/field bypass, two fail-opens, and a self-disarming test
Findings from a qe-court adversarial round (4 prosecutors across 2 vendors).
Each was verified against the code before being accepted; the ones below
reproduced, the rest are reported in the PR thread rather than acted on.
FATAL — `/api/field` was reachable with no credential, on both listeners.
The gate protected `/api/v1/*` by prefix. `/api/field` is the REST sibling of
`/ws/field` and serves the same signed FieldEvent stream — live presence, pose,
vitals. `/ws/field` was gated in this PR; its twin one path segment over was
not. Measured with RUVIEW_API_TOKEN set and no credential supplied:
/api/v1/models 401 (control)
/ws/field 401 (gated by this PR)
/api/field 200 on :8080 AND :8765
Fixed by inverting the gate to deny-by-default with an explicit anonymous
allowlist (`/`, `/ui`, `/health`, `/oauth/`). A route added at a new path is
now gated because nobody exposed it, rather than exposed because nobody
protected it — the same inversion already applied to the scope gate.
FATAL — the wiring test disarmed itself exactly when it mattered.
`Server::start` returned an Option that all five tests turned into `return`,
so a server that failed to boot produced "5 passed" with zero assertions run,
and cargo swallows the skip line without --nocapture. The one test that
observes real wiring — the guard against both shipped bypasses — was silent
for any change that breaks startup, including a boot panic in the auth path.
It now panics with the child's stderr.
MAJOR — a malformed client-id list silently disabled the audience check.
An empty allowlist is the opt-out sentinel in verify.rs. `RUVIEW_OAUTH_CLIENT_IDS=","`
is non-empty, passes the guard, then filters to an empty Vec — turning the
audience boundary off with no log and admitting a token minted for any other
Cognitum product. Only a literal `*` may opt out now; anything else that parses
to nothing warns and falls back to the default. Same fail-open shape as the
scope denylist this PR already had to invert.
MAJOR — credentials were world-readable for a window on every refresh.
`fs::write` creates at 0666 & !umask (0644 by default), and both writers
chmodded afterwards. The existing permissions test asserted on the FINAL file
and passed throughout. Affected the CLI refresh token (rotated with reuse
detection — a thief who presents it first takes the session family) and the
browser session secret (the HMAC key for every session; stealing it forges any
account at any scope). Both now create with mode 0600 via OpenOptions.
MAJOR — ui/sw.js cached authenticated API responses.
Closing the /oauth/ leg left the /api/ leg open. `networkFirst` cached every
successful response, keyed by URL alone, purged by nothing at sign-out: sign in
as A, load sensing data, sign out, sign in as B, lose the network, and B is
served A's data with no authorization check. API responses are now network-only
— which is also the correct behaviour for a live sensing dashboard, where
replaying a stale reading can show a room occupied after the person left — plus
a cache purge on sign-out.
CI — 40 of ruview-auth's 87 tests never ran.
The workspace runs --no-default-features, which switches off the `login` and
`pkce` features. Measured: 47 tests vs 87. The whole interactive sign-in path —
credential storage, single-flight refresh, the file lock, the loopback callback
— was green locally and never executed in CI. Added an --all-features step.
(Checked the sensing-server for the same problem and did NOT find it:
bearer_auth's 49 and browser_session's 15 do run under CI flags.)
Tests: +2 wiring tests (one fails against the old gate with
"http port served /api/field to an anonymous caller", passes after), +3 UI
service-worker tests, +3 CLI scope tests, +1 temp-file permission test, +1
client-id parsing test. wifi-densepose-cli/src/auth.rs had zero tests and
builds its own scope string, so the library's least-privilege test said nothing
about what the CLI requests.
Verified: ruview-auth 61+25+2 pass (--all-features), sensing-server bearer_auth
50, browser_session 15, auth_wiring 7, workspace 25 suites clean, UI 22.
Co-Authored-By: Ruflo & AQE
This commit is contained in:
@@ -86,18 +86,78 @@ pub const DEFAULT_CLIENT_ID: &str = "ruview";
|
||||
fn allowed_client_ids() -> Vec<String> {
|
||||
match std::env::var(OAUTH_CLIENT_IDS_ENV) {
|
||||
Ok(v) if v.trim() == "*" => Vec::new(), // explicit opt-out
|
||||
Ok(v) if !v.trim().is_empty() => v
|
||||
.split(',')
|
||||
.map(|c| c.trim().to_string())
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect(),
|
||||
Ok(v) if !v.trim().is_empty() => {
|
||||
let parsed: Vec<String> = v
|
||||
.split(',')
|
||||
.map(|c| c.trim().to_string())
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
// An empty Vec is the OPT-OUT sentinel downstream: `verify.rs` skips
|
||||
// the audience check entirely when the allowlist is empty. So a value
|
||||
// that is non-empty but parses to nothing — `","`, `" , "`, a stray
|
||||
// trailing comma — would silently disable the audience boundary and
|
||||
// admit a token minted for any other Cognitum product. Only the
|
||||
// literal `*` may turn that check off.
|
||||
if parsed.is_empty() {
|
||||
tracing::warn!(
|
||||
value = %v,
|
||||
"{OAUTH_CLIENT_IDS_ENV} is set but lists no client id; falling back to \
|
||||
{DEFAULT_CLIENT_ID}. Use `*` if you really mean to accept any client."
|
||||
);
|
||||
return vec![DEFAULT_CLIENT_ID.to_string()];
|
||||
}
|
||||
parsed
|
||||
}
|
||||
_ => vec![DEFAULT_CLIENT_ID.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Path prefix the middleware protects when auth is enabled.
|
||||
///
|
||||
/// Retained because it names the bulk of the protected surface and is asserted
|
||||
/// in tests, but it is NO LONGER the rule. The gate is [`is_anonymous`] —
|
||||
/// deny-by-default. See that function for why.
|
||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
||||
|
||||
/// Paths that stay reachable with no credential when auth is enabled.
|
||||
///
|
||||
/// # Why an allowlist
|
||||
///
|
||||
/// The gate used to be the inverse: protect `/api/v1/*`, let everything else
|
||||
/// through. That is exposure-by-default, and it leaked. `/api/field` — the REST
|
||||
/// sibling of `/ws/field`, serving the same signed `FieldEvent` stream of live
|
||||
/// presence, pose and vitals — sits at `/api/field`, not `/api/v1/`, so it
|
||||
/// returned `200` with no credential on BOTH listeners while `/api/v1/models`
|
||||
/// correctly returned `401`. `/ws/field` was gated in this same PR; its REST
|
||||
/// twin one path segment over was not.
|
||||
///
|
||||
/// Measured, with `RUVIEW_API_TOKEN` set and no credential supplied:
|
||||
/// `/api/v1/models` -> 401, `/ws/field` -> 401, `/api/field` -> 200 on :8080
|
||||
/// and :8765.
|
||||
///
|
||||
/// With an allowlist, a route added at a new path is gated because nobody
|
||||
/// remembered to expose it, rather than exposed because nobody remembered to
|
||||
/// protect it. That is the same inversion already applied to the scope gate in
|
||||
/// [`required_scope_for`].
|
||||
const ANONYMOUS_PREFIXES: &[&str] = &[
|
||||
// Orchestrator and load-balancer probes. Documented exemption (ADR-272),
|
||||
// pinned by `health_stays_anonymous_on_both_listeners`.
|
||||
"/health",
|
||||
// Sign-in cannot require being signed in.
|
||||
"/oauth/",
|
||||
];
|
||||
|
||||
/// Is this path reachable without a credential? See [`ANONYMOUS_PREFIXES`].
|
||||
pub fn is_anonymous(path: &str) -> bool {
|
||||
// The dashboard shell itself, mounted with `nest_service("/ui", …)`. It has
|
||||
// to load for the user to reach the sign-in button at all; the data it then
|
||||
// fetches is what is protected.
|
||||
if path == "/" || path == "/ui" || path.starts_with("/ui/") {
|
||||
return true;
|
||||
}
|
||||
ANONYMOUS_PREFIXES.iter().any(|p| path.starts_with(p))
|
||||
}
|
||||
|
||||
/// WebSocket upgrade endpoints. Previously ungated — `/ws/*` sat outside
|
||||
/// [`PROTECTED_PREFIX`] and `/api/v1/stream/pose` was an explicit exemption —
|
||||
/// because a browser's `WebSocket` constructor cannot attach an
|
||||
@@ -489,7 +549,7 @@ pub async fn require_bearer(
|
||||
}
|
||||
// No ticket: fall through to the bearer path below, which is how a
|
||||
// native (non-browser) client authenticates a WebSocket.
|
||||
} else if !path.starts_with(PROTECTED_PREFIX) {
|
||||
} else if is_anonymous(&path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
@@ -655,6 +715,43 @@ mod tests {
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// ONE test, not three, because `allowed_client_ids` reads process-global
|
||||
/// env and cargo runs tests on parallel threads — three tests mutating
|
||||
/// `RUVIEW_OAUTH_CLIENT_IDS` race and fail intermittently.
|
||||
#[test]
|
||||
fn client_id_allowlist_parsing_never_silently_opts_out() {
|
||||
let read = |v: &str| {
|
||||
std::env::set_var(OAUTH_CLIENT_IDS_ENV, v);
|
||||
let ids = allowed_client_ids();
|
||||
std::env::remove_var(OAUTH_CLIENT_IDS_ENV);
|
||||
ids
|
||||
};
|
||||
|
||||
// An empty allowlist is the OPT-OUT sentinel in `verify.rs` — it skips
|
||||
// the audience check entirely. So a value that is non-empty but parses
|
||||
// to nothing (a stray trailing comma, `","`) would silently turn the
|
||||
// boundary off and admit a token minted for any other Cognitum product.
|
||||
// Same fail-open shape as the scope denylist this PR already inverted.
|
||||
for bad in [",", " , ", ",,,", " "] {
|
||||
let ids = read(bad);
|
||||
assert!(
|
||||
!ids.is_empty(),
|
||||
"{bad:?} produced an empty allowlist, which disables the audience check"
|
||||
);
|
||||
assert_eq!(ids, vec![DEFAULT_CLIENT_ID.to_string()]);
|
||||
}
|
||||
|
||||
// The deliberate escape hatch must keep working, or the fix above would
|
||||
// be a behaviour change wearing a security label.
|
||||
assert!(read("*").is_empty(), "`*` must remain the way to accept any client");
|
||||
|
||||
// And a real list must still parse, trailing comma and all.
|
||||
assert_eq!(
|
||||
read(" ruview , musica ,"),
|
||||
vec!["ruview".to_string(), "musica".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
fn ok_handler() -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
|
||||
@@ -214,13 +214,26 @@ fn write_secret(path: &Path, value: &str) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, value)?;
|
||||
// Created 0600, not written-then-chmodded. `fs::write` creates at
|
||||
// `0666 & !umask`, so this file — the HMAC key for EVERY browser session —
|
||||
// was world-readable for the window before the chmod. Anyone who read it
|
||||
// could forge a session cookie for any account with any scope, including
|
||||
// `sensing:admin`, which is strictly worse than stealing one session.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Restrict BEFORE publishing the path, as with the CLI's credentials.
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?;
|
||||
f.write_all(value.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(&tmp, value)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user