fix(discord): route attachment downloads through authenticated bot session (#11568)
Three open issues — #8242, #6587, #11345 — all trace to the same root cause: the image / audio / document download paths in `DiscordAdapter._handle_message` used plain, unauthenticated HTTP to fetch `att.url`. That broke in three independent ways: #8242 cdn.discordapp.com attachment URLs increasingly require the bot session to download; unauthenticated httpx sees 403 Forbidden, image/voice analysis fail silently. #6587 Some user environments (VPNs, corporate DNS, tunnels) resolve cdn.discordapp.com to private-looking IPs. Our is_safe_url() guard correctly blocks them as SSRF risks, but the user environment is legitimate — image analysis and voice STT die. #11345 The document download path skipped is_safe_url() entirely — raw aiohttp.ClientSession.get(att.url) with no SSRF check, inconsistent with the image/audio branches. Unified fix: use `discord.Attachment.read()` as the primary download path on all three branches. `att.read()` routes through discord.py's own authenticated HTTPClient, so: - Discord CDN auth is handled (#8242 resolved). - Our is_safe_url() gate isn't consulted for the attachment path at all — the bot session handles networking internally (#6587 resolved). - All three branches now share the same code path, eliminating the document-path SSRF gap (#11345 resolved). Falls back to the existing cache_*_from_url helpers (image/audio) or an SSRF-gated aiohttp fetch (documents) when `att.read()` is unavailable or fails — preserves defense-in-depth for any future payload-schema drift that could slip a non-CDN URL into att.url. New helpers on DiscordAdapter: - _read_attachment_bytes(att) — safe att.read() wrapper - _cache_discord_image(att, ext) — primary + URL fallback - _cache_discord_audio(att, ext) — primary + URL fallback - _cache_discord_document(att, ext) — primary + SSRF-gated aiohttp fallback Tests: - tests/gateway/test_discord_attachment_download.py — 12 new cases covering all three helpers: primary path, fallback on missing .read(), fallback on validator rejection, SSRF guard on document fallback, aiohttp fallback happy-path, and an E2E case via _handle_message confirming cache_image_from_url is never invoked when att.read() succeeds. - All 11 existing document-handling tests continue to pass via the aiohttp fallback path (their SimpleNamespace attachments have no .read(), which triggers the fallback — now SSRF-gated). Closes #8242, closes #6587, closes #11345.
This commit is contained in:
@@ -51,7 +51,9 @@ from gateway.platforms.base import (
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
cache_image_from_url,
|
||||
cache_image_from_bytes,
|
||||
cache_audio_from_url,
|
||||
cache_audio_from_bytes,
|
||||
cache_document_from_bytes,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
)
|
||||
@@ -2489,6 +2491,124 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return f"{parent_name} / {thread_name}"
|
||||
return thread_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attachment download helpers
|
||||
#
|
||||
# Discord attachments (images / audio / documents) are fetched via the
|
||||
# authenticated bot session whenever the Attachment object exposes
|
||||
# ``read()``. That sidesteps two classes of bug that hit the older
|
||||
# plain-HTTP path:
|
||||
#
|
||||
# 1. ``cdn.discordapp.com`` URLs increasingly require bot auth on
|
||||
# download — unauthenticated httpx sees 403 Forbidden.
|
||||
# (issue #8242)
|
||||
# 2. Some user environments (VPNs, corporate DNS, tunnels) resolve
|
||||
# ``cdn.discordapp.com`` to private-looking IPs that our
|
||||
# ``is_safe_url`` guard classifies as SSRF risks. Routing the
|
||||
# fetch through discord.py's own HTTP client handles DNS
|
||||
# internally so our guard isn't consulted for the attachment
|
||||
# path. (issue #6587)
|
||||
#
|
||||
# If ``att.read()`` is unavailable (unexpected object shape / test
|
||||
# stub) or the bot session fetch fails, we fall back to the existing
|
||||
# SSRF-gated URL downloaders. The fallback keeps defense-in-depth
|
||||
# against any future Discord payload-schema drift that could slip a
|
||||
# non-CDN URL into the ``att.url`` field. (issue #11345)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _read_attachment_bytes(self, att) -> 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.
|
||||
"""
|
||||
reader = getattr(att, "read", None)
|
||||
if reader is None or not callable(reader):
|
||||
return None
|
||||
try:
|
||||
return 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
|
||||
|
||||
async def _cache_discord_image(self, att, ext: str) -> str:
|
||||
"""Cache a Discord image attachment to local disk.
|
||||
|
||||
Primary path: ``att.read()`` + ``cache_image_from_bytes``
|
||||
(authenticated, no SSRF gate).
|
||||
|
||||
Fallback: ``cache_image_from_url`` (plain httpx, SSRF-gated).
|
||||
"""
|
||||
raw_bytes = await self._read_attachment_bytes(att)
|
||||
if raw_bytes is not None:
|
||||
try:
|
||||
return cache_image_from_bytes(raw_bytes, ext=ext)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[Discord] cache_image_from_bytes rejected att.read() data; falling back to URL: %s",
|
||||
e,
|
||||
)
|
||||
return await cache_image_from_url(att.url, ext=ext)
|
||||
|
||||
async def _cache_discord_audio(self, att, ext: str) -> str:
|
||||
"""Cache a Discord audio attachment to local disk.
|
||||
|
||||
Primary path: ``att.read()`` + ``cache_audio_from_bytes``
|
||||
(authenticated, no SSRF gate).
|
||||
|
||||
Fallback: ``cache_audio_from_url`` (plain httpx, SSRF-gated).
|
||||
"""
|
||||
raw_bytes = await self._read_attachment_bytes(att)
|
||||
if raw_bytes is not None:
|
||||
try:
|
||||
return cache_audio_from_bytes(raw_bytes, ext=ext)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[Discord] cache_audio_from_bytes failed; falling back to URL: %s",
|
||||
e,
|
||||
)
|
||||
return await cache_audio_from_url(att.url, ext=ext)
|
||||
|
||||
async def _cache_discord_document(self, att, ext: str) -> bytes:
|
||||
"""Download a Discord document attachment and return the raw bytes.
|
||||
|
||||
Primary path: ``att.read()`` (authenticated, no SSRF gate).
|
||||
|
||||
Fallback: SSRF-gated ``aiohttp`` download. This closes the gap
|
||||
where the old document path made raw ``aiohttp.ClientSession``
|
||||
requests with no safety check (#11345). The caller is responsible
|
||||
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)
|
||||
if raw_bytes is not None:
|
||||
return raw_bytes
|
||||
|
||||
# Fallback: SSRF-gated URL download.
|
||||
if not is_safe_url(att.url):
|
||||
raise ValueError(
|
||||
f"Blocked unsafe attachment URL (SSRF protection): {att.url}"
|
||||
)
|
||||
import aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with session.get(
|
||||
att.url,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
return await resp.read()
|
||||
|
||||
async def _handle_message(self, message: DiscordMessage) -> None:
|
||||
"""Handle incoming Discord messages."""
|
||||
# In server channels (not DMs), require the bot to be @mentioned
|
||||
@@ -2642,7 +2762,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
ext = "." + content_type.split("/")[-1].split(";")[0]
|
||||
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
||||
ext = ".jpg"
|
||||
cached_path = await cache_image_from_url(att.url, ext=ext)
|
||||
cached_path = await self._cache_discord_image(att, ext)
|
||||
media_urls.append(cached_path)
|
||||
media_types.append(content_type)
|
||||
print(f"[Discord] Cached user image: {cached_path}", flush=True)
|
||||
@@ -2656,7 +2776,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
ext = "." + content_type.split("/")[-1].split(";")[0]
|
||||
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
|
||||
ext = ".ogg"
|
||||
cached_path = await cache_audio_from_url(att.url, ext=ext)
|
||||
cached_path = await self._cache_discord_audio(att, ext)
|
||||
media_urls.append(cached_path)
|
||||
media_types.append(content_type)
|
||||
print(f"[Discord] Cached user audio: {cached_path}", flush=True)
|
||||
@@ -2687,19 +2807,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
)
|
||||
else:
|
||||
try:
|
||||
import aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with session.get(
|
||||
att.url,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
raw_bytes = await resp.read()
|
||||
raw_bytes = await self._cache_discord_document(att, ext)
|
||||
cached_path = cache_document_from_bytes(
|
||||
raw_bytes, att.filename or f"document{ext}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user