mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +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:
@@ -228,11 +228,19 @@ pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> {
|
||||
let json = serde_json::to_vec_pretty(creds).expect("credentials serialize");
|
||||
let tmp = path.with_extension("tmp");
|
||||
|
||||
std::fs::write(&tmp, &json).map_err(|source| StoreError::Unwritable {
|
||||
// Create with 0600 ALREADY SET, rather than write-then-chmod.
|
||||
//
|
||||
// `fs::write` creates at `0666 & !umask` — 0644 on a default umask — so the
|
||||
// refresh token was world-readable at a predictable path for the window
|
||||
// between the write and the chmod. `save` runs on every silent refresh
|
||||
// (REFRESH_SKEW_SECS against a 15-minute token), so that window recurred
|
||||
// every few minutes, and the refresh token is the highest-value credential
|
||||
// here: identity rotates with reuse detection, so a thief who presents it
|
||||
// first takes the session family and logs the real user out.
|
||||
write_private(&tmp, &json).map_err(|source| StoreError::Unwritable {
|
||||
path: tmp.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict_permissions(&tmp)?;
|
||||
std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
@@ -240,6 +248,29 @@ pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to a file that is never readable by anyone else, at any point.
|
||||
///
|
||||
/// `create_new` also means a pre-existing `.tmp` — a symlink planted by a local
|
||||
/// attacker, or a leftover from a crash — is an error rather than a target.
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let _ = std::fs::remove_file(path); // clear our own leftover, not a race
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -557,6 +588,36 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
|
||||
// The test above checks the FINAL file. It passed while `save` wrote via
|
||||
// `fs::write` (0644 under a default umask) and chmodded afterwards — so
|
||||
// the refresh token sat world-readable at a predictable path in between,
|
||||
// on every silent refresh. Asserting on the destination could never see
|
||||
// that; this asserts on the temp file `save` actually creates.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-tmpperm-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let tmp = path.with_extension("tmp");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
write_private(&tmp, b"secret").unwrap();
|
||||
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "temp file must be created 0600, got {mode:o}");
|
||||
assert_eq!(mode & 0o077, 0, "group/other must have no access at all");
|
||||
|
||||
// A leftover from a crashed run is cleared and replaced, and the
|
||||
// replacement is 0600 too — the mode must not be inherited from
|
||||
// whatever was there before.
|
||||
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o666));
|
||||
write_private(&tmp, b"replacement").unwrap();
|
||||
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "a replaced temp file must also be 0600, got {mode:o}");
|
||||
assert_eq!(std::fs::read(&tmp).unwrap(), b"replacement", "must replace, not append");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_missing_file_says_not_logged_in_rather_than_erroring_obscurely() {
|
||||
let path = std::env::temp_dir().join("ruview-auth-definitely-absent.json");
|
||||
|
||||
Reference in New Issue
Block a user