fix(dashboard): authenticate the gateway health probe without leaking the key to /health
The dashboard's _probe_gateway_health() sent no Authorization header, so an auth-protected gateway (API_SERVER_KEY set) answered 401 to every /health/detailed poll and logged a warning per probe. Read API_SERVER_KEY at probe time and send it as a Bearer header — to the authenticated /health/detailed endpoint only; the public /health fallback never receives the credential. Fixes #76051
This commit is contained in:
@@ -47,9 +47,15 @@ def _probe_gateway_health() -> tuple[bool, dict | None]:
|
||||
if not _GATEWAY_HEALTH_URL:
|
||||
return False, None
|
||||
base = re.sub(r"/health(/detailed)?$", "", _GATEWAY_HEALTH_URL.rstrip("/"))
|
||||
# Bearer credential for an auth-protected gateway (issue #76051): sent to the
|
||||
# authenticated /health/detailed probe ONLY — never to the public /health fallback,
|
||||
# which is unauthenticated by design.
|
||||
api_key = os.environ.get("API_SERVER_KEY", "").strip()
|
||||
for path in (f"{base}/health/detailed", f"{base}/health"):
|
||||
try:
|
||||
req = urllib.request.Request(path, method="GET")
|
||||
if api_key and path.endswith("/health/detailed"):
|
||||
req.add_header("Authorization", f"Bearer {api_key}")
|
||||
with urllib.request.urlopen(req, timeout=_GATEWAY_HEALTH_TIMEOUT) as resp:
|
||||
if resp.status == 200:
|
||||
return True, _read_dashboard_json_response(resp)
|
||||
|
||||
@@ -125,3 +125,74 @@ class TestProbeGatewayHealth:
|
||||
"http://gw:8642/health/detailed",
|
||||
"http://gw:8642/health",
|
||||
]
|
||||
|
||||
|
||||
class TestProbeGatewayHealthAuth:
|
||||
"""API_SERVER_KEY must authenticate the /health/detailed probe — and must
|
||||
never leak to the unauthenticated /health fallback endpoint."""
|
||||
|
||||
@staticmethod
|
||||
def _recording_urlopen(monkeypatch, detailed_behavior):
|
||||
"""urlopen mock that records each request's (url, Authorization header)."""
|
||||
requests = []
|
||||
|
||||
def mock_urlopen(req, **kwargs):
|
||||
requests.append((req.full_url, req.get_header("Authorization")))
|
||||
if len(requests) == 1:
|
||||
detailed_behavior()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.read.return_value = json.dumps({"status": "ok"}).encode()
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
monkeypatch.setattr(_web_server_gateway.urllib.request, "urlopen", mock_urlopen)
|
||||
return requests
|
||||
|
||||
def test_auth_key_sent_to_detailed_endpoint(self, monkeypatch):
|
||||
"""With API_SERVER_KEY set, the /health/detailed probe carries the bearer
|
||||
credential so an auth-protected gateway does not log 401 warnings."""
|
||||
monkeypatch.setenv("API_SERVER_KEY", "sekrit-token")
|
||||
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
|
||||
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_TIMEOUT", 1)
|
||||
requests = self._recording_urlopen(
|
||||
monkeypatch,
|
||||
lambda: json.dumps({"status": "ok"}),
|
||||
)
|
||||
|
||||
alive, body = _web_server_gateway._probe_gateway_health()
|
||||
|
||||
assert alive is True
|
||||
assert requests[0] == ("http://gw:8642/health/detailed", "Bearer sekrit-token")
|
||||
|
||||
def test_auth_key_never_sent_to_public_health_fallback(self, monkeypatch):
|
||||
"""The credential authenticates /health/detailed only; the unauthenticated
|
||||
/health fallback must never receive it (it is the public endpoint)."""
|
||||
monkeypatch.setenv("API_SERVER_KEY", "sekrit-token")
|
||||
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
|
||||
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_TIMEOUT", 1)
|
||||
requests = self._recording_urlopen(
|
||||
monkeypatch, lambda: (_ for _ in ()).throw(ConnectionError("401"))
|
||||
)
|
||||
|
||||
alive, body = _web_server_gateway._probe_gateway_health()
|
||||
|
||||
assert alive is True
|
||||
assert requests[0] == ("http://gw:8642/health/detailed", "Bearer sekrit-token")
|
||||
assert requests[1] == ("http://gw:8642/health", None)
|
||||
|
||||
def test_no_key_means_no_header(self, monkeypatch):
|
||||
"""Without API_SERVER_KEY the probe sends no Authorization header at all."""
|
||||
monkeypatch.delenv("API_SERVER_KEY", raising=False)
|
||||
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
|
||||
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_TIMEOUT", 1)
|
||||
requests = self._recording_urlopen(
|
||||
monkeypatch,
|
||||
lambda: json.dumps({"status": "ok"}),
|
||||
)
|
||||
|
||||
alive, body = _web_server_gateway._probe_gateway_health()
|
||||
|
||||
assert alive is True
|
||||
assert requests[0][1] is None
|
||||
|
||||
Reference in New Issue
Block a user