fix: cap image download size at 50 MB, validate tool call parser fields

vision_tools.py: _download_image() loads the full HTTP response body into
memory via response.content (line 190) with no Content-Length check and no
max file size limit.  An attacker-hosted multi-gigabyte file causes OOM.
Add a 50 MB hard cap: check Content-Length header before download, and
verify actual body size before writing to disk.

hermes_parser.py: tc_data["name"] at line 57 raises KeyError when the LLM
outputs a tool call JSON without a "name" field.  The outer except catches
it silently, causing the entire tool call to be lost with zero diagnostics.
Add "name" field validation before constructing the ChatCompletionMessage.

mistral_parser.py: tc["name"] at line 101 has the same KeyError issue in
the pre-v11 format path.  The fallback decoder (line 112) already checks
"name" correctly, but the primary path does not.  Add validation to match.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aaronagent 2026-04-10 12:13:42 +08:00 committed by Teknium
parent 307697688e
commit 1909877e6e
3 changed files with 22 additions and 2 deletions

View file

@ -49,6 +49,8 @@ class HermesToolCallParser(ToolCallParser):
continue continue
tc_data = json.loads(raw_json) tc_data = json.loads(raw_json)
if "name" not in tc_data:
continue
tool_calls.append( tool_calls.append(
ChatCompletionMessageToolCall( ChatCompletionMessageToolCall(
id=f"call_{uuid.uuid4().hex[:8]}", id=f"call_{uuid.uuid4().hex[:8]}",

View file

@ -89,6 +89,8 @@ class MistralToolCallParser(ToolCallParser):
parsed = [parsed] parsed = [parsed]
for tc in parsed: for tc in parsed:
if "name" not in tc:
continue
args = tc.get("arguments", {}) args = tc.get("arguments", {})
if isinstance(args, dict): if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False) args = json.dumps(args, ensure_ascii=False)

View file

@ -67,6 +67,10 @@ def _resolve_download_timeout() -> float:
_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout() _VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout()
# Hard cap on downloaded image file size (50 MB). Prevents OOM from
# attacker-hosted multi-gigabyte files or decompression bombs.
_VISION_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
def _validate_image_url(url: str) -> bool: def _validate_image_url(url: str) -> bool:
""" """
@ -181,13 +185,25 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
) )
response.raise_for_status() response.raise_for_status()
# Reject overly large images early via Content-Length header.
cl = response.headers.get("content-length")
if cl and int(cl) > _VISION_MAX_DOWNLOAD_BYTES:
raise ValueError(
f"Image too large ({int(cl)} bytes, max {_VISION_MAX_DOWNLOAD_BYTES})"
)
final_url = str(response.url) final_url = str(response.url)
blocked = check_website_access(final_url) blocked = check_website_access(final_url)
if blocked: if blocked:
raise PermissionError(blocked["message"]) raise PermissionError(blocked["message"])
# Save the image content # Save the image content (double-check actual size)
destination.write_bytes(response.content) body = response.content
if len(body) > _VISION_MAX_DOWNLOAD_BYTES:
raise ValueError(
f"Image too large ({len(body)} bytes, max {_VISION_MAX_DOWNLOAD_BYTES})"
)
destination.write_bytes(body)
return destination return destination
except Exception as e: except Exception as e: