Skip to content

Commit ba42b28

Browse files
security: require auth token and restrict CORS on viewer API
The viewer exposed trace, evaluation, memory, annotation, explorer, OTLP and journal APIs with no authorization while listening on all interfaces, and its CORS policy allowed every origin, method and header. Add a `_require_viewer_authorization` dependency on every API router and every `/v1` ingest/sync endpoint. When `NOOA_VIEWER_AUTH_TOKEN` is set, requests must send `Authorization: Bearer <token>`, compared with `hmac.compare_digest()`. When it is unset, loopback clients keep working so existing local development and ingest clients are unaffected, while non-loopback clients get a 403. Restrict CORS to `http://localhost:5001` and `http://127.0.0.1:5001` by default, overridable via a comma-separated `NOOA_VIEWER_CORS_ORIGINS`, and narrow allowed methods and headers to the ones the viewer APIs use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6f122cf commit ba42b28

2 files changed

Lines changed: 81 additions & 16 deletions

File tree

src/nooa/viewer/main.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
"""
99

1010
import asyncio
11+
import hmac
12+
import ipaddress
1113
import logging
1214
import os
1315
import time
1416
from concurrent.futures import ThreadPoolExecutor
1517
from contextlib import asynccontextmanager
1618

1719
from dotenv import load_dotenv
18-
from fastapi import FastAPI, Request
20+
from fastapi import Depends, FastAPI, HTTPException, Request
1921
from fastapi.middleware.cors import CORSMiddleware
2022
from fastapi.responses import FileResponse, JSONResponse
2123
from starlette.requests import ClientDisconnect
@@ -146,27 +148,63 @@ async def lifespan(app: FastAPI):
146148

147149
app = FastAPI(title="NVIDIA OO Agents Viewer", version="2.0.0", lifespan=lifespan)
148150

151+
152+
def _require_viewer_authorization(request: Request) -> None:
153+
"""Authorize a protected viewer API route when an auth token is configured."""
154+
expected = os.environ.get("NOOA_VIEWER_AUTH_TOKEN")
155+
if not expected:
156+
client_host = request.client.host if request.client else ""
157+
try:
158+
is_loopback = ipaddress.ip_address(client_host).is_loopback
159+
except ValueError:
160+
# Starlette's in-process test transport uses this non-network host.
161+
is_loopback = client_host == "testclient"
162+
if is_loopback:
163+
return
164+
raise HTTPException(
165+
status_code=403,
166+
detail="set NOOA_VIEWER_AUTH_TOKEN before exposing the viewer",
167+
)
168+
169+
authorization = request.headers.get("Authorization", "")
170+
if not hmac.compare_digest(authorization, f"Bearer {expected}"):
171+
raise HTTPException(
172+
status_code=401,
173+
detail="viewer authorization required",
174+
headers={"WWW-Authenticate": "Bearer"},
175+
)
176+
177+
178+
_cors_origins = [
179+
origin.strip()
180+
for origin in os.environ.get(
181+
"NOOA_VIEWER_CORS_ORIGINS", "http://localhost:5001,http://127.0.0.1:5001"
182+
).split(",")
183+
if origin.strip()
184+
]
185+
_protected = [Depends(_require_viewer_authorization)]
186+
149187
app.add_middleware(
150188
CORSMiddleware,
151-
allow_origins=["*"],
189+
allow_origins=_cors_origins,
152190
allow_credentials=True,
153-
allow_methods=["*"],
154-
allow_headers=["*"],
191+
allow_methods=["GET", "POST", "PATCH", "DELETE"],
192+
allow_headers=["Authorization", "Content-Type", "X-Session-Id"],
155193
)
156194

157-
app.include_router(trace_router)
158-
app.include_router(eval_router)
159-
app.include_router(annotation_router)
160-
app.include_router(explorer_router)
161-
app.include_router(memory_router)
195+
app.include_router(trace_router, dependencies=_protected)
196+
app.include_router(eval_router, dependencies=_protected)
197+
app.include_router(annotation_router, dependencies=_protected)
198+
app.include_router(explorer_router, dependencies=_protected)
199+
app.include_router(memory_router, dependencies=_protected)
162200

163201

164202
# ============================================================================
165203
# OTLP ingest endpoint
166204
# ============================================================================
167205

168206

169-
@app.post("/v1/traces")
207+
@app.post("/v1/traces", dependencies=_protected)
170208
async def otlp_ingest(request: Request):
171209
"""Accept OTLP JSON ExportTraceServiceRequest and queue for async SQLite write.
172210
@@ -201,7 +239,7 @@ async def otlp_ingest(request: Request):
201239
# ============================================================================
202240

203241

204-
@app.post("/v1/sync")
242+
@app.post("/v1/sync", dependencies=_protected)
205243
async def sync_ingest():
206244
"""Block until the ingest queue is fully drained and all spans are in SQLite.
207245
@@ -228,7 +266,7 @@ async def sync_ingest():
228266
# ============================================================================
229267

230268

231-
@app.post("/v1/journal/messages")
269+
@app.post("/v1/journal/messages", dependencies=_protected)
232270
async def journal_messages_ingest(request: Request):
233271
"""Accept a batch of content-addressed message records.
234272
@@ -269,7 +307,7 @@ async def journal_messages_ingest(request: Request):
269307
return JSONResponse(content=result)
270308

271309

272-
@app.post("/v1/journal/calls")
310+
@app.post("/v1/journal/calls", dependencies=_protected)
273311
async def journal_call_ingest(request: Request):
274312
"""Accept a single LLM call record with input/output hash lists.
275313
@@ -309,7 +347,7 @@ async def journal_call_ingest(request: Request):
309347
return JSONResponse(content=result)
310348

311349

312-
@app.post("/v1/journal/blocks")
350+
@app.post("/v1/journal/blocks", dependencies=_protected)
313351
async def journal_blocks_ingest(request: Request):
314352
"""Accept a batch of content-addressed message blocks for a session.
315353
@@ -363,7 +401,7 @@ async def journal_blocks_ingest(request: Request):
363401
return JSONResponse(content=result)
364402

365403

366-
@app.get("/api/traces/{session_id:path}/calls")
404+
@app.get("/api/traces/{session_id:path}/calls", dependencies=_protected)
367405
def get_session_calls(session_id: str):
368406
"""Return all LLM calls for a session with fully reconstructed messages."""
369407
if not otlp_store.session_exists(session_id):
@@ -376,7 +414,7 @@ def get_session_calls(session_id: str):
376414
# ============================================================================
377415

378416

379-
@app.post("/api/refresh")
417+
@app.post("/api/refresh", dependencies=_protected)
380418
def refresh_all():
381419
"""Return current store stats."""
382420
stats = otlp_store.get_stats()

tests/viewer/test_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,33 @@ def client(tmp_path, monkeypatch):
130130
del otlp_store._write_tls.conn
131131

132132

133+
def test_api_routes_require_configured_bearer_token(client, monkeypatch):
134+
monkeypatch.setenv("NOOA_VIEWER_AUTH_TOKEN", "test-viewer-token")
135+
136+
read_response = client.get("/api/version")
137+
write_response = client.post("/api/refresh")
138+
assert read_response.status_code == 401
139+
assert write_response.status_code == 401
140+
assert read_response.headers["www-authenticate"] == "Bearer"
141+
142+
authorized = client.get(
143+
"/api/version", headers={"Authorization": "Bearer test-viewer-token"}
144+
)
145+
assert authorized.status_code == 200
146+
147+
148+
def test_cors_rejects_unconfigured_origin(client):
149+
response = client.options(
150+
"/api/version",
151+
headers={
152+
"Origin": "https://attacker.example",
153+
"Access-Control-Request-Method": "GET",
154+
},
155+
)
156+
assert response.status_code == 400
157+
assert "access-control-allow-origin" not in response.headers
158+
159+
133160
def _seed_session(db: sqlite3.Connection, session_id: str = "sess1") -> None:
134161
db.execute(
135162
"INSERT INTO sessions (session_id, experiment, span_count, modified) VALUES (?, 'default', 0, 0)",

0 commit comments

Comments
 (0)