* fix(security): replace persistent admin keys with sessions Exchange the remote administrator key once for bounded, revocable credentials. Canonicalize backend principals, enforce cookie CSRF and exact origins, and use path-bound one-use WebSocket tickets. Migrate the bundled UI away from durable master-key storage and credential-bearing URLs. Add unit, integration, static-hygiene, and production-browser regressions plus synchronized operator documentation. * docs: link session hardening to PR 1528 * fix(security): key session indexes with process pepper Use HMAC-SHA-256 instead of an unkeyed digest for in-memory session and WebSocket-ticket indexes. This preserves constant-size lookup identifiers, makes copied records unusable without the process pepper, and resolves CodeQL's weak sensitive-data hash finding. * fix(auth): align empty bearer migration precedence Centralize the Authorization-channel presence decision with canonical principal parsing. Bearer followed only by spaces now remains an empty channel during legacy cookie migration, while unsupported or invalid explicit credentials stay authoritative and fail closed. * fix(security): harden admin session review boundaries * fix(security): derive key generations with HKDF * fix(auth): anchor the admin-session store so module reloads cannot fork it test_master_exchange_does_not_bypass_pin_on_normal_routes failed in full-suite runs: test_mcp_bindings' client fixture purges the services.* tree from sys.modules and reloads main, so api.routers.auth re-imported a fresh services.admin_sessions (new AdminSessionStore) while core.auth kept its import-time reference to the old one — the exchange issued the cookie into one store and the middleware resolved it against another, turning the expected "PIN required" into "API key required". Root cause is the class of bug, not the one test: a process-global auth store defined as a bare module-level singleton forks under importlib.reload or purge-and-reimport. Fix at the source: admin_session_store now resolves through a synthetic sys.modules anchor (_omnivoice_admin_session_store_anchor) that reloads never re-execute and package-prefix purges never match, so every copy of the module shares the one per-process store. No consumer or behavior changes. Regression test reproduces both fork vectors (in-place reload and sys.modules purge + fresh import) and asserts previously issued sessions still resolve and the store identity is preserved; it fails before this fix and passes after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): honor X-Forwarded-Proto for CSRF origin and Secure cookies behind TLS proxies Behind Tailscale Serve (docs/remote-gpu.md) or any TLS-terminating proxy, the browser talks https while the backend hop stays http, so exact-origin CSRF compared an https Origin against an http expectation and rejected every legitimate request, and the session cookie shipped without Secure. uvicorn's ProxyHeadersMiddleware only rewrites the scope for loopback peers, which misses Docker and any non-loopback proxy topology. New core.csrf.effective_scheme derives the client-facing scheme: resolved scope first (uvicorn's trusted-proxy rewrite wins), then an upgrade-only read of X-Forwarded-Proto's first value — https/wss promotes http to https, everything else is ignored, and a genuine TLS hop can never be downgraded. Used by both the destination-origin comparison and auth._secure_cookie so the WS-ticket/logout CSRF paths and the cookie Secure flag agree. Spoofing gains nothing: the host:port half of the origin tuple is untouched, browsers cannot attach the header cross-site without a preflight this API never grants, and forging it on plain http only adds Secure (the browser then drops the cookie — self-harm only). Regression tests: proxied https origin accepted (origin check, Secure flag, logout), comma-separated chains, scope-fallback path, spoofed header still rejects cross-origin, cannot downgrade real https, junk values ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): consume the stored admin key only after a successful exchange A remote-backend user upgrading with their backend unreachable lost the only stored copy of OMNIVOICE_API_KEY: every migration path deleted the durable ov_api_key BEFORE the session exchange settled, stranding them until they recovered the key from the server box. Close the whole class: - client.ts bootstrap: read the legacy key, exchange first, and remove the durable copy only after the exchange succeeds; on failure the key stays so the next launch retries the migration (auth gate still rises). - authSession.ts exchangeApiKey: move removeLegacyMaster from before the fetch to the cookie/bearer success paths — the key never coexists with a live session, but a rejected or hung exchange no longer consumes it. - remoteBackendProbe.ts configuredRemoteBackend: stop wiping the key on every app mount. - RemoteBackendPanel: a connection test or an aborted save no longer wipes the pending key; only disabling the remote backend discards it. - prefKeys.js: ov_api_key moves from PREF_KEYS to PRESERVED_KEYS — factory reset preserves the pending connection credential exactly like ov_backend_url; the successful migration is what deletes it. Tighten the credential-hygiene static guard to match: it accepted sessionStorage.setItem('ov_api_key', …) — the exact class it exists to close. The guard now flags .setItem(<master key>) on any storage receiver, quote style, or injected-store alias, with a self-test pinning what it catches and what stays legal. Fail-before/pass-after regression tests: backend unreachable retains the key and the next bootstrap retries it; a successful exchange removes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(auth): make session validation occupancy-independent * test(auth): catch optional master-key storage calls * feat(docs): add PR control document for bultodepapas in VoiceStudio * docs: keep the PR tracking board in the fork; credit the changelog line The pr-control document is excellent process discipline, but it is the contributor's own operational board (their inventory, their update commands) — it lives naturally in their fork, and docs/agents/ here is context every repo agent loads. Removed with appreciation; the changelog line gains its contributor credit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
178 lines
6.4 KiB
Python
178 lines
6.4 KiB
Python
# tests/test_network_middleware.py
|
|
from fastapi.testclient import TestClient
|
|
from services import network_share as ns
|
|
|
|
|
|
def _app_with_pin(pin="123456"):
|
|
from main import app
|
|
app.state.network_share = ns.ShareState(enabled=True, share_port=3901, pin=pin, lan_addresses=["10.0.0.9"])
|
|
return app
|
|
|
|
|
|
def teardown_function():
|
|
from main import app
|
|
from services.admin_sessions import admin_session_store
|
|
|
|
app.state.network_share = ns.ShareState() # reset → middleware inert
|
|
admin_session_store.clear()
|
|
|
|
|
|
def test_inert_when_no_pin():
|
|
from main import app
|
|
app.state.network_share = ns.ShareState() # no pin
|
|
c = TestClient(app, client=("10.0.0.5", 1)) # non-loopback
|
|
assert c.get("/health").status_code == 200
|
|
|
|
|
|
def test_loopback_bypasses_pin():
|
|
c = TestClient(_app_with_pin(), client=("127.0.0.1", 1))
|
|
assert c.get("/system/info").status_code == 200 # loopback → ok
|
|
|
|
|
|
def test_non_loopback_without_pin_401_on_api():
|
|
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
|
|
r = c.get("/api/voices") # any non-shell API path
|
|
assert r.status_code in (401,) # PIN required
|
|
|
|
|
|
def test_non_loopback_with_valid_pin_passes():
|
|
c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
|
|
r = c.get("/api/voices", headers={"X-OmniVoice-Pin": "654321"})
|
|
assert r.status_code != 401
|
|
|
|
|
|
def test_non_ascii_invalid_pin_fails_closed_instead_of_raising():
|
|
c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
|
|
|
|
response = c.get("/api/voices", params={"pin": "clé-incorrecte"})
|
|
|
|
assert response.status_code == 401
|
|
assert response.json() == {"detail": "PIN required"}
|
|
|
|
|
|
def test_spa_shell_served_without_pin():
|
|
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
|
|
assert c.get("/health").status_code == 200
|
|
|
|
|
|
def test_session_exchange_reaches_its_master_key_guard_before_the_pin_gate(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "master-key")
|
|
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
|
|
|
|
response = c.post(
|
|
"/api/auth/session",
|
|
json={"transport": "cookie"},
|
|
headers={"Authorization": "Bearer wrong"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert response.json() == {"detail": "API key required"}
|
|
|
|
|
|
def test_master_exchange_does_not_bypass_pin_on_normal_routes(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "master-key")
|
|
c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
|
|
|
|
issued = c.post(
|
|
"/api/auth/session",
|
|
json={"transport": "cookie"},
|
|
headers={"Authorization": "Bearer master-key"},
|
|
)
|
|
|
|
assert issued.status_code == 204
|
|
without_pin = c.get("/api/voices")
|
|
assert without_pin.status_code == 401
|
|
assert without_pin.json() == {"detail": "PIN required"}
|
|
with_both = c.get("/api/voices", headers={"X-OmniVoice-Pin": "654321"})
|
|
assert with_both.status_code not in {401, 403}
|
|
|
|
|
|
def test_cors_wraps_both_auth_gates_and_answers_credentialless_preflight(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "master-key")
|
|
c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
|
|
cors = {
|
|
"Origin": "tauri://localhost",
|
|
"Access-Control-Request-Method": "GET",
|
|
"Access-Control-Request-Headers": "authorization,x-omnivoice-pin",
|
|
}
|
|
|
|
preflight = c.options("/api/voices", headers=cors)
|
|
rejected = c.get("/api/voices", headers={"Origin": "tauri://localhost"})
|
|
|
|
assert preflight.status_code == 200
|
|
assert preflight.headers["access-control-allow-origin"] == "tauri://localhost"
|
|
assert "authorization" in preflight.headers["access-control-allow-headers"].lower()
|
|
assert rejected.status_code == 401
|
|
assert rejected.headers["access-control-allow-origin"] == "tauri://localhost"
|
|
|
|
|
|
def test_middleware_is_plain_asgi_not_buffering():
|
|
# A pure ASGI middleware (class with __call__(scope, receive, send)) does
|
|
# NOT subclass starlette's BaseHTTPMiddleware, which buffers streaming
|
|
# responses. Guard against a regression back to the buffering base class.
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from main import NetworkAccessMiddleware
|
|
|
|
assert not issubclass(NetworkAccessMiddleware, BaseHTTPMiddleware)
|
|
assert callable(getattr(NetworkAccessMiddleware, "__call__", None))
|
|
|
|
|
|
def test_streaming_response_passes_through_with_valid_pin():
|
|
# A PIN'd, non-loopback request to a StreamingResponse route must stream
|
|
# chunk-by-chunk, not be collected into one buffered body. We mount a tiny
|
|
# streaming route on a fresh app wrapped with the real middleware and
|
|
# confirm the response arrives chunked (multiple yields concatenated).
|
|
from fastapi import FastAPI
|
|
from starlette.responses import StreamingResponse
|
|
from main import NetworkAccessMiddleware
|
|
|
|
app = FastAPI()
|
|
app.add_middleware(NetworkAccessMiddleware)
|
|
app.state.network_share = ns.ShareState(
|
|
enabled=True, share_port=3901, pin="777888", lan_addresses=["10.0.0.9"]
|
|
)
|
|
|
|
@app.get("/stream")
|
|
def stream():
|
|
def gen():
|
|
for i in range(5):
|
|
yield f"chunk-{i}\n"
|
|
|
|
return StreamingResponse(gen(), media_type="text/plain")
|
|
|
|
c = TestClient(app, client=("10.0.0.5", 1))
|
|
# Without the PIN, the stream route is gated.
|
|
assert c.get("/stream").status_code == 401
|
|
# With the PIN, it streams the full body through the ASGI middleware.
|
|
r = c.get("/stream", headers={"X-OmniVoice-Pin": "777888"})
|
|
assert r.status_code == 200
|
|
body = r.text
|
|
for i in range(5):
|
|
assert f"chunk-{i}" in body
|
|
# Streaming responses carry no precomputed Content-Length — a buffering
|
|
# middleware would re-materialise the body and set one.
|
|
assert "content-length" not in {k.lower() for k in r.headers}
|
|
|
|
|
|
def test_valid_pin_sets_cookie_via_asgi():
|
|
from fastapi import FastAPI
|
|
from main import NetworkAccessMiddleware
|
|
|
|
app = FastAPI()
|
|
app.add_middleware(NetworkAccessMiddleware)
|
|
app.state.network_share = ns.ShareState(
|
|
enabled=True, share_port=3901, pin="424242", lan_addresses=["10.0.0.9"]
|
|
)
|
|
|
|
@app.get("/api/ping")
|
|
def ping():
|
|
return {"ok": True}
|
|
|
|
c = TestClient(app, client=("10.0.0.5", 1))
|
|
r = c.get("/api/ping", headers={"X-OmniVoice-Pin": "424242"})
|
|
assert r.status_code == 200
|
|
# The ASGI send-wrapper injects Set-Cookie on the first valid-PIN request
|
|
# (when the cookie isn't already present).
|
|
set_cookie = r.headers.get("set-cookie", "")
|
|
assert "ov_pin=424242" in set_cookie
|