Files
semantica/tests/explorer/test_explorer_auth.py
T
Mohd KaifandZohaib Hassnain 3496d62335 security: require API-key auth on all Explorer API routes (GHSA-j4mq) (#909)
* security: require API-key auth on all Explorer API routes (GHSA-j4mq-hprp-987v)

Every Explorer route (bulk import/export, delete, LLM-backed ontology
generation, SPARQL, etc.) was mounted with no authentication, and both
server entrypoints bind 0.0.0.0 by default. Anyone reaching the port got
full read/write/delete on the graph.

- Add require_auth dependency (explorer/dependencies.py): checks
  X-API-Key against SEMANTICA_API_KEY, fails closed with 503 if
  unconfigured (not silently anonymous), 401 on wrong/missing key.
  SEMANTICA_ALLOW_ANONYMOUS=true opts out explicitly for local dev.
- Wire dependencies=[Depends(require_auth)] into all 11 API routers in
  both explorer/app.py and server.py. /health, /api/info, static assets,
  and the SPA catch-all stay public.
- /ws/graph-updates handshake now checks the same key via header or
  ?api_key= query param (browsers can't set custom WS headers) before
  accepting the connection.
- Default bind changed from 0.0.0.0 to 127.0.0.1 in server.py's main()
  and cli.py's `server start`; the CLI warns if a non-loopback host is
  passed explicitly without a key configured.
- Startup logging reports the resolved auth mode in both app factories.
- Document/generate SEMANTICA_API_KEY in the deploy recipes that expose
  a public endpoint by default: docker-compose, Railway, Fly, Render.

Added tests/explorer/test_explorer_auth.py covering fail-closed default,
wrong/missing/correct key, anonymous opt-in, public-route exemptions, and
the WS handshake. Added tests/explorer/conftest.py defaulting the
pre-existing ~200 explorer tests to SEMANTICA_ALLOW_ANONYMOUS=true so
they keep exercising route logic without needing a key.

* fix CORS

---------

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-08-11 14:05:00 +05:00

153 lines
5.6 KiB
Python

"""Tests for the API-key auth dependency added for GHSA-j4mq-hprp-987v
(missing authentication on all Explorer API routes).
Covers: protected routes refuse requests when no key is configured (fail
closed, not fail open), reject wrong/missing keys once a key is
configured, accept the correct key, remain reachable when
SEMANTICA_ALLOW_ANONYMOUS=true is set explicitly, and that health/info/
static routes stay public regardless. Also covers the /ws/graph-updates
handshake, which can't use the same FastAPI Depends() plumbing since
browsers can't set custom headers on a WebSocket handshake.
"""
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
def _build_sample_graph() -> ContextGraph:
graph = ContextGraph(advanced_analytics=False)
graph.add_node("python", node_type="language", content="Python")
return graph
@pytest.fixture
def client():
session = GraphSession(_build_sample_graph())
app = create_app(session=session)
with TestClient(app) as test_client:
yield test_client
# ---------------------------------------------------------------------------
# Fail-closed: no SEMANTICA_API_KEY and no explicit anonymous opt-in.
# ---------------------------------------------------------------------------
def test_protected_route_returns_503_when_auth_not_configured(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
resp = client.get("/api/graph/nodes")
assert resp.status_code == 503
def test_write_route_also_refuses_when_auth_not_configured(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
resp = client.post("/api/export", json={"format": "json"})
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# Configured key: wrong/missing key rejected, correct key accepted.
# ---------------------------------------------------------------------------
def test_protected_route_rejects_missing_key(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
resp = client.get("/api/graph/nodes")
assert resp.status_code == 401
def test_protected_route_rejects_wrong_key(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
resp = client.get("/api/graph/nodes", headers={"X-API-Key": "wrong-key"})
assert resp.status_code == 401
def test_protected_route_accepts_correct_key(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
resp = client.get("/api/graph/nodes", headers={"X-API-Key": "correct-key"})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Explicit opt-in: SEMANTICA_ALLOW_ANONYMOUS=true.
# ---------------------------------------------------------------------------
def test_anonymous_opt_in_allows_requests_without_a_key(client, monkeypatch):
monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true")
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
resp = client.get("/api/graph/nodes")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Public routes stay public regardless of auth configuration.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", ["/api/health", "/api/info"])
def test_public_routes_stay_public_when_auth_not_configured(client, monkeypatch, path):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
resp = client.get(path)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# WebSocket handshake: header or query-param key, same policy as REST.
# ---------------------------------------------------------------------------
def test_websocket_rejects_connection_without_key_when_configured(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
with pytest.raises(Exception):
with client.websocket_connect("/ws/graph-updates"):
pass
def test_websocket_accepts_connection_with_correct_query_param_key(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
with client.websocket_connect("/ws/graph-updates?api_key=correct-key") as websocket:
ack = websocket.receive_json()
assert ack["event"] == "connection_ack"
def test_websocket_accepts_connection_with_header_key(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
with client.websocket_connect(
"/ws/graph-updates", headers={"X-API-Key": "correct-key"}
) as websocket:
ack = websocket.receive_json()
assert ack["event"] == "connection_ack"