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:
Austin Pickett
2026-10-06 16:59:36 -04:00
parent 0f0b0a12aa
commit c061068b26
2 changed files with 77 additions and 0 deletions

View File

@@ -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)

View File

@@ -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