From f8aef2e4e0f5abe5d80aa8361f4b87bb167cabab Mon Sep 17 00:00:00 2001 From: luyifan Date: Tue, 30 Jun 2026 10:14:43 +0800 Subject: [PATCH] Bound Slack response_url error reads --- plugins/platforms/slack/adapter.py | 32 +++++++++++++- tests/gateway/test_slack.py | 68 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index cd9fd57947a9..9329942f6f20 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -64,6 +64,36 @@ except ImportError: # pragma: no cover - plugin loaded outside package context logger = logging.getLogger(__name__) +_SLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024 + + +async def _read_error_text_limited( + response: Any, + *, + limit: int = _SLACK_ERROR_BODY_LIMIT_BYTES, +) -> str: + content = getattr(response, "content", None) + read = getattr(content, "read", None) + if callable(read): + chunks: list[bytes] = [] + total = 0 + while total <= limit: + size = min(4096, limit + 1 - total) + chunk = await read(size) + if not chunk: + break + data = bytes(chunk) + chunks.append(data) + total += len(data) + if total > limit: + release = getattr(response, "release", None) + if callable(release): + release() + return b"".join(chunks)[:limit].decode("utf-8", errors="replace") + + text = await response.text() + return str(text)[:limit] + # ContextVar carrying the user_id of the slash-command invoker. # Set in _handle_slash_command, read in send() to match the correct # stashed response_url when multiple users issue commands on the same @@ -1175,7 +1205,7 @@ class SlackAdapter(BasePlatformAdapter): ) as resp: if resp.status == 200: return SendResult(success=True, message_id=None) - body = await resp.text() + body = await _read_error_text_limited(resp) logger.warning( "[Slack] response_url POST returned %s: %s", resp.status, diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index fcdcc31657c8..f89cad658531 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -5322,6 +5322,7 @@ class TestSlashEphemeralAck: mock_resp = AsyncMock() mock_resp.status = 500 + mock_resp.content = None mock_resp.text = AsyncMock(return_value="Internal Server Error") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) @@ -5343,6 +5344,73 @@ class TestSlashEphemeralAck: # Still success — the user saw the initial ack already assert result.success is True + @pytest.mark.asyncio + async def test_send_slash_ephemeral_limits_error_body(self, adapter): + """response_url failures should not read oversized bodies unbounded.""" + import time + + class _FakeContent: + def __init__(self, payload: bytes): + self._payload = payload + self._offset = 0 + self.bytes_read = 0 + + async def read(self, size: int = -1): + if size is None or size < 0: + size = len(self._payload) - self._offset + chunk = self._payload[self._offset : self._offset + size] + self._offset += len(chunk) + self.bytes_read += len(chunk) + return chunk + + class _FakeResponse: + status = 500 + + def __init__(self, text: str): + self.content = _FakeContent(text.encode("utf-8")) + self.text_calls = 0 + self.released = False + + async def text(self): + self.text_calls += 1 + return "should not be called" + + def release(self): + self.released = True + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + adapter._slash_command_contexts[("C1", "U1")] = { + "response_url": "https://hooks.slack.com/commands/oversized", + "ts": time.monotonic(), + } + response = _FakeResponse( + ("slack response_url failure " * 1000) + "tail-marker" + ) + + mock_session = AsyncMock() + mock_session.post = MagicMock(return_value=response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch( + "plugins.platforms.slack.adapter.aiohttp.ClientSession", + return_value=mock_session, + ): + result = await adapter.send("C1", "Some response") + + assert result.success is True + assert response.text_calls == 0 + assert ( + response.content.bytes_read + == _slack_mod._SLACK_ERROR_BODY_LIMIT_BYTES + 1 + ) + assert response.released is True + @pytest.mark.asyncio async def test_send_slash_ephemeral_fallback_on_exception(self, adapter): """_send_slash_ephemeral returns success=True even if aiohttp raises."""