"""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())