Files
hermes-agent/tests/hermes_cli/test_audio_router_scope.py
Austin Pickett ca38caeea0 fix(dashboard+auth): cap HTTP JSON response body reads
Dashboard-side urllib readers (gateway health probe, ElevenLabs voices)
and self-hosted OIDC httpx readers (discovery, token exchange, revocation)
buffered remote response bodies with no size bound — a hostile or
compromised upstream could exhaust memory. Both halves now read with a
1 MiB hard cap: a shared _read_dashboard_json_response for urllib JSON
reads and a streaming _request_limited_response for IdP endpoints that
prechecks Content-Length and caps the chunked body regardless.

Adapted from #54809 and #55122 (credit @ooiuuii) to current main, where
the token exchange lives in plugins/dashboard_auth/_shared.py and the
health probe in hermes_cli/web_server_gateway.py.

Fixes #54808, Fixes #55121
2026-10-06 12:28:55 -04:00

125 lines
4.2 KiB
Python

"""Scope-isolation regression for the ElevenLabs voices route's env fallback.
``get_elevenlabs_voices`` reads ``ELEVENLABS_API_KEY`` through the secret scope
when ``.env`` has no key. A bound scope whose read fails must leave the route
unavailable -- never borrow the ambient ``os.environ`` key (another profile's
under multiplex). Only the unscoped default-profile path (UnscopedSecretError)
may read the env.
"""
import json
import pytest
from fastapi import HTTPException
@pytest.fixture(autouse=True)
def _restore_process_scope_state():
"""``_config_profile_scope``/``launch_secret_scope`` can freeze the launch-env
snapshot (one-way process state); restore it so sibling suites are unaffected."""
import agent.secret_scope as secret_scope
from tui_gateway import launch_profile_policy
was_active = secret_scope.is_multiplex_active()
snapshot = launch_profile_policy._snapshot
try:
yield
finally:
secret_scope.set_multiplex_active(was_active)
launch_profile_policy._snapshot = snapshot
class _ExplodingScope(dict):
"""A bound secret scope whose resolution fails (resolver/backend error)."""
def get(self, name, default=None):
raise RuntimeError("resolver boom")
@pytest.mark.asyncio
async def test_voices_route_scope_failure_never_borrows_env(tmp_path, monkeypatch):
from agent import secret_scope as ss
from hermes_cli.web_routers.audio import get_elevenlabs_voices
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-foreign-profile")
def _urlopen(*a, **k):
raise AssertionError("network must not be reached without a key")
monkeypatch.setattr("urllib.request.urlopen", _urlopen)
was_active = ss.is_multiplex_active()
ss.set_multiplex_active(True)
token = ss.set_secret_scope(_ExplodingScope())
try:
result = await get_elevenlabs_voices()
finally:
ss.reset_secret_scope(token)
ss.set_multiplex_active(was_active)
assert result == {"available": False, "voices": []}
@pytest.mark.asyncio
async def test_voices_route_bounds_response_read(monkeypatch, tmp_path):
"""The ElevenLabs voices fetch must read the body with a size cap."""
from hermes_cli import web_server_gateway
import hermes_cli.web_routers.audio as audio
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-test")
captured = {}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self, size=-1):
captured["size"] = size
data = json.dumps(
{"voices": [{"voice_id": "voice-1", "name": "Voice One"}]}
).encode()
return data if size < 0 else data[:size]
monkeypatch.setattr(audio.urllib.request, "urlopen", lambda req, timeout=None: _Resp())
result = await audio.get_elevenlabs_voices()
assert result["available"] is True
assert result["voices"][0]["voice_id"] == "voice-1"
assert captured["size"] == (
web_server_gateway._DASHBOARD_JSON_RESPONSE_BODY_MAX_BYTES + 1
)
@pytest.mark.asyncio
async def test_voices_route_rejects_oversized_response(monkeypatch, tmp_path):
"""An oversized ElevenLabs voices body must fail as 502, not buffer."""
from hermes_cli import web_server_gateway
import hermes_cli.web_routers.audio as audio
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-test")
monkeypatch.setattr(
web_server_gateway, "_DASHBOARD_JSON_RESPONSE_BODY_MAX_BYTES", 8
)
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self, size=-1):
return b"x" * size
monkeypatch.setattr(audio.urllib.request, "urlopen", lambda req, timeout=None: _Resp())
with pytest.raises(HTTPException) as exc_info:
await audio.get_elevenlabs_voices()
assert exc_info.value.status_code == 502