mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
89cceaf835
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
189 lines
6.0 KiB
JavaScript
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.) */ } |