273 lines
9.1 KiB
Python
273 lines
9.1 KiB
Python
"""Tests for credential-gated configuration disclosure on ``GET /health``.
|
|
|
|
Issue #3294: ``/health`` is whitelisted by default so it answers unauthenticated
|
|
liveness probes (HTTP 200). It must therefore reveal sensitive runtime
|
|
configuration (filesystem paths, LLM/embedding provider + model + host, storage
|
|
backends, queue status, ...) ONLY to authenticated callers. These tests pin
|
|
that behavior across the three authentication modes:
|
|
|
|
- fully open (no AUTH_ACCOUNTS, no API key): everything is open anyway, so the
|
|
full configuration is returned to every caller.
|
|
- password auth (AUTH_ACCOUNTS): only a valid non-guest JWT (or a configured
|
|
API key) unlocks the configuration; anonymous probes get liveness only.
|
|
- API-key-only: only a valid X-API-Key unlocks the configuration.
|
|
|
|
In every case ``/health`` returns HTTP 200 so external liveness probes keep
|
|
working.
|
|
"""
|
|
|
|
import sys
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from fastapi import APIRouter
|
|
from fastapi.testclient import TestClient
|
|
|
|
# Fields that must NEVER appear in an unauthenticated response.
|
|
_SENSITIVE_TOP_LEVEL = ("working_directory", "input_directory", "configuration")
|
|
# Liveness fields that must always be present (safe; already exposed by the
|
|
# unauthenticated /auth-status endpoint or pure liveness signals).
|
|
_LIVENESS_FIELDS = ("status", "auth_mode", "core_version", "api_version")
|
|
|
|
|
|
_ENV_VARS_TO_ISOLATE = (
|
|
"LLM_BINDING",
|
|
"EMBEDDING_BINDING",
|
|
"AUTH_ACCOUNTS",
|
|
"TOKEN_SECRET",
|
|
"LIGHTRAG_API_KEY",
|
|
"WHITELIST_PATHS",
|
|
"LIGHTRAG_API_PREFIX",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_env(monkeypatch):
|
|
"""Keep tests hermetic from developer-local .env and global config state."""
|
|
for var in _ENV_VARS_TO_ISOLATE:
|
|
monkeypatch.delenv(var, raising=False)
|
|
monkeypatch.setenv("AUTH_ACCOUNTS", "")
|
|
monkeypatch.setenv("LIGHTRAG_API_KEY", "")
|
|
monkeypatch.setenv("TOKEN_SECRET", "")
|
|
monkeypatch.setenv("WHITELIST_PATHS", "/health,/api/*")
|
|
monkeypatch.setenv("LLM_BINDING", "ollama")
|
|
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
|
|
|
|
import lightrag.api.config as config
|
|
|
|
config._global_args = None
|
|
config._initialized = False
|
|
yield
|
|
config._global_args = None
|
|
config._initialized = False
|
|
|
|
|
|
class _FakeLightRAG:
|
|
"""Minimal stand-in implementing the async surface /health touches."""
|
|
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def register_role_llm_builder(self, _builder):
|
|
return None
|
|
|
|
def set_role_llm_metadata(self, _role, **_metadata):
|
|
return None
|
|
|
|
def get_llm_role_config(self):
|
|
return {}
|
|
|
|
async def get_llm_queue_status(self, include_base=True):
|
|
return {}
|
|
|
|
async def get_embedding_queue_status(self):
|
|
return {}
|
|
|
|
async def get_rerank_queue_status(self):
|
|
return {}
|
|
|
|
|
|
class _FakeOllamaAPI:
|
|
def __init__(self, *_args, **_kwargs):
|
|
self.router = APIRouter()
|
|
|
|
|
|
def _build_client(monkeypatch, *, api_key=None):
|
|
"""Build a /health-capable TestClient with all backend I/O mocked out."""
|
|
from lightrag.api.config import parse_args, initialize_config
|
|
|
|
original_argv = sys.argv.copy()
|
|
try:
|
|
sys.argv = ["lightrag-server"]
|
|
args = parse_args()
|
|
finally:
|
|
sys.argv = original_argv
|
|
if api_key is not None:
|
|
args.key = api_key
|
|
initialize_config(args, force=True)
|
|
|
|
import lightrag.api.lightrag_server as lightrag_server
|
|
|
|
monkeypatch.setattr(lightrag_server, "LightRAG", _FakeLightRAG)
|
|
monkeypatch.setattr(lightrag_server, "check_frontend_build", lambda: (True, False))
|
|
monkeypatch.setattr(
|
|
lightrag_server, "create_document_routes", lambda *_a, **_k: APIRouter()
|
|
)
|
|
monkeypatch.setattr(
|
|
lightrag_server, "create_query_routes", lambda *_a, **_k: APIRouter()
|
|
)
|
|
monkeypatch.setattr(
|
|
lightrag_server, "create_graph_routes", lambda *_a, **_k: APIRouter()
|
|
)
|
|
monkeypatch.setattr(lightrag_server, "OllamaAPI", _FakeOllamaAPI)
|
|
monkeypatch.setattr(
|
|
lightrag_server, "get_namespace_data", AsyncMock(return_value={"busy": False})
|
|
)
|
|
monkeypatch.setattr(lightrag_server, "get_default_workspace", lambda: "default")
|
|
monkeypatch.setattr(
|
|
lightrag_server,
|
|
"cleanup_keyed_lock",
|
|
lambda: {"cleanup_performed": {}, "current_status": {}},
|
|
)
|
|
|
|
app = lightrag_server.create_app(args)
|
|
return TestClient(app)
|
|
|
|
|
|
def _set_auth_mode(monkeypatch, *, auth_configured):
|
|
"""Override the module-level auth flags the /health gate reads at runtime.
|
|
|
|
Also pin a whitelist that exempts /health so combined_auth keeps returning
|
|
200 for anonymous callers (the gate, not combined_auth, hides the config).
|
|
"""
|
|
import lightrag.api.utils_api as utils_api
|
|
|
|
monkeypatch.setattr(utils_api, "auth_configured", auth_configured)
|
|
monkeypatch.setattr(
|
|
utils_api, "whitelist_patterns", [("/health", False), ("/api", True)]
|
|
)
|
|
|
|
|
|
def _assert_liveness_only(body):
|
|
for field in _LIVENESS_FIELDS:
|
|
assert field in body, f"liveness field {field!r} missing"
|
|
for field in _SENSITIVE_TOP_LEVEL:
|
|
assert field not in body, f"sensitive field {field!r} leaked"
|
|
|
|
|
|
def _assert_full_config(body):
|
|
assert "configuration" in body
|
|
assert "working_directory" in body
|
|
assert "llm_binding" in body["configuration"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fully open mode: everything is open, so config is returned to everyone.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_open_mode_returns_full_config_to_anonymous(monkeypatch):
|
|
client = _build_client(monkeypatch)
|
|
_set_auth_mode(monkeypatch, auth_configured=False)
|
|
|
|
resp = client.get("/health")
|
|
|
|
assert resp.status_code == 200
|
|
_assert_full_config(resp.json())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Password auth: anonymous gets liveness only; a valid token unlocks config.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_password_mode_anonymous_gets_liveness_only(monkeypatch):
|
|
client = _build_client(monkeypatch)
|
|
_set_auth_mode(monkeypatch, auth_configured=True)
|
|
|
|
resp = client.get("/health")
|
|
|
|
assert resp.status_code == 200 # liveness probe must stay green
|
|
_assert_liveness_only(resp.json())
|
|
|
|
|
|
def test_password_mode_valid_token_unlocks_config(monkeypatch):
|
|
import lightrag.api.utils_api as utils_api
|
|
|
|
client = _build_client(monkeypatch)
|
|
_set_auth_mode(monkeypatch, auth_configured=True)
|
|
monkeypatch.setattr(
|
|
utils_api.auth_handler,
|
|
"validate_token",
|
|
lambda token: (
|
|
{"username": "admin", "role": "user"}
|
|
if token == "valid-user-token"
|
|
else (_ for _ in ()).throw(ValueError("bad token"))
|
|
),
|
|
)
|
|
|
|
resp = client.get("/health", headers={"Authorization": "Bearer valid-user-token"})
|
|
|
|
assert resp.status_code == 200
|
|
_assert_full_config(resp.json())
|
|
|
|
|
|
def test_password_mode_guest_token_stays_liveness_only(monkeypatch):
|
|
import lightrag.api.utils_api as utils_api
|
|
|
|
client = _build_client(monkeypatch)
|
|
_set_auth_mode(monkeypatch, auth_configured=True)
|
|
monkeypatch.setattr(
|
|
utils_api.auth_handler,
|
|
"validate_token",
|
|
lambda token: {"username": "guest", "role": "guest"},
|
|
)
|
|
|
|
resp = client.get("/health", headers={"Authorization": "Bearer guest-token"})
|
|
|
|
assert resp.status_code == 200
|
|
_assert_liveness_only(resp.json())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API-key-only mode: only a valid X-API-Key unlocks the configuration.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_api_key_mode_anonymous_gets_liveness_only(monkeypatch):
|
|
client = _build_client(monkeypatch, api_key="secret-key")
|
|
_set_auth_mode(monkeypatch, auth_configured=False)
|
|
|
|
resp = client.get("/health")
|
|
|
|
assert resp.status_code == 200
|
|
_assert_liveness_only(resp.json())
|
|
|
|
|
|
def test_api_key_mode_valid_key_unlocks_config(monkeypatch):
|
|
client = _build_client(monkeypatch, api_key="secret-key")
|
|
_set_auth_mode(monkeypatch, auth_configured=False)
|
|
|
|
resp = client.get("/health", headers={"X-API-Key": "secret-key"})
|
|
|
|
assert resp.status_code == 200
|
|
_assert_full_config(resp.json())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Combined mode (AUTH_ACCOUNTS + LIGHTRAG_API_KEY): either a valid JWT or a
|
|
# valid X-API-Key unlocks the configuration; an anonymous probe gets liveness.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_combined_mode_anonymous_gets_liveness_only(monkeypatch):
|
|
client = _build_client(monkeypatch, api_key="secret-key")
|
|
_set_auth_mode(monkeypatch, auth_configured=True)
|
|
|
|
resp = client.get("/health")
|
|
|
|
assert resp.status_code == 200
|
|
_assert_liveness_only(resp.json())
|
|
|
|
|
|
def test_combined_mode_valid_api_key_unlocks_config(monkeypatch):
|
|
client = _build_client(monkeypatch, api_key="secret-key")
|
|
_set_auth_mode(monkeypatch, auth_configured=True)
|
|
|
|
resp = client.get("/health", headers={"X-API-Key": "secret-key"})
|
|
|
|
assert resp.status_code == 200
|
|
_assert_full_config(resp.json())
|