Files
ruvnet--RuView/ui/services/api.service.js
T
Dragan Spiridonov 89cceaf835 fix(auth): close P1/P2/P3 — JWKS stall, 12h session, cookie shadowing
All three deferred findings from the qe-court round. Each fix is guarded by a
test confirmed to FAIL against the old behaviour.

P1 — JWKS: self-inflicted stall, and a blocking fetch on a tokio worker.

`fetched_at` advances only on SUCCESS, and the only rate limiter sat behind
`if fresh`. So once the TTL elapsed after the last successful fetch, `fresh`
was permanently false, the limiter was never consulted, and EVERY request
performed its own blocking 3s-timeout fetch. A Pi that loses WAN stalled
itself 300s later with no attacker present; an attacker could force the same
state by flooding tokens with an unknown `kid`.

Now: `last_attempt_at` is recorded BEFORE every fetch regardless of outcome,
and gates the stale path too; a stale-but-present key is served rather than
erroring, which is the offline tolerance this module always claimed.
Measured by the new test: 26 outbound fetches before, 1 after.

Kept as TWO independent limiters. Merging them looks tidy and is wrong — a
routine refetch would then suppress the unknown-`kid` path for 30s and delay
pickup of a key rotation inside the TTL. I made that mistake first; two
existing tests caught it.

The blocking call also now runs in `spawn_blocking` at the verify boundary,
matching what `main.rs` already does for the token exchange, where the comment
reads "the same mistake this codebase had to fix in jwks.rs". The hot
verification path had never been given the same treatment. A panicked task
fails closed.

P2 — session lifetime, per decision: 1 hour, plus step-up.

SESSION_TTL_SECS 12h -> 1h, and privileged (`sensing:admin`) actions now
require the user to have authenticated within ADMIN_REVERIFY_SECS (5 min),
tracked by a new `auth_time` claim. Reads ride the full session; only the
routes where a stale session does damage are re-verified, so a dashboard whose
main use is watching a live stream does not re-auth hourly.

`auth_time` is `#[serde(default)]`, so a cookie issued before the field existed
reads as 0 — infinitely stale. Such a session keeps working for reads and
cannot perform privileged actions. Fail-closed and self-healing on next sign-in.

The refusal carries an RFC 6750 `WWW-Authenticate` error code, because the
client's correct response differs from a plain 401: the user IS signed in and
needs to prove it again. `api.service.js` acts on that and redirects through
`/oauth/start` — otherwise a stale-session delete surfaces as a generic
"Request failed" with no hint that signing in again fixes it.

P3 — cookie shadowing.

`read_cookie` returned the FIRST match, and RFC 6265 §5.4 sends longer-`Path`
cookies first. Cookies are not isolated by port or scheme, so any other service
on the host — or a plain-HTTP MITM injecting Set-Cookie — could plant
`ruview_session=<their own validly signed session>; Path=/ui`. The victim sent
both, the attacker's first, and it verified because it genuinely was signed:
silent session takeover, with `/oauth/status` reporting the attacker's account.

The signature was doing its job throughout, which is why "it's signed" never
answered this. `__Host-` would, but requires `Secure`, and RuView is routinely
reached over plain HTTP on a LAN.

So both credential paths now accept only when EXACTLY ONE candidate verifies.
An attacker can still cause a refusal by planting a second valid cookie — a
nuisance — but no longer a takeover. Planting junk changes nothing, so this
does not become a trivial DoS.

Tests: +1 jwks (26-vs-1 fetch amplification), +4 step-up, +4 shadowing, +1
duplicate-name reader. Mutation-verified: reverting the stale-path guard gives
26 fetches; reverting to first-match cookie reads fails
`a_shadowing_cookie_cannot_silently_take_over_the_session`.

Verified: workspace 176 suites clean under CI flags, ruview-auth 62+25+2 with
--all-features, UI 22.

ADR-271: P1/P2/P3 marked RESOLVED with the analysis retained, since it explains
why each fix has the shape it does.

Co-Authored-By: Ruflo & AQE
2026-07-23 13:13:42 +02:00

189 lines
6.0 KiB
JavaScript

// API Service for WiFi-DensePose UI
import { API_CONFIG, buildApiUrl } from '../config/api.config.js';
import { backendDetector } from '../utils/backend-detector.js';
export class ApiService {
constructor() {
this.authToken = null;
this.requestInterceptors = [];
this.responseInterceptors = [];
}
// Set authentication token
setAuthToken(token) {
this.authToken = token;
}
// Add request interceptor
addRequestInterceptor(interceptor) {
this.requestInterceptors.push(interceptor);
}
// Add response interceptor
addResponseInterceptor(interceptor) {
this.responseInterceptors.push(interceptor);
}
// Build headers for requests
getHeaders(customHeaders = {}) {
const headers = {
...API_CONFIG.DEFAULT_HEADERS,
...customHeaders
};
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
return headers;
}
// Process request through interceptors
async processRequest(url, options) {
let processedUrl = url;
let processedOptions = options;
for (const interceptor of this.requestInterceptors) {
const result = await interceptor(processedUrl, processedOptions);
processedUrl = result.url || processedUrl;
processedOptions = result.options || processedOptions;
}
return { url: processedUrl, options: processedOptions };
}
// Process response through interceptors
async processResponse(response, url) {
let processedResponse = response;
for (const interceptor of this.responseInterceptors) {
processedResponse = await interceptor(processedResponse, url);
}
return processedResponse;
}
// Generic request method
async request(url, options = {}) {
try {
// Process request through interceptors
const processed = await this.processRequest(url, options);
// Determine the correct base URL (real backend vs mock)
let finalUrl = processed.url;
if (processed.url.startsWith(API_CONFIG.BASE_URL)) {
const baseUrl = await backendDetector.getBaseUrl();
finalUrl = processed.url.replace(API_CONFIG.BASE_URL, baseUrl);
}
// Make the request
const response = await fetch(finalUrl, {
...processed.options,
headers: this.getHeaders(processed.options.headers)
});
// Process response through interceptors
const processedResponse = await this.processResponse(response, url);
// Step-up re-authentication (ADR-271 P2).
//
// A browser session outlives the ~15-minute access token that created it,
// and Cognitum publishes no introspection endpoint, so the server refuses
// PRIVILEGED actions from a session older than a few minutes. That is a
// 401, but it means something different from "you are not signed in" —
// the user IS signed in, and the fix is to prove it again. The server
// marks it with an RFC 6750 error code so the two are distinguishable.
//
// Without this branch a stale-session delete surfaces as a generic
// "Request failed" and the user has no way to know that signing in again
// resolves it.
if (processedResponse.status === 401) {
const challenge = processedResponse.headers.get('WWW-Authenticate') || '';
if (challenge.includes('reauthentication required')) {
// Full-page redirect: the flow ends by setting a cookie, which an
// XHR cannot do usefully. Returns here with a fresh auth_time and,
// while the Cognitum session is alive, no prompt.
window.location.href = '/oauth/start';
// Never settles — the navigation is already underway, and resolving
// would let the caller render an error for an operation that is
// simply being retried after sign-in.
return new Promise(() => {});
}
}
// Handle errors
if (!processedResponse.ok) {
const error = await processedResponse.json().catch(() => ({
message: `HTTP ${processedResponse.status}: ${processedResponse.statusText}`
}));
throw new Error(error.message || error.detail || 'Request failed');
}
// Parse JSON response
const data = await processedResponse.json().catch(() => null);
return data;
} catch (error) {
// Only log if not a connection refusal (expected when DensePose API is down)
if (error.message && !error.message.includes('Failed to fetch')) {
console.error('API Request Error:', error);
}
throw error;
}
}
// GET request
async get(endpoint, params = {}, options = {}) {
const url = buildApiUrl(endpoint, params);
return this.request(url, {
method: 'GET',
...options
});
}
// POST request
async post(endpoint, data = {}, options = {}) {
const url = buildApiUrl(endpoint);
return this.request(url, {
method: 'POST',
body: JSON.stringify(data),
...options
});
}
// PUT request
async put(endpoint, data = {}, options = {}) {
const url = buildApiUrl(endpoint);
return this.request(url, {
method: 'PUT',
body: JSON.stringify(data),
...options
});
}
// DELETE request
async delete(endpoint, options = {}) {
const url = buildApiUrl(endpoint);
return this.request(url, {
method: 'DELETE',
...options
});
}
}
// Create singleton instance
export const apiService = new ApiService();
// Storage key shared with the QuickSettings "API Access" panel.
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
// Apply a previously-saved bearer token at module load — before app init
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
// the very first /api/v1/* call. The server only ever checks the
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
// never puts the token in a URL query string.
try {
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
if (storedToken) apiService.setAuthToken(storedToken);
} catch { /* storage unavailable (private browsing etc.) */ }