Skip to content
Merged
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
117 changes: 109 additions & 8 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,96 @@ async def _ssrf_redirect_guard(response):
# Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/)
IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache")

# ---------------------------------------------------------------------------
# Inbound media size cap (#13145)
#
# Inbound image / audio / video payloads are buffered fully into process
# memory before being written to the cache directory. With no cap, a single
# large upload (Discord Nitro allows 500 MB) — or a remote URL in an inbound
# message payload pointing at an arbitrarily large file — can spike RAM and
# OOM-kill the gateway. The ``cache_*_from_bytes`` helpers (the shared funnel
# every platform reaches eventually) and the ``cache_*_from_url`` downloaders
# enforce this cap, so the protection holds regardless of which platform
# adapter or code path produced the bytes.
#
# Configurable via ``gateway.max_inbound_media_bytes`` in config.yaml.
# ``0`` disables the cap. Default 128 MiB — generous enough for ordinary
# photos/voice notes/short clips while still bounding a hostile upload.
# ---------------------------------------------------------------------------
DEFAULT_INBOUND_MEDIA_MAX_BYTES = 128 * 1024 * 1024


def get_inbound_media_max_bytes() -> int:
"""Return the max inbound image/audio/video bytes allowed in memory.

Reads ``gateway.max_inbound_media_bytes`` from config.yaml. ``0`` (or a
negative / unparseable value) disables the cap. Non-fatal if config is
unreadable — falls back to the default.
"""
try:
from hermes_cli.config import load_config as _load_config
cfg = _load_config()
except Exception:
return DEFAULT_INBOUND_MEDIA_MAX_BYTES
gw = cfg.get("gateway", {}) if isinstance(cfg, dict) else {}
if not isinstance(gw, dict) or "max_inbound_media_bytes" not in gw:
return DEFAULT_INBOUND_MEDIA_MAX_BYTES
try:
return int(gw["max_inbound_media_bytes"])
except (TypeError, ValueError):
return DEFAULT_INBOUND_MEDIA_MAX_BYTES


def validate_inbound_media_size(
size: int,
*,
media_type: str = "media",
max_bytes: Optional[int] = None,
) -> None:
"""Raise ``ValueError`` if an inbound media payload exceeds the cap.

A ``max_bytes`` of ``0`` (or the configured cap resolving to ``0``)
disables the check entirely. Passing ``max_bytes`` lets callers resolve
the limit once and reuse it across an incremental read.
"""
limit = get_inbound_media_max_bytes() if max_bytes is None else max_bytes
if limit and size > limit:
raise ValueError(
f"Inbound {media_type} payload is too large "
f"({size} bytes > {limit} bytes)"
)


async def _read_httpx_body_with_limit(response, *, media_type: str) -> bytes:
"""Read an httpx streaming response body without exceeding the media cap.

Rejects early on an oversized ``Content-Length`` header, then re-checks
the running total as chunks arrive so a lying/absent header can't smuggle
an unbounded body past the cap.
"""
max_bytes = get_inbound_media_max_bytes()
content_length = response.headers.get("content-length")
if content_length:
try:
declared_size = int(content_length)
except ValueError:
logger.debug(
"Ignoring invalid Content-Length for inbound %s: %r",
media_type, content_length,
)
else:
validate_inbound_media_size(
declared_size, media_type=media_type, max_bytes=max_bytes,
)

chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
total += len(chunk)
validate_inbound_media_size(total, media_type=media_type, max_bytes=max_bytes)
chunks.append(chunk)
return b"".join(chunks)


def get_image_cache_dir() -> Path:
"""Return the image cache directory, creating it if it doesn't exist."""
Expand Down Expand Up @@ -606,6 +696,7 @@ def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
ValueError: If *data* does not look like a valid image (e.g. an HTML
error page returned by the upstream server).
"""
validate_inbound_media_size(len(data), media_type="image")
if not _looks_like_image(data):
snippet = data[:80].decode("utf-8", errors="replace")
raise ValueError(
Expand Down Expand Up @@ -651,15 +742,19 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
) as client:
for attempt in range(retries + 1):
try:
response = await client.get(
async with client.stream(
"GET",
url,
headers={
"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
"Accept": "image/*,*/*;q=0.8",
},
)
response.raise_for_status()
return cache_image_from_bytes(response.content, ext)
) as response:
response.raise_for_status()
content = await _read_httpx_body_with_limit(
response, media_type="image",
)
return cache_image_from_bytes(content, ext)
except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
raise
Expand Down Expand Up @@ -726,6 +821,7 @@ def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
Returns:
Absolute path to the cached audio file as a string.
"""
validate_inbound_media_size(len(data), media_type="audio")
cache_dir = get_audio_cache_dir()
filename = f"audio_{uuid.uuid4().hex[:12]}{ext}"
filepath = cache_dir / filename
Expand Down Expand Up @@ -765,15 +861,19 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
) as client:
for attempt in range(retries + 1):
try:
response = await client.get(
async with client.stream(
"GET",
url,
headers={
"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
"Accept": "audio/*,*/*;q=0.8",
},
)
response.raise_for_status()
return cache_audio_from_bytes(response.content, ext)
) as response:
response.raise_for_status()
content = await _read_httpx_body_with_limit(
response, media_type="audio",
)
return cache_audio_from_bytes(content, ext)
except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
raise
Expand Down Expand Up @@ -818,6 +918,7 @@ def get_video_cache_dir() -> Path:

def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
"""Save raw video bytes to the cache and return the absolute file path."""
validate_inbound_media_size(len(data), media_type="video")
cache_dir = get_video_cache_dir()
filename = f"video_{uuid.uuid4().hex[:12]}{ext}"
filepath = cache_dir / filename
Expand Down
10 changes: 10 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2474,6 +2474,16 @@ def _ensure_hermes_home_managed(home: Path):
"enabled": False,
},

# Maximum bytes for an inbound image / audio / video payload the
# gateway will buffer into memory and cache to disk. Inbound media is
# read fully into RAM before being written, so an unbounded upload
# (Discord Nitro allows 500 MB) or a remote media URL pointing at a
# huge file can spike memory and OOM-kill the gateway on constrained
# deployments. Enforced in the shared cache helpers
# (gateway/platforms/base.py), so the cap holds across every platform
# adapter. ``0`` disables the cap. Default 128 MiB.
"max_inbound_media_bytes": 134217728,

# When false (default), any file path the agent emits is delivered
# as a native attachment as long as it isn't under the credential /
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
Expand Down
26 changes: 21 additions & 5 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
cache_audio_from_bytes,
cache_document_from_bytes,
SUPPORTED_DOCUMENT_TYPES,
validate_inbound_media_size,
)
from tools.url_safety import is_safe_url

Expand Down Expand Up @@ -5052,26 +5053,41 @@ def _format_thread_chat_name(self, thread: Any) -> str:
# non-CDN URL into the ``att.url`` field. (issue #11345)
# ------------------------------------------------------------------

async def _read_attachment_bytes(self, att) -> Optional[bytes]:
async def _read_attachment_bytes(
self,
att,
*,
media_type: str = "media",
) -> Optional[bytes]:
"""Read an attachment via discord.py's authenticated bot session.

Returns the raw bytes on success, or ``None`` if ``att`` doesn't
expose a callable ``read()`` or the read itself fails. Callers
should treat ``None`` as a signal to fall back to the URL-based
downloaders.

Oversized attachments (per ``gateway.max_inbound_media_bytes``) raise
``ValueError`` BEFORE the bytes are pulled into memory when Discord
reports the size up front, so a hostile upload can't OOM the gateway.
"""
attachment_size = getattr(att, "size", None)
if attachment_size:
validate_inbound_media_size(int(attachment_size), media_type=media_type)

reader = getattr(att, "read", None)
if reader is None or not callable(reader):
return None
try:
return await reader()
raw_bytes = await reader()
except Exception as e:
logger.warning(
"[Discord] Authenticated attachment read failed for %s: %s",
getattr(att, "filename", None) or getattr(att, "url", "<unknown>"),
e,
)
return None
validate_inbound_media_size(len(raw_bytes), media_type=media_type)
return raw_bytes

async def _cache_discord_image(self, att, ext: str) -> str:
"""Cache a Discord image attachment to local disk.
Expand All @@ -5081,7 +5097,7 @@ async def _cache_discord_image(self, att, ext: str) -> str:

Fallback: ``cache_image_from_url`` (plain httpx, SSRF-gated).
"""
raw_bytes = await self._read_attachment_bytes(att)
raw_bytes = await self._read_attachment_bytes(att, media_type="image")
if raw_bytes is not None:
try:
return cache_image_from_bytes(raw_bytes, ext=ext)
Expand All @@ -5100,7 +5116,7 @@ async def _cache_discord_audio(self, att, ext: str) -> str:

Fallback: ``cache_audio_from_url`` (plain httpx, SSRF-gated).
"""
raw_bytes = await self._read_attachment_bytes(att)
raw_bytes = await self._read_attachment_bytes(att, media_type="audio")
if raw_bytes is not None:
try:
return cache_audio_from_bytes(raw_bytes, ext=ext)
Expand All @@ -5122,7 +5138,7 @@ async def _cache_discord_document(self, att, ext: str) -> bytes:
for passing the returned bytes to ``cache_document_from_bytes``
(and, where applicable, for injecting text content).
"""
raw_bytes = await self._read_attachment_bytes(att)
raw_bytes = await self._read_attachment_bytes(att, media_type="document")
if raw_bytes is not None:
return raw_bytes

Expand Down
Loading
Loading