mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* 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>
153 lines
5.6 KiB
Python
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"
|