{"repo":"NousResearch/hermes-agent","public_id":"11345","display_label":"Discord attachment downloads SSRF guard bypass fix","summary":"These issues and PRs address a security concern where Discord document-attachment downloads were bypassing the SSRF `is_safe_url` guard. The work involves routing attachment downloads through an authenticated bot session and applying the SSRF guard to those requests.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11568,"merged_at":"2026-04-17T11:59:04+00:00"},"resolution":{"state":"resolved","by":"pr-11568"},"freshness":{"state":"stale","last_verified_at":"2026-04-17T11:59:04+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":6511,"pr_number":11568,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-17T11:59:04+00:00"},{"issue_number":6587,"pr_number":11568,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-17T11:59:04+00:00"},{"issue_number":8242,"pr_number":11568,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-17T11:59:04+00:00"},{"issue_number":11345,"pr_number":11568,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-17T11:59:04+00:00"}],"members":[{"kind":"issue","number":11345,"title":"Discord: document-attachment downloads bypass the SSRF `is_safe_url` guard","url":null,"state":"closed"},{"kind":"pr","number":11346,"title":"fix(discord): apply is_safe_url SSRF guard to document attachment downloads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11345","number":11345},{"kind":"pr","value":"#11568","number":11568}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11568,"title":"fix(discord): route attachment downloads through authenticated bot session","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","tests/gateway/test_discord_attachment_download.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -51,7 +51,9 @@\n     ProcessingOutcome,\n     SendResult,\n     cache_image_from_url,\n+    cache_image_from_bytes,\n     cache_audio_from_url,\n+    cache_audio_from_bytes,\n     cache_document_from_bytes,\n     SUPPORTED_DOCUMENT_TYPES,\n )","@@ -2489,6 +2491,124 @@ def _format_thread_chat_name(self, thread: Any) -> str:\n             return f\"{parent_name} / {thread_name}\"\n         return thread_name\n \n+    # ------------------------------------------------------------------\n+    # Attachment download helpers\n+    #\n+    # Discord attachments (images / audio / documents) are fetched via the\n+    # authenticated bot session whenever the Attachment object exposes\n+    # ``read()``. That sidesteps two classes of bug that hit the older\n+    # plain-HTTP path:\n+    #\n+    #   1. ``cdn.discordapp.com`` URLs increasingly require bot auth on\n+    #      download — unauthenticated httpx sees 403 Forbidden.\n+    #      (issue #8242)\n+    #   2. Some user environments (VPNs, corporate DNS, tunnels) resolve\n+    #      ``cdn.discordapp.com`` to private-looking IPs that our\n+    #      ``is_safe_url`` guard classifies as SSRF risks. Routing the\n+    #      fetch through discord.py's own HTTP client handles DNS\n+    #      internally so our guard isn't consulted for the attachment\n+    #      path. (issue #6587)\n+    #\n+    # If ``att.read()`` is unavailable (unexpected object shape / test\n+    # stub) or the bot session fetch fails, we fall back to the existing\n+    # SSRF-gated URL downloaders. The fallback keeps defense-in-depth\n+    # against any future Discord payload-schema drift that could slip a\n+    # non-CDN URL into the ``att.url`` field. (issue #11345)\n+    # ------------------------------------------------------------------\n+\n+    async def _read_attachment_bytes(self, att) -> Optional[bytes]:\n+        \"\"\"Read an attachment via discord.py's authenticated bot session.\n+\n+        Returns the raw bytes on success, or ``None`` if ``att`` doesn't\n+        expose a callable ``read()`` or the read itself fails. Callers\n+        should treat ``None`` as a signal to fall back to the URL-based\n+        downloaders.\n+        \"\"\"\n+        reader = getattr(att, \"read\", None)\n+        if reader is None or not callable(reader):\n+            return None\n+        try:\n+            return await reader()\n+        except Exception as e:\n+            logger.warning(\n+                \"[Discord] Authenticated attachment read failed for %s: %s\",\n+                getattr(att, \"filename\", None) or getattr(att, \"url\", \"<unknown>\"),\n+                e,\n+            )\n+            return None\n+\n+    async def _cache_discord_image(self, att, ext: str) -> str:\n+        \"\"\"Cache a Discord image attachment to local disk.\n+\n+        Primary path: ``att.read()`` + ``cache_image_from_bytes``\n+        (authenticated, no SSRF gate).\n+\n+        Fallback: ``cache_image_from_url`` (plain httpx, SSRF-gated).\n+        \"\"\"\n+        raw_bytes = await self._read_attachment_bytes(att)\n+        if raw_bytes is not None:\n+            try:\n+                return cache_image_from_bytes(raw_bytes, ext=ext)\n+            except Exception as e:\n+                logger.debug(\n+                    \"[Discord] cache_image_from_bytes rejected att.read() data; falling back to URL: %s\",\n+                    e,\n+                )\n+        return await cache_image_from_url(att.url, ext=ext)\n+\n+    async def _cache_discord_audio(self, att, ext: str) -> str:\n+        \"\"\"Cache a Discord audio attachment to local disk.\n+\n+        Primary path: ``att.read()`` + ``cache_audio_from_bytes``\n+        (authenticated, no SSRF gate).\n+\n+        Fallback: ``cache_audio_from_url`` (plain httpx, SSRF-gated).\n+        \"\"\"\n+        raw_bytes = await self._read_attachment_bytes(att)\n+        if raw_bytes is not None:\n+            try:\n+                return cache_audio_from_bytes(raw_bytes, ext=ext)\n+            except Exception as e:\n+                logger.debug(\n+                    \"[Discord] cache_audio_from_bytes failed; falling back to URL: %s\",\n+                    e,\n+                )\n+        return await cache_audio_from_url(att.url, ext=ext)\n+\n+    async def _cache_discord_document(self, att, ext: str) -> bytes:\n+        \"\"\"Download a Discord document attachment and return the raw bytes.\n+\n+        Primary path: ``att.read()`` (authenticated, no SSRF gate).\n+\n+        Fallback: SSRF-gated ``aiohttp`` download. This closes the gap\n+        where the old document path made raw ``aiohttp.ClientSession``\n+        requests with no safety check (#11345). The caller is responsible\n+        for passing the returned bytes to ``cache_document_from_bytes``\n+        (and, where applicable, for injecting text content).\n+        \"\"\"\n+        raw_bytes = await self._read_attachment_bytes(att)\n+        if raw_bytes is not None:\n+            return raw_bytes\n+\n+        # Fallback: SSRF-gated URL download.\n+        if not is_safe_url(att.url):\n+            raise ValueError(\n+                f\"Blocked unsafe attachment URL (SSRF protection): {att.url}\"\n+            )\n+        import aiohttp\n+        from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp\n+        _proxy = resolve_proxy_url(platform_env_var=\"DISCORD_PROXY\")\n+        _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)\n+        async with aiohttp.ClientSession(**_sess_kw) as session:\n+            async with session.get(\n+                att.url,\n+                timeout=aiohttp.ClientTimeout(total=30),\n+                **_req_kw,\n+            ) as resp:\n+                if resp.status != 200:\n+                    raise Exception(f\"HTTP {resp.status}\")\n+                return await resp.read()\n+\n     async def _handle_message(self, message: DiscordMessage) -> None:\n         \"\"\"Handle incoming Discord messages.\"\"\"\n         # In server channels (not DMs), require the bot to be @mentioned","@@ -2642,7 +2762,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n                     ext = \".\" + content_type.split(\"/\")[-1].split(\";\")[0]\n                     if ext not in (\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\"):\n                         ext = \".jpg\"\n-                    cached_path = await cache_image_from_url(att.url, ext=ext)\n+                    cached_path = await self._cache_discord_image(att, ext)\n                     media_urls.append(cached_path)\n                     media_types.append(content_type)\n                     print(f\"[Discord] Cached user image: {cached_path}\", flush=True)","@@ -2656,7 +2776,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n                     ext = \".\" + content_type.split(\"/\")[-1].split(\";\")[0]\n                     if ext not in (\".ogg\", \".mp3\", \".wav\", \".webm\", \".m4a\"):\n                         ext = \".ogg\"\n-                    cached_path = await cache_audio_from_url(att.url, ext=ext)\n+                    cached_path = await self._cache_discord_audio(att, ext)\n                     media_urls.append(cached_path)\n                     media_types.append(content_type)\n                     print(f\"[Discord] Cached user audio: {cached_path}\", flush=True)","@@ -2687,19 +2807,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n                         )\n                     else:\n                         try:\n-                            import aiohttp\n-                            from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp\n-                            _proxy = resolve_proxy_url(platform_env_var=\"DISCORD_PROXY\")\n-                            _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)\n-                            async with aiohttp.ClientSession(**_sess_kw) as session:\n-                                async with session.get(\n-                                    att.url,\n-                                    timeout=aiohttp.ClientTimeout(total=30),\n-                                    **_req_kw,\n-                                ) as resp:\n-                                    if resp.status != 200:\n-                                        raise Exception(f\"HTTP {resp.status}\")\n-                                    raw_bytes = await resp.read()\n+                            raw_bytes = await self._cache_discord_document(att, ext)\n                             cached_path = cache_document_from_bytes(\n                                 raw_bytes, att.filename or f\"document{ext}\"\n                             )"]},{"file":"tests/gateway/test_discord_attachment_download.py","hunks":["@@ -0,0 +1,360 @@\n+\"\"\"Tests for Discord attachment downloads via the authenticated bot session.\n+\n+Covers the three download paths (image / audio / document) in\n+``DiscordAdapter._handle_message()`` and the shared ``_cache_discord_*``\n+helpers. Verifies that:\n+\n+- ``att.read()`` is preferred over the legacy URL-based downloaders so\n+  that Discord's CDN auth (and user-environment DNS quirks) can't block\n+  media caching. (issues #8242 image 403s, #6587 CDN SSRF false-positives)\n+- Falls back cleanly to the SSRF-gated ``cache_*_from_url`` helpers\n+  (image/audio) or SSRF-gated aiohttp (documents) when ``att.read()``\n+  isn't available or fails.\n+- The document fallback path now runs through the SSRF gate for\n+  defense-in-depth. (issue #11345)\n+\"\"\"\n+\n+import sys\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _ensure_discord_mock():\n+    \"\"\"Install a mock discord module when discord.py isn't available.\"\"\"\n+    if \"discord\" in sys.modules and hasattr(sys.modules[\"discord\"], \"__file__\"):\n+        return\n+\n+    discord_mod = MagicMock()\n+    discord_mod.Intents.default.return_value = MagicMock()\n+    discord_mod.Client = MagicMock\n+    discord_mod.File = MagicMock\n+    discord_mod.DMChannel = type(\"DMChannel\", (), {})\n+    discord_mod.Thread = type(\"Thread\", (), {})\n+    discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n+    discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)\n+    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)\n+    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)\n+    discord_mod.Interaction = object\n+    discord_mod.Embed = MagicMock\n+    discord_mod.app_commands = SimpleNamespace(\n+        describe=lambda **kwargs: (lambda fn: fn),\n+        choices=lambda **kwargs: (lambda fn: fn),\n+        Choice=lambda **kwargs: SimpleNamespace(**kwargs),\n+    )\n+\n+    ext_mod = MagicMock()\n+    commands_mod = MagicMock()\n+    commands_mod.Bot = MagicMock\n+    ext_mod.commands = commands_mod\n+\n+    sys.modules.setdefault(\"discord\", discord_mod)\n+    sys.modules.setdefault(\"discord.ext\", ext_mod)\n+    sys.modules.setdefault(\"discord.ext.commands\", commands_mod)\n+\n+\n+_ensure_discord_mock()\n+\n+from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+\n+\n+# Minimal valid image / audio / PDF bytes so the cache_*_from_bytes\n+# validators accept them. cache_image_from_bytes runs _looks_like_image()\n+# which checks for magic bytes; PNG's magic is sufficient.\n+_PNG_BYTES = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * 64\n+_OGG_BYTES = b\"OggS\" + b\"\\x00\" * 60\n+_PDF_BYTES = b\"%PDF-1.4\\n\" + b\"fake pdf body\" + b\"\\n%%EOF\"\n+\n+\n+def _make_adapter() -> DiscordAdapter:\n+    return DiscordAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+\n+\n+def _make_attachment_with_read(payload: bytes) -> SimpleNamespace:\n+    \"\"\"Attachment stub that exposes .read() — the happy-path primary.\"\"\"\n+    return SimpleNamespace(\n+        url=\"https://cdn.discordapp.com/attachments/fake/file.png\",\n+        filename=\"file.png\",\n+        size=len(payload),\n+        read=AsyncMock(return_value=payload),\n+    )\n+\n+\n+def _make_attachment_without_read() -> SimpleNamespace:\n+    \"\"\"Attachment stub that has no .read() — exercises the URL fallback.\"\"\"\n+    return SimpleNamespace(\n+        url=\"https://cdn.discordapp.com/attachments/fake/file.png\",\n+        filename=\"file.png\",\n+        size=1024,\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# _read_attachment_bytes\n+# ---------------------------------------------------------------------------\n+\n+class TestReadAttachmentBytes:\n+    \"\"\"Unit tests for the low-level att.read() wrapper.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_returns_bytes_on_successful_read(self):\n+        adapter = _make_adapter()\n+        att = _make_attachment_with_read(b\"hello world\")\n+\n+        result = await adapter._read_attachment_bytes(att)\n+\n+        assert result == b\"hello world\"\n+        att.read.assert_awaited_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_returns_none_when_read_missing(self):\n+        adapter = _make_adapter()\n+        att = _make_attachment_without_read()\n+\n+        result = await adapter._read_attachment_bytes(att)\n+\n+        assert result is None\n+\n+    @pytest.mark.asyncio\n+    async def test_returns_none_when_read_raises(self):\n+        \"\"\"Bot-session fetch failures are swallowed so callers fall back.\"\"\"\n+        adapter = _make_adapter()\n+        att = SimpleNamespace(\n+            url=\"https://cdn.discordapp.com/attachments/fake/file.png\",\n+            filename=\"file.png\",\n+            read=AsyncMock(side_effect=RuntimeError(\"403 Forbidden\")),\n+        )\n+\n+        result = await adapter._read_attachment_bytes(att)\n+\n+        assert result is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# _cache_discord_image\n+# ---------------------------------------------------------------------------\n+\n+class TestCacheDiscordImage:\n+    @pytest.mark.asyncio\n+    async def test_prefers_att_read_over_url(self):\n+        \"\"\"Primary path: att.read() bytes → cache_image_from_bytes, no URL fetch.\"\"\"\n+        adapter = _make_adapter()\n+        att = _make_attachment_with_read(_PNG_BYTES)\n+\n+        with patch(\n+            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            return_value=\"/tmp/cached.png\",\n+        ) as mock_bytes, patch(\n+            \"gateway.platforms.discord.cache_image_from_url\",\n+            new_callable=AsyncMock,\n+        ) as mock_url:\n+            result = await adapter._cache_discord_image(att, \".png\")\n+\n+        assert result == \"/tmp/cached.png\"\n+        mock_bytes.assert_called_once_with(_PNG_BYTES, ext=\".png\")\n+        mock_url.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_falls_back_to_url_when_no_read(self):\n+        \"\"\"No .read() → URL path is used (existing SSRF-gated behavior).\"\"\"\n+        adapter = _make_adapter()\n+        att = _make_attachment_without_read()\n+\n+        with patch(\n+            \"gateway.platforms.discord.cache_image_from_bytes\",\n+        ) as mock_bytes, patch(\n+            \"gateway.platforms.discord.cache_image_from_url\",\n+            new_callable=AsyncMock,\n+            return_value=\"/tmp/from_url.png\",\n+        ) as mock_url:\n+            result = await adapter._cache_discord_image(att, \".png\")\n+\n+        assert result == \"/tmp/from_url.png\"\n+        mock_bytes.assert_not_called()\n+        mock_url.assert_awaited_once_with(att.url, ext=\".png\")\n+\n+    @pytest.mark.asyncio\n+    async def test_falls_back_to_url_when_bytes_validator_rejects(self):\n+        \"\"\"If att.read() returns garbage that cache_image_from_bytes rejects\n+        (e.g. an HTML error page), fall back to the URL downloader instead\n+        of surfacing the validation error to the caller.\"\"\"\n+        adapter = _make_adapter()\n+        att = _make_attachment_with_read(b\"<html>forbidden</html>\")\n+\n+        with patch(\n+            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            side_effect=ValueError(\"not a valid image\"),\n+        ), patch(\n+            \"gateway.platforms.discord.cache_image_from_url\",\n+            new_callable=AsyncMock,\n+            return_value=\"/tmp/fallback.png\",\n+        ) as mock_url:\n+            result = await adapter._cache_discord_image(att, \".png\")\n+\n+        assert result == \"/tmp/fallback.png\"\n+        mock_url.assert_awaited_once()\n+\n+\n+# ---------------------------------------------------------------------------\n+# _cache_discord_audio\n+# ---------------------------------------------------------------------------\n+\n+class TestCacheDiscordAudio:\n+    @pytest.mark.asyncio\n+    async def test_prefers_att_read_over_url(self):\n+        adapter = _make_adapter()\n+        att = _make_attachment_with_read(_OGG_BYTES)\n+\n+        with patch(\n+            \"gateway.platforms.discord.cache_audio_from_bytes\",\n+            return_value=\"/tmp/voice.ogg\",\n+        ) as mock_bytes, patch(\n+            \"gateway.platforms.discord.cache_audio_from_url\",\n+            new_callable=AsyncMock,\n+        ) as mock_url:\n+            result = await adapter._cache_discord_audio(att, \".ogg\")\n+\n+        assert result == \"/tmp/voice.ogg\"\n+        mock_bytes.assert_called_once_with(_OGG_BYTES, ext=\".ogg\")\n+        mock_url.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_falls_back_to_url_when_no_read(self):\n+        adapter = _make_adapter()\n+        att = _make_attachment_without_read()\n+\n+        with patch(\n+            \"gateway.platforms.discord.cache_audio_from_url\",\n+            new_callable=AsyncMock,\n+            return_value=\"/tmp/from_url.ogg\",\n+        ) as mock_url:\n+            result = await adapter._cache_discord_audio(att, \".ogg\")\n+\n+        assert result == \"/tmp/from_url.ogg\"\n+        mock_url.assert_awaited_once_with(att.url, ext=\".ogg\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# _cache_discord_document\n+# ---------------------------------------------------------------------------\n+\n+class TestCacheDiscordDocument:\n+    @pytest.mark.asyncio\n+    async def test_prefers_att_read_returns_bytes_directly(self):\n+        \"\"\"Primary path: att.read() → raw bytes, no aiohttp involvement.\"\"\"\n+        adapter = _make_adapter()\n+        att = _make_attachment_with_read(_PDF_BYTES)\n+\n+        with patch(\"aiohttp.ClientSession\") as mock_session:\n+            result = await adapter._cache_discord_document(att, \".pdf\")\n+\n+        assert result == _PDF_BYTES\n+        mock_session.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_fallback_blocked_by_ssrf_guard(self):\n+        \"\"\"Document fallback path now honors is_safe_url — was missing before.\n+\n+        Regression guard for #11345: the old aiohttp block skipped the\n+        SSRF check entirely; a non-CDN ``att.url`` could have reached\n+        internal-looking hosts. The fallback must now refuse unsafe URLs.\n+        \"\"\"\n+        adapter = _make_adapter()\n+        att = _make_attachment_without_read()  # no .read → forces fallback\n+\n+        with patch(\n+            \"gateway.platforms.discord.is_safe_url\", return_value=False\n+        ) as mock_safe, patch(\"aiohttp.ClientSession\") as mock_session:\n+            with pytest.raises(ValueError, match=\"SSRF\"):\n+                await adapter._cache_discord_document(att, \".pdf\")\n+\n+        mock_safe.assert_called_once_with(att.url)\n+        # aiohttp must NOT be contacted when the URL is blocked.\n+        mock_session.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_fallback_aiohttp_when_safe_url(self):\n+        \"\"\"Safe URL + no att.read() → aiohttp fallback executes.\"\"\"\n+        adapter = _make_adapter()\n+        att = _make_attachment_without_read()\n+\n+        # Build an aiohttp session mock that returns 200 + payload.\n+        resp = AsyncMock()\n+        resp.status = 200\n+        resp.read = AsyncMock(return_value=_PDF_BYTES)\n+        resp.__aenter__ = AsyncMock(return_value=resp)\n+        resp.__aexit__ = AsyncMock(return_value=False)\n+\n+        session = AsyncMock()\n+        session.get = MagicMock(return_value=resp)\n+        session.__aenter__ = AsyncMock(return_value=session)\n+        session.__aexit__ = AsyncMock(return_value=False)\n+\n+        with patch(\n+            \"gateway.platforms.discord.is_safe_url\", return_value=True\n+        ), patch(\"aiohttp.ClientSession\", return_value=session):\n+            result = await adapter._cache_discord_document(att, \".pdf\")\n+\n+        assert result == _PDF_BYTES\n+\n+\n+# ---------------------------------------------------------------------------\n+# Integration: end-to-end via _handle_message\n+# ---------------------------------------------------------------------------\n+\n+class TestHandleMessageUsesAuthenticatedRead:\n+    \"\"\"E2E: verify _handle_message routes image/audio downloads through\n+    att.read() so cdn.discordapp.com 403s (#8242) and SSRF false-positives\n+    on mangled DNS (#6587) no longer block media caching.\n+    \"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_image_downloads_via_att_read_not_url(self, monkeypatch):\n+        \"\"\"Image attachments with .read() never call cache_image_from_url.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))\n+        adapter.handle_message = AsyncMock()\n+\n+        with patch(\n+            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            return_value=\"/tmp/img_from_read.png\",\n+        ), patch(\n+            \"gateway.platforms.discord.cache_image_from_url\",\n+            new_callable=AsyncMock,\n+        ) as mock_url_download:\n+            att = SimpleNamespace(\n+                url=\"https://cdn.discordapp.com/attachments/fake/file.png\",\n+                filename=\"file.png\",\n+                content_type=\"image/png\",\n+                size=len(_PNG_BYTES),\n+                read=AsyncMock(return_value=_PNG_BYTES),\n+            )\n+            # Minimal Discord message stub for _handle_message.\n+            from datetime import datetime, timezone\n+\n+            class _FakeDMChannel:\n+                id = 100\n+                name = \"dm\"\n+\n+            # Patch the DMChannel isinstance check so our fake counts as DM.\n+            monkeypatch.setattr(\n+                \"gateway.platforms.discord.discord.DMChannel\",\n+                _FakeDMChannel,\n+            )\n+            chan = _FakeDMChannel()\n+            msg = SimpleNamespace(\n+                id=1, content=\"\", attachments=[att], mentions=[],\n+                reference=None,\n+                created_at=datetime.now(timezone.utc),\n+                channel=chan,\n+                author=SimpleNamespace(id=42, display_name=\"U\", name=\"U\"),\n+            )\n+            await adapter._handle_message(msg)\n+\n+        mock_url_download.assert_not_called()\n+        event = adapter.handle_message.call_args[0][0]\n+        assert event.media_urls == [\"/tmp/img_from_read.png\"]\n+        assert event.media_types == [\"image/png\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}