Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

logger = logging.getLogger(__name__)

# Upper bound on a /models catalog response. A model list is small JSON even
# for providers with thousands of models; this only exists so a misconfigured
# or hostile catalog endpoint can't stream an unbounded body into memory.
_MAX_MODELS_RESPONSE_BYTES = 16 * 1024 * 1024 # 16 MiB

# Sentinel for "omit temperature entirely" (Kimi: server manages it)
OMIT_TEMPERATURE = object()

Expand Down Expand Up @@ -204,9 +209,33 @@ def fetch_models(
for k, v in self.default_headers.items():
req.add_header(k, v)

# Bound the response so a misconfigured/hostile catalog endpoint
# can't force an unbounded in-memory buffer. A timeout limits
# wall-clock, not size. Oversized responses fall back to the static

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main opens this credential-bearing request through open_credentialed_url, which strips secrets on cross-origin redirects. Please apply the bounded read while retaining that helper rather than switching to urllib.request.urlopen; otherwise this stale patch regresses the redirect-security boundary.

# model list (return None), matching the documented contract.
max_bytes = _MAX_MODELS_RESPONSE_BYTES
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
content_length = resp.headers.get("Content-Length")
if content_length is not None:
try:
if int(content_length) > max_bytes:
logger.debug(
"fetch_models(%s): catalog response too large "
"(Content-Length=%s > %d)",
self.name, content_length, max_bytes,
)
return None
except (TypeError, ValueError):
pass
raw = resp.read(max_bytes + 1)
if len(raw) > max_bytes:
logger.debug(
"fetch_models(%s): catalog response exceeded %d bytes",
self.name, max_bytes,
)
return None
data = json.loads(raw.decode())
items = data if isinstance(data, list) else data.get("data", [])
return [m["id"] for m in items if isinstance(m, dict) and "id" in m]
except Exception as exc:
Expand Down
69 changes: 69 additions & 0 deletions tests/providers/test_fetch_models_size_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Regression tests for ProviderProfile.fetch_models response-size bounding.

fetch_models() probes a provider's /models endpoint. The endpoint URL comes
from operator/config (base_url/models_url, e.g. a self-hosted or community
relay), so a misconfigured or hostile catalog endpoint must not be able to
force the process to buffer an unbounded body into memory. A timeout bounds
wall-clock, not size — the response itself has to be capped.
"""

import json

import providers.base as pbase
from providers.base import ProviderProfile


class _FakeResp:
"""Minimal stand-in for an http.client.HTTPResponse context manager."""

def __init__(self, body: bytes, content_length=None):
self._body = body
self.headers = {}
if content_length is not None:
self.headers["Content-Length"] = str(content_length)

def read(self, n=-1):
if n is None or n < 0:
return self._body
return self._body[:n]

def __enter__(self):
return self

def __exit__(self, *exc):
return False


def _patch_urlopen(monkeypatch, resp):
monkeypatch.setattr(
"urllib.request.urlopen", lambda req, timeout=None: resp
)


def _profile():
return ProviderProfile(name="test", base_url="https://api.example.test/v1")


def test_small_response_returns_model_ids(monkeypatch):
body = json.dumps({"data": [{"id": "m1"}, {"id": "m2"}]}).encode()
_patch_urlopen(monkeypatch, _FakeResp(body))
assert _profile().fetch_models() == ["m1", "m2"]


def test_oversized_body_is_rejected(monkeypatch):
# Cap to a tiny value so we don't allocate megabytes in the test.
monkeypatch.setattr(pbase, "_MAX_MODELS_RESPONSE_BYTES", 32)
body = json.dumps({"data": [{"id": "x" * 200}]}).encode()
assert len(body) > 32
# No Content-Length header → the post-read length guard must catch it.
_patch_urlopen(monkeypatch, _FakeResp(body))
assert _profile().fetch_models() is None


def test_oversized_content_length_header_is_rejected(monkeypatch):
monkeypatch.setattr(pbase, "_MAX_MODELS_RESPONSE_BYTES", 32)
# Small body, but the server advertises a huge Content-Length.
_patch_urlopen(
monkeypatch, _FakeResp(b'{"data": []}', content_length=10_000_000)
)
assert _profile().fetch_models() is None
Loading