{"repo":"NousResearch/hermes-agent","public_id":"3777","display_label":"SSRF URL safety check causing false-positive blocks","summary":"Multiple issues and fixes address the SSRF protection logic incorrectly blocking legitimate traffic, including RFC 2544 and NAT64 address ranges, Discord CDN URLs, and QQ/Tencent CDN domains under TUN/DNS-rewriting proxy setups. Work spans allowlisting specific ranges, adding configurable trusted hosts, and adjusting how synthesized IPv6 addresses are evaluated.","subsystem":"agent","counts":{"members":22,"open_issues":3,"open_issues_distinct":3,"open_prs":4,"closed_issues":5,"merged_prs":3,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11567},"resolution":{"state":"partially_resolved","by":"pr-11567,pr-11568"},"freshness":{"state":"stale","last_verified_at":"2026-04-22T21:39:00+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.0883116883116883},"fix_edges":[{"issue_number":3777,"pr_number":14166,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6511,"pr_number":11568,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6587,"pr_number":11568,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8242,"pr_number":11568,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11284,"pr_number":11567,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11345,"pr_number":11568,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3777,"title":"[Bug]: RFC 2544 benchmark range (198.18.0.0/15) misclassified as private, breaking TUN proxy users","url":null,"state":"closed"},{"kind":"issue","number":6511,"title":"Discord image/audio attachments fail under DNS-rewriting proxies (false-positive SSRF block from #5944)","url":null,"state":"closed"},{"kind":"issue","number":6587,"title":"Discord media auto-processing fails because SSRF check blocks cdn.discordapp.com","url":null,"state":"closed"},{"kind":"issue","number":11284,"title":"QQbot cannot process images: url_safety blocks Tencent CDN domain due to local proxy IP flagged as private","url":null,"state":"closed"},{"kind":"issue","number":19992,"title":"url_safety.py incorrectly blocks RFC 2544 (198.18.0.0/15) used by Clash TUN Fake IP","url":null,"state":"closed"},{"kind":"issue","number":21379,"title":"test: Discord document handling tests fail due to SSRF protection blocking fake CDN URLs","url":null,"state":"open"},{"kind":"issue","number":38048,"title":"url_safety false-positively blocks DNS64/NAT64 addresses in 64:ff9b::/96","url":null,"state":"open"},{"kind":"issue","number":47123,"title":"[qqbot] url_safety whitelist blocks C2C file attachment downloads from grouptalk.c2c.qq.com","url":null,"state":"open"},{"kind":"pr","number":3779,"title":"fix(security): allow RFC 2544 range bypass for TUN-mode proxy / Fake-IP users          ","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14166","number":14166}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":3833,"title":"fix(security): allow RFC 2544 benchmark range for TUN/Fake-IP proxy users","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":6512,"title":"fix(gateway): bypass SSRF check for Discord SDK-supplied attachment URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"53da34a4f","number":0},{"kind":"commit","value":"53da34a4fc2e408d3545b607b3c0b298a6d21e84","number":0},{"kind":"pr","value":"#11568","number":11568},{"kind":"pr","value":"#6587","number":6587},{"kind":"pr","value":"#6511","number":6511},{"kind":"pr","value":"#11568","number":11568}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":11388,"title":"fix(url-safety): allow trusted QQ CDN benchmark resolution","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11567,"title":"fix(qqbot): Batch-2 salvage — SSRF allowlist, voice preflight, group auth, WS reconnect wait","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/qqbot.py","gateway/run.py","scripts/release.py","tests/gateway/test_qqbot.py","tests/gateway/test_unauthorized_dm_behavior.py","tests/tools/test_url_safety.py","tools/url_safety.py"],"fix_hunks":[{"file":"gateway/platforms/qqbot.py","hunks":["@@ -64,6 +64,7 @@\n     MessageEvent,\n     MessageType,\n     SendResult,\n+    _ssrf_redirect_guard,\n     cache_document_from_bytes,\n     cache_image_from_bytes,\n )","@@ -226,7 +227,11 @@ async def connect(self) -> bool:\n             return False\n \n         try:\n-            self._http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True)\n+            self._http_client = httpx.AsyncClient(\n+                timeout=30.0,\n+                follow_redirects=True,\n+                event_hooks={\"response\": [_ssrf_redirect_guard]},\n+            )\n \n             # 1. Get access token\n             await self._ensure_token()","@@ -1101,6 +1106,11 @@ async def _stt_voice_attachment(\n             is_pre_wav = True\n             logger.info(\"[QQ] STT: using voice_wav_url (pre-converted WAV)\")\n \n+        from tools.url_safety import is_safe_url\n+        if not is_safe_url(download_url):\n+            logger.warning(\"[QQ] STT blocked unsafe URL: %s\", download_url[:80])\n+            return None\n+\n         try:\n             # 2. Download audio (QQ CDN requires Authorization header)\n             if not self._http_client:","@@ -1525,6 +1535,33 @@ async def _upload_media(\n \n         raise last_exc  # type: ignore[misc]\n \n+    # Maximum time (seconds) to wait for reconnection before giving up on send.\n+    _RECONNECT_WAIT_SECONDS = 15.0\n+    # How often (seconds) to poll is_connected while waiting.\n+    _RECONNECT_POLL_INTERVAL = 0.5\n+\n+    async def _wait_for_reconnection(self) -> bool:\n+        \"\"\"Wait for the WebSocket listener to reconnect.\n+\n+        The listener loop (_listen_loop) auto-reconnects on disconnect, but\n+        there is a race window where send() is called right after a disconnect\n+        and before the reconnect completes.  This method polls is_connected\n+        for up to _RECONNECT_WAIT_SECONDS.\n+\n+        Returns True if reconnected, False if still disconnected.\n+        \"\"\"\n+        logger.info(\"[%s] Not connected — waiting for reconnection (up to %.0fs)\",\n+                    self.name, self._RECONNECT_WAIT_SECONDS)\n+        waited = 0.0\n+        while waited < self._RECONNECT_WAIT_SECONDS:\n+            await asyncio.sleep(self._RECONNECT_POLL_INTERVAL)\n+            waited += self._RECONNECT_POLL_INTERVAL\n+            if self.is_connected:\n+                logger.info(\"[%s] Reconnected after %.1fs\", self.name, waited)\n+                return True\n+        logger.warning(\"[%s] Still not connected after %.0fs\", self.name, self._RECONNECT_WAIT_SECONDS)\n+        return False\n+\n     async def send(\n         self,\n         chat_id: str,","@@ -1540,7 +1577,8 @@ async def send(\n         del metadata\n \n         if not self.is_connected:\n-            return SendResult(success=False, error=\"Not connected\")\n+            if not await self._wait_for_reconnection():\n+                return SendResult(success=False, error=\"Not connected\", retryable=True)\n \n         if not content or not content.strip():\n             return SendResult(success=True)","@@ -1741,7 +1779,8 @@ async def _send_media(\n     ) -> SendResult:\n         \"\"\"Upload media and send as a native message.\"\"\"\n         if not self.is_connected:\n-            return SendResult(success=False, error=\"Not connected\")\n+            if not await self._wait_for_reconnection():\n+                return SendResult(success=False, error=\"Not connected\", retryable=True)\n \n         try:\n             # Resolve media source"]},{"file":"gateway/run.py","hunks":["@@ -2618,6 +2618,9 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             Platform.BLUEBUBBLES: \"BLUEBUBBLES_ALLOWED_USERS\",\n             Platform.QQBOT: \"QQ_ALLOWED_USERS\",\n         }\n+        platform_group_env_map = {\n+            Platform.QQBOT: \"QQ_GROUP_ALLOWED_USERS\",\n+        }\n         platform_allow_all_map = {\n             Platform.TELEGRAM: \"TELEGRAM_ALLOW_ALL_USERS\",\n             Platform.DISCORD: \"DISCORD_ALLOW_ALL_USERS\",","@@ -2649,12 +2652,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n \n         # Check platform-specific and global allowlists\n         platform_allowlist = os.getenv(platform_env_map.get(source.platform, \"\"), \"\").strip()\n+        group_allowlist = \"\"\n+        if source.chat_type == \"group\":\n+            group_allowlist = os.getenv(platform_group_env_map.get(source.platform, \"\"), \"\").strip()\n         global_allowlist = os.getenv(\"GATEWAY_ALLOWED_USERS\", \"\").strip()\n \n-        if not platform_allowlist and not global_allowlist:\n+        if not platform_allowlist and not group_allowlist and not global_allowlist:\n             # No allowlists configured -- check global allow-all flag\n             return os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in (\"true\", \"1\", \"yes\")\n \n+        # Some platforms authorize group traffic by chat ID rather than sender ID.\n+        if group_allowlist and source.chat_type == \"group\" and source.chat_id:\n+            allowed_group_ids = {\n+                chat_id.strip() for chat_id in group_allowlist.split(\",\") if chat_id.strip()\n+            }\n+            if \"*\" in allowed_group_ids or source.chat_id in allowed_group_ids:\n+                return True\n+\n         # Check if user is in any allowlist\n         allowed_ids = set()\n         if platform_allowlist:"]},{"file":"scripts/release.py","hunks":["@@ -96,6 +96,7 @@\n     \"[redacted:email]\": \"wakamex\",\n     \"[redacted:email]\": \"clawdia-nash\",\n     \"[redacted:email]\": \"austinpickett\",\n+    \"[redacted:email]\": \"dieutx\",\n     \"[redacted:email]\": \"jaisup\",\n     \"[redacted:email]\": \"PercyDikec\",\n     \"[redacted:email]\": \"deankerr\",","@@ -235,6 +236,7 @@\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"v1k22\",\n     \"[redacted:email]\": \"RucchiZ\",\n+    \"[redacted:email]\": \"LehaoLin\",\n }\n \n "]},{"file":"tests/gateway/test_qqbot.py","hunks":["@@ -1,5 +1,6 @@\n \"\"\"Tests for the QQ Bot platform adapter.\"\"\"\n \n+import asyncio\n import json\n import os\n import sys","@@ -149,6 +150,47 @@ def test_audio_extension_amr(self):\n         assert self._fn(\"\", \"recording.amr\") is True\n \n \n+# ---------------------------------------------------------------------------\n+# Voice attachment SSRF protection\n+# ---------------------------------------------------------------------------\n+\n+class TestVoiceAttachmentSSRFProtection:\n+    def _make_adapter(self, **extra):\n+        from gateway.platforms.qqbot import QQAdapter\n+        return QQAdapter(_make_config(**extra))\n+\n+    def test_stt_blocks_unsafe_download_url(self):\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._http_client = mock.AsyncMock()\n+\n+        with mock.patch(\"tools.url_safety.is_safe_url\", return_value=False):\n+            transcript = asyncio.run(\n+                adapter._stt_voice_attachment(\n+                    \"http://127.0.0.1/voice.silk\",\n+                    \"audio/silk\",\n+                    \"voice.silk\",\n+                )\n+            )\n+\n+        assert transcript is None\n+        adapter._http_client.get.assert_not_called()\n+\n+    def test_connect_uses_redirect_guard_hook(self):\n+        from gateway.platforms.qqbot import QQAdapter, _ssrf_redirect_guard\n+\n+        client = mock.AsyncMock()\n+        with mock.patch(\"gateway.platforms.qqbot.httpx.AsyncClient\", return_value=client) as async_client_cls:\n+            adapter = QQAdapter(_make_config(app_id=\"a\", client_secret=\"b\"))\n+            adapter._ensure_token = [redacted:secret-assignment]\"stop after client creation\"))\n+\n+            connected = asyncio.run(adapter.connect())\n+\n+        assert connected is False\n+        assert async_client_cls.call_count == 1\n+        kwargs = async_client_cls.call_args.kwargs\n+        assert kwargs.get(\"follow_redirects\") is True\n+        assert kwargs.get(\"event_hooks\", {}).get(\"response\") == [_ssrf_redirect_guard]\n+\n # ---------------------------------------------------------------------------\n # _strip_at_mention\n # ---------------------------------------------------------------------------","@@ -458,3 +500,85 @@ def test_reply_to(self):\n         adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\", markdown_support=False)\n         body = adapter._build_text_body(\"reply text\", reply_to=\"msg_123\")\n         assert body.get(\"message_reference\", {}).get(\"message_id\") == \"msg_123\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _wait_for_reconnection / send reconnection wait\n+# ---------------------------------------------------------------------------\n+\n+class TestWaitForReconnection:\n+    \"\"\"Test that send() waits for reconnection instead of silently dropping.\"\"\"\n+\n+    def _make_adapter(self, **extra):\n+        from gateway.platforms.qqbot import QQAdapter\n+        return QQAdapter(_make_config(**extra))\n+\n+    @pytest.mark.asyncio\n+    async def test_send_waits_and_succeeds_on_reconnect(self):\n+        \"\"\"send() should wait for reconnection and then deliver the message.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        # Initially disconnected\n+        adapter._running = False\n+        adapter._http_client = mock.MagicMock()\n+\n+        # Simulate reconnection after 0.3s (faster than real interval)\n+        async def fake_api_request(*args, **kwargs):\n+            return {\"id\": \"msg_123\"}\n+\n+        adapter._api_request = fake_api_request\n+        adapter._ensure_token = [redacted:secret-assignment]\n+        adapter._RECONNECT_POLL_INTERVAL = 0.1\n+        adapter._RECONNECT_WAIT_SECONDS = 5.0\n+\n+        # Schedule reconnection after a short delay\n+        async def reconnect_after_delay():\n+            await asyncio.sleep(0.3)\n+            adapter._running = True\n+\n+        asyncio.get_event_loop().create_task(reconnect_after_delay())\n+\n+        result = await adapter.send(\"test_openid\", \"Hello, world!\")\n+        assert result.success\n+        assert result.message_id == \"msg_123\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_returns_retryable_after_timeout(self):\n+        \"\"\"send() should return retryable=True if reconnection takes too long.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._running = False\n+        adapter._RECONNECT_POLL_INTERVAL = 0.05\n+        adapter._RECONNECT_WAIT_SECONDS = 0.2\n+\n+        result = await adapter.send(\"test_openid\", \"Hello, world!\")\n+        assert not result.success\n+        assert result.retryable is True\n+        assert \"Not connected\" in result.error\n+\n+    @pytest.mark.asyncio\n+    async def test_send_succeeds_immediately_when_connected(self):\n+        \"\"\"send() should not wait when already connected.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._running = True\n+        adapter._http_client = mock.MagicMock()\n+\n+        async def fake_api_request(*args, **kwargs):\n+            return {\"id\": \"msg_immediate\"}\n+\n+        adapter._api_request = fake_api_request\n+\n+        result = await adapter.send(\"test_openid\", \"Hello!\")\n+        assert result.success\n+        assert result.message_id == \"msg_immediate\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_media_waits_for_reconnect(self):\n+        \"\"\"_send_media should also wait for reconnection.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._running = False\n+        adapter._RECONNECT_POLL_INTERVAL = 0.05\n+        adapter._RECONNECT_WAIT_SECONDS = 0.2\n+\n+        result = await adapter._send_media(\"test_openid\", \"http://example.com/img.jpg\", 1, \"image\")\n+        assert not result.success\n+        assert result.retryable is True\n+        assert \"Not connected\" in result.error"]},{"file":"tests/gateway/test_unauthorized_dm_behavior.py","hunks":["@@ -21,6 +21,7 @@ def _clear_auth_env(monkeypatch) -> None:\n         \"MATTERMOST_ALLOWED_USERS\",\n         \"MATRIX_ALLOWED_USERS\",\n         \"DINGTALK_ALLOWED_USERS\", \"FEISHU_ALLOWED_USERS\", \"WECOM_ALLOWED_USERS\",\n+        \"QQ_ALLOWED_USERS\", \"QQ_GROUP_ALLOWED_USERS\",\n         \"GATEWAY_ALLOWED_USERS\",\n         \"TELEGRAM_ALLOW_ALL_USERS\",\n         \"DISCORD_ALLOW_ALL_USERS\",","@@ -32,6 +33,7 @@ def _clear_auth_env(monkeypatch) -> None:\n         \"MATTERMOST_ALLOW_ALL_USERS\",\n         \"MATRIX_ALLOW_ALL_USERS\",\n         \"DINGTALK_ALLOW_ALL_USERS\", \"FEISHU_ALLOW_ALL_USERS\", \"WECOM_ALLOW_ALL_USERS\",\n+        \"QQ_ALLOW_ALL_USERS\",\n         \"GATEWAY_ALLOW_ALL_USERS\",\n     ):\n         monkeypatch.delenv(key, raising=False)","@@ -130,6 +132,46 @@ def test_star_wildcard_works_for_any_platform(monkeypatch):\n     assert runner._is_user_authorized(source) is True\n \n \n+def test_qq_group_allowlist_authorizes_group_chat_without_user_allowlist(monkeypatch):\n+    _clear_auth_env(monkeypatch)\n+    monkeypatch.setenv(\"QQ_GROUP_ALLOWED_USERS\", \"group-openid-1\")\n+\n+    runner, _adapter = _make_runner(\n+        Platform.QQBOT,\n+        GatewayConfig(platforms={Platform.QQBOT: PlatformConfig(enabled=True)}),\n+    )\n+\n+    source = SessionSource(\n+        platform=Platform.QQBOT,\n+        user_id=\"member-openid-999\",\n+        chat_id=\"group-openid-1\",\n+        user_name=\"tester\",\n+        chat_type=\"group\",\n+    )\n+\n+    assert runner._is_user_authorized(source) is True\n+\n+\n+def test_qq_group_allowlist_does_not_authorize_other_groups(monkeypatch):\n+    _clear_auth_env(monkeypatch)\n+    monkeypatch.setenv(\"QQ_GROUP_ALLOWED_USERS\", \"group-openid-1\")\n+\n+    runner, _adapter = _make_runner(\n+        Platform.QQBOT,\n+        GatewayConfig(platforms={Platform.QQBOT: PlatformConfig(enabled=True)}),\n+    )\n+\n+    source = SessionSource(\n+        platform=Platform.QQBOT,\n+        user_id=\"member-openid-999\",\n+        chat_id=\"group-openid-2\",\n+        user_name=\"tester\",\n+        chat_type=\"group\",\n+    )\n+\n+    assert runner._is_user_authorized(source) is False\n+\n+\n @pytest.mark.asyncio\n async def test_unauthorized_dm_pairs_by_default(monkeypatch):\n     _clear_auth_env(monkeypatch)"]},{"file":"tests/tools/test_url_safety.py","hunks":["@@ -152,6 +152,34 @@ def test_non_cgnat_100_allowed(self):\n             # 100.0.0.1 is a global IP, not in CGNAT range\n             assert is_safe_url(\"http://legit-host.example/\") is True\n \n+    def test_benchmark_ip_blocked_for_non_allowlisted_host(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"https://example.com/file.jpg\") is False\n+\n+    def test_qq_multimedia_hostname_allowed_with_benchmark_ip(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"https://multimedia.nt.qq.com.cn/download?id=123\") is True\n+\n+    def test_qq_multimedia_hostname_exception_is_exact_match(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"https://sub.multimedia.nt.qq.com.cn/download?id=123\") is False\n+\n+    def test_qq_multimedia_hostname_exception_requires_https(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"http://multimedia.nt.qq.com.cn/download?id=123\") is False\n+\n+    def test_qq_multimedia_hostname_dns_failure_still_blocked(self):\n+        with patch(\"socket.getaddrinfo\", side_effect=socket.gaierror(\"Name resolution failed\")):\n+            assert is_safe_url(\"https://multimedia.nt.qq.com.cn/download?id=123\") is False\n+\n \n class TestIsBlockedIp:\n     \"\"\"Direct tests for the _is_blocked_ip helper.\"\"\"","@@ -159,7 +187,7 @@ class TestIsBlockedIp:\n     @pytest.mark.parametrize(\"ip_str\", [\n         \"127.0.0.1\", \"10.0.0.1\", \"172.16.0.1\", \"192.168.1.1\",\n         \"169.254.169.254\", \"0.0.0.0\", \"224.0.0.1\", \"255.255.255.255\",\n-        \"100.64.0.1\", \"100.100.100.100\", \"100.127.255.254\",\n+        \"100.64.0.1\", \"100.100.100.100\", \"100.127.255.254\", \"198.18.0.23\",\n         \"::1\", \"fe80::1\", \"fc00::1\", \"fd12::1\", \"ff02::1\",\n         \"::ffff:127.0.0.1\", \"::ffff:169.254.169.254\",\n     ])"]},{"file":"tools/url_safety.py","hunks":["@@ -29,6 +29,13 @@\n     \"metadata.goog\",\n })\n \n+# Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs.\n+# This is intentionally narrow: QQ media downloads can legitimately resolve\n+# to 198.18.0.0/15 behind local proxy/benchmark infrastructure.\n+_TRUSTED_PRIVATE_IP_HOSTS = frozenset({\n+    \"multimedia.nt.qq.com.cn\",\n+})\n+\n # 100.64.0.0/10 (CGNAT / Shared Address Space, RFC 6598) is NOT covered by\n # ipaddress.is_private — it returns False for both is_private and is_global.\n # Must be blocked explicitly. Used by carrier-grade NAT, Tailscale/WireGuard","@@ -48,6 +55,11 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:\n     return False\n \n \n+def _allows_private_ip_resolution(hostname: str, scheme: str) -> bool:\n+    \"\"\"Return True when a trusted HTTPS hostname may bypass IP-class blocking.\"\"\"\n+    return scheme == \"https\" and hostname in _TRUSTED_PRIVATE_IP_HOSTS\n+\n+\n def is_safe_url(url: str) -> bool:\n     \"\"\"Return True if the URL target is not a private/internal address.\n ","@@ -56,7 +68,8 @@ def is_safe_url(url: str) -> bool:\n     \"\"\"\n     try:\n         parsed = urlparse(url)\n-        hostname = (parsed.hostname or \"\").strip().lower()\n+        hostname = (parsed.hostname or \"\").strip().lower().rstrip(\".\")\n+        scheme = (parsed.scheme or \"\").strip().lower()\n         if not hostname:\n             return False\n ","@@ -65,6 +78,8 @@ def is_safe_url(url: str) -> bool:\n             logger.warning(\"Blocked request to internal hostname: %s\", hostname)\n             return False\n \n+        allow_private_ip = _allows_private_ip_resolution(hostname, scheme)\n+\n         # Try to resolve and check IP\n         try:\n             addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)","@@ -81,13 +96,19 @@ def is_safe_url(url: str) -> bool:\n             except ValueError:\n                 continue\n \n-            if _is_blocked_ip(ip):\n+            if not allow_private_ip and _is_blocked_ip(ip):\n                 logger.warning(\n                     \"Blocked request to private/internal address: %s -> %s\",\n                     hostname, ip_str,\n                 )\n                 return False\n \n+        if allow_private_ip:\n+            logger.debug(\n+                \"Allowing trusted hostname despite private/internal resolution: %s\",\n+                hostname,\n+            )\n+\n         return True\n \n     except Exception as exc:\n"]}]},{"kind":"pr","number":11568,"title":"fix(discord): route attachment downloads through authenticated bot session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"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"]}]},{"kind":"pr","number":12256,"title":"fix(url_safety): allow NAT64 well-known prefix (RFC 6052)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51758","number":51758},{"kind":"pr","value":"#51758","number":51758}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":14166,"title":"feat(security): global toggle to allow private/internal URL resolution","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/tools/test_url_safety.py","tools/url_safety.py"]},{"kind":"pr","number":21386,"title":"test(gateway): mock SSRF URL guard in Discord document handling tests","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"cc8e5ec2a","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":36700,"title":"feat(security): make trusted private-IP hosts configurable via security.trusted_private_hosts","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38058,"title":"fix(url_safety): evaluate embedded IPv4 for NAT64 64:ff9b::/96 addresses","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":38071,"title":"fix(url_safety): handle DNS64/NAT64 synthesized addresses in SSRF check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38058","number":38058},{"kind":"pr","value":"#12256","number":12256}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":107724,"title":"fix(qqbot): allow grouptalk.c2c.qq.com in the url_safety private-IP bypass (#47123)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#47177","number":47177},{"kind":"pr","value":"#47123","number":47123},{"kind":"pr","value":"#85256","number":85256},{"kind":"pr","value":"#47177","number":47177}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":131466,"title":"fix(url_safety): classify the NAT64 well-known prefix by its embedded IPv4","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#12256","number":12256},{"kind":"pr","value":"#51758","number":51758},{"kind":"pr","value":"#51758","number":51758}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}