Skip to content

Commit bf3f557

Browse files
Merge pull request #26 from NVIDIA-NeMo/security/viewer-api-authorization-and-cors
security: require auth token and restrict CORS on viewer API
2 parents 6f122cf + ba42b28 commit bf3f557

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)