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:
Dragan Spiridonov
2026-07-23 11:48:59 +02:00
parent 9b9754778f
commit c72bbc15dd
8 changed files with 459 additions and 68 deletions
+52 -9
View File
@@ -97,7 +97,16 @@ self.addEventListener('fetch', (event) => {
// Credentialed endpoints: hands off entirely. Not networkFirst — that still
// writes a copy into the cache, which would be replayed the moment the server
// is briefly unreachable, silently reinstating a stale sign-in state.
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
// Signing out is the one moment we know cached data belongs to a session
// that is ending. Observed, not intercepted — the request itself still goes
// straight to the network. `waitUntil` keeps the worker alive for the purge
// even though the navigation is what the browser is really waiting on.
if (url.pathname === '/oauth/logout') {
event.waitUntil(purgeNonShell());
}
return;
}
// API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
@@ -140,20 +149,54 @@ async function cacheFirst(request) {
}
}
/**
* Network-only, with an explicit offline signal.
*
* This used to cache every successful `/api/` response and replay it whenever
* the network failed. Two things are wrong with that now:
*
* 1. **Authorization.** API responses are per-user once auth is on, but the
* cache is keyed by URL alone and nothing purges it at sign-out. Sign in as
* A, load sensing data, sign out, sign in as B, lose the network — B is
* served A's data with no authorization check at all. That is the same
* defect class as the cached `/oauth/status`: the Cache API happily outlives
* the session that produced its contents.
* 2. **Correctness.** This is a live sensing dashboard. Replaying a stale pose
* or presence reading as if it were current is its own defect — it can show
* a room as occupied after the person has left.
*
* The offline shell (HTML/CSS/JS) is still cached; only the data is not. If
* offline data replay is wanted back, it needs a per-session cache key and a
* purge on sign-out, not a URL-keyed shared cache.
*/
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
return await fetch(request);
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Drop everything except the static shell.
*
* Called when the user signs out. Belt-and-braces: nothing user-specific should
* be in the cache after the `networkFirst` change above, but a cache populated
* by an OLDER worker on this browser can still hold API responses, and that
* worker's entries survive into this one under the same name.
*/
async function purgeNonShell() {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
await Promise.all(
keys
.filter((req) => {
const p = new URL(req.url).pathname;
return p.startsWith('/api/') || p.startsWith('/health/');
})
.map((req) => cache.delete(req))
);
}
+72 -8
View File
@@ -33,10 +33,18 @@ function loadServiceWorker() {
const listeners = {};
const cachePuts = [];
// A real in-memory cache, so purge and put behaviour can be observed rather
// than assumed.
const entries = new Map();
const cacheStub = {
addAll: async () => {},
put: async (req, res) => { cachePuts.push(String(req.url ?? req)); return undefined; },
keys: async () => [],
put: async (req, res) => {
const url = String(req.url ?? req);
cachePuts.push(url);
entries.set(url, res);
},
keys: async () => Array.from(entries.keys()).map((url) => ({ url })),
delete: async (req) => entries.delete(String(req.url ?? req)),
match: async () => undefined,
};
@@ -63,7 +71,7 @@ function loadServiceWorker() {
vm.createContext(sandbox);
vm.runInContext(SW_SOURCE, sandbox);
return { listeners, cachePuts, sandbox };
return { listeners, cachePuts, sandbox, entries };
}
/**
@@ -72,17 +80,28 @@ function loadServiceWorker() {
* request goes to the network untouched, which is the only safe outcome for a
* credentialed endpoint.
*/
function route(path, { method = 'GET', mode = 'cors', headers = {} } = {}) {
const { listeners } = loadServiceWorker();
function route(path, opts = {}) {
return dispatch(path, opts).handled;
}
/** Route one request and expose everything the worker did with it. */
function dispatch(path, { method = 'GET', mode = 'cors', headers = {}, sw = null } = {}) {
const worker = sw ?? loadServiceWorker();
let handled = false;
let responded = null;
const waited = [];
const request = {
url: `${ORIGIN}${path}`,
method,
mode,
headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null },
};
listeners.fetch({ request, respondWith: () => { handled = true; } });
return handled;
worker.listeners.fetch({
request,
respondWith: (p) => { handled = true; responded = p; },
waitUntil: (p) => { waited.push(p); },
});
return { handled, responded, waited, worker };
}
let sw;
@@ -123,7 +142,8 @@ test('a navigation request is still served cache-first', () => {
assert.equal(route('/ui/', { mode: 'navigate' }), true);
});
test('API paths are still handled, so offline fallback survives', () => {
test('API paths are still routed through the worker', () => {
// Handled, but network-only — see the "not written to the cache" test below.
assert.equal(route('/api/v1/models'), true);
assert.equal(route('/health/live'), true);
});
@@ -153,6 +173,50 @@ test('cross-origin requests are ignored', () => {
assert.equal(handled, false);
});
// --- authenticated API responses must not be retained ------------------------
// Filed by the cross-vendor prosecutor in the qe-court round after the
// /oauth/status fix: closing the /oauth/ leg left the /api/ leg open.
test('a successful API response is NOT written to the cache', async () => {
// The leak: cache keys are URLs, nothing partitions them by session, and
// nothing purged them at sign-out. Sign in as A, fetch sensing data, sign
// out, sign in as B, lose the network -> B is served A's data.
const { responded, worker } = dispatch('/api/v1/sensing/latest');
await responded;
assert.deepEqual(worker.cachePuts, [], 'API responses must not be cached');
});
test('an API request with no network returns 503 rather than stale data', async () => {
// Also a correctness property, not only an authorization one: replaying a
// stale pose reading as current can show a room occupied after the person
// has left.
const worker = loadServiceWorker();
worker.sandbox.fetch = async () => { throw new Error('offline'); };
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, { stale: true });
const { responded } = dispatch('/api/v1/sensing/latest', { sw: worker });
const res = await responded;
assert.equal(res.status, 503);
assert.match(String(res.body), /offline/);
});
test('signing out purges cached API data but keeps the offline shell', async () => {
const worker = loadServiceWorker();
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, {});
worker.entries.set(`${ORIGIN}/health/live`, {});
worker.entries.set(`${ORIGIN}/app.js`, {});
const { handled, waited } = dispatch('/oauth/logout', { sw: worker });
// Observed, not intercepted — the logout request itself must still reach the
// server, or signing out would not actually sign anyone out.
assert.equal(handled, false, '/oauth/logout must still go to the network');
assert.equal(waited.length, 1, 'the purge must be kept alive via waitUntil');
await Promise.all(waited);
const left = Array.from(worker.entries.keys());
assert.deepEqual(left, [`${ORIGIN}/app.js`], 'only the static shell should survive');
});
// --- cache hygiene -----------------------------------------------------------
test('the cache name is bumped so clients holding the poisoned v1 evict it', () => {