diff --git a/backend/app/channels/slack.py b/backend/app/channels/slack.py index 7b8522c0e7..83d552560a 100644 --- a/backend/app/channels/slack.py +++ b/backend/app/channels/slack.py @@ -7,17 +7,39 @@ import html import logging import re from typing import Any +from urllib.parse import urlsplit +import httpx from markdown_to_mrkdwn import SlackMarkdownConverter from app.channels.allowed_users import parse_allowed_users, parse_string_or_int_user_id from app.channels.base import Channel, ChannelStopTimeout from app.channels.commands import is_known_channel_command from app.channels.connection_identity import attach_connection_identity -from app.channels.message_bus import InboundMessageType, InboundReservation, MessageBus, OutboundMessage, ResolvedAttachment +from app.channels.message_bus import ( + INBOUND_FILE_CONTENT_KEY, + InboundMessage, + InboundMessageType, + InboundReservation, + MessageBus, + OutboundMessage, + ResolvedAttachment, +) +from deerflow.uploads.manager import is_upload_staging_file, normalize_filename logger = logging.getLogger(__name__) SLACK_STOP_TIMEOUT_SECONDS = 5.0 +# Match the manager's URL download ceiling. Slack workspace limits may be higher; +# this keeps a single upload from exhausting Gateway memory. +SLACK_MAX_INBOUND_FILE_BYTES = 50 * 1024 * 1024 +# Cap total in-memory bytes for one file_share message (N files × per-file max). +SLACK_MAX_INBOUND_MESSAGE_BYTES = SLACK_MAX_INBOUND_FILE_BYTES +# Private download URLs are Slack-hosted; gate Bearer fetches to this suffix. +SLACK_ALLOWED_FILE_HOST_SUFFIXES = frozenset({"files.slack.com"}) +# User-authored subtypes we still want to turn into chat turns. Everything else +# (edits, deletes, joins, bot_message, thread_broadcast, …) stays ignored so we +# do not double-fire or treat platform noise as a user message. +_SLACK_ACCEPTED_MESSAGE_SUBTYPES = frozenset({"file_share"}) _slack_md_converter = SlackMarkdownConverter() @@ -292,15 +314,19 @@ class SlackChannel(Channel): except Exception: logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True) - async def _get_web_client_for_message(self, msg: OutboundMessage): - if msg.connection_id and self._connection_repo is not None: - credentials = await self._connection_repo.get_credentials(msg.connection_id) + async def _web_client_for_connection(self, connection_id: str | None): + """Resolve a WebClient for *connection_id*, falling back to the operator client. + + WebClient keeps its own HTTP session and rate-limit state, so reuse one + per connection until its token changes. Shared by outbound sends and + inbound private-file downloads so the cache cannot drift. + """ + if connection_id and self._connection_repo is not None: + credentials = await self._connection_repo.get_credentials(connection_id) access_token = credentials.get("access_token") if credentials else None if not access_token: return self._web_client - # WebClient keeps its own HTTP session and rate-limit state, so - # reuse one per connection until its token changes. - cached = self._connection_web_clients.get(msg.connection_id) + cached = self._connection_web_clients.get(connection_id) if cached is not None and cached[0] == access_token: return cached[1] if self._web_client_factory is None: @@ -308,10 +334,13 @@ class SlackChannel(Channel): self._web_client_factory = WebClient web_client = self._web_client_factory(token=access_token) - self._connection_web_clients[msg.connection_id] = (access_token, web_client) + self._connection_web_clients[connection_id] = (access_token, web_client) return web_client return self._web_client + async def _get_web_client_for_message(self, msg: OutboundMessage): + return await self._web_client_for_connection(msg.connection_id) + @staticmethod def _add_reaction_with_client(web_client, channel_id: str, timestamp: str, emoji: str) -> None: try: @@ -377,8 +406,11 @@ class SlackChannel(Channel): logger.exception("Error processing Slack event") def _handle_message_event(self, event: dict, *, team_id: str | None = None) -> None: - # Ignore bot messages - if event.get("bot_id") or event.get("subtype"): + # Ignore bot traffic and platform subtypes we do not handle as chat. + # file_share is a user upload (optional caption + files[]) and must not + # be dropped just because Slack sets subtype. + subtype = event.get("subtype") + if event.get("bot_id") or (subtype and subtype not in _SLACK_ACCEPTED_MESSAGE_SUBTYPES): return user_id = event.get("user", "") @@ -389,7 +421,8 @@ class SlackChannel(Channel): # Decode after mention stripping: a real mention is a raw <@...>, while # a user-typed "<@...>" arrives as <@...> and must stay text. text = _unescape_slack_text(text) - if not text: + files = self._extract_inbound_files(event) + if not text and not files: return connect_code = self._pending_connect_code(text) @@ -432,6 +465,7 @@ class SlackChannel(Channel): text=text, msg_type=msg_type, thread_ts=thread_ts, + files=files, metadata={ # team_id is already resolved (payload team_id/team, else event team) by the caller. "team_id": team_id, @@ -468,6 +502,226 @@ class SlackChannel(Channel): reservation.release() logger.info("[Slack] main loop stopped before reserved inbound could be scheduled") + async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage: + """Download inbound Slack file_share attachments for the shared upload pipeline. + + Slack private download URLs require the bot token in an Authorization + header, so this adapter never hands those URLs to the generic HTTP + reader. Bytes cross to ``ChannelManager`` through + ``INBOUND_FILE_CONTENT_KEY`` (same shape as Telegram). + """ + del thread_id, user_id + if not msg.files: + return msg + + web_client = await self._get_web_client_for_inbound(msg) + token = getattr(web_client, "token", None) if web_client is not None else None + if not isinstance(token, str) or not token: + notice = "[Slack attachment unavailable: bot token missing]" + msg.text = f"{msg.text}\n\n{notice}" if msg.text else notice + msg.files = [] + return msg + + materialized: list[dict[str, Any]] = [] + unavailable: list[str] = [] + materialized_bytes = 0 + + for file_info in msg.files: + if not isinstance(file_info, dict): + continue + + filename = self._safe_inbound_filename(file_info.get("filename"), "attachment") + declared_size = self._file_size(file_info.get("size")) + if declared_size is not None and declared_size > SLACK_MAX_INBOUND_FILE_BYTES: + logger.warning("[Slack] inbound file exceeds download limit, skipping: %s", filename) + unavailable.append(f"{filename} (exceeds the download limit)") + continue + if declared_size is not None and materialized_bytes + declared_size > SLACK_MAX_INBOUND_MESSAGE_BYTES: + logger.warning("[Slack] inbound message exceeds aggregate download limit, skipping: %s", filename) + unavailable.append(f"{filename} (exceeds the message download limit)") + continue + + url = file_info.get("url_private_download") + if not isinstance(url, str) or not url.strip(): + logger.error("[Slack] cannot download inbound file without url_private_download: %s", filename) + unavailable.append(f"{filename} (download unavailable)") + continue + + url = url.strip() + if not self._is_allowed_private_file_url(url): + logger.warning("[Slack] inbound file URL host is not allowlisted, skipping: %s", filename) + unavailable.append(f"{filename} (download unavailable)") + continue + + remaining_budget = SLACK_MAX_INBOUND_MESSAGE_BYTES - materialized_bytes + per_file_cap = min(SLACK_MAX_INBOUND_FILE_BYTES, remaining_budget) + if per_file_cap <= 0: + logger.warning("[Slack] inbound message aggregate download budget exhausted, skipping: %s", filename) + unavailable.append(f"{filename} (exceeds the message download limit)") + continue + + try: + content = await self._download_private_file(url, token, max_bytes=per_file_cap) + except Exception as exc: + logger.error( + "[Slack] failed to download inbound file %s: %s", + filename, + type(exc).__name__, + ) + unavailable.append(f"{filename} (download failed)") + continue + + if content is None: + # Stream aborted for size; encoding refusals raise above. + unavailable.append(f"{filename} (exceeds the download limit)") + continue + + if len(content) > SLACK_MAX_INBOUND_FILE_BYTES: + logger.warning("[Slack] downloaded inbound file exceeds limit, skipping: %s", filename) + unavailable.append(f"{filename} (exceeds the download limit)") + continue + if materialized_bytes + len(content) > SLACK_MAX_INBOUND_MESSAGE_BYTES: + logger.warning("[Slack] downloaded inbound file exceeds aggregate message limit, skipping: %s", filename) + unavailable.append(f"{filename} (exceeds the message download limit)") + continue + + materialized.append( + { + "type": "image" if file_info.get("type") == "image" else "file", + "filename": filename, + "mime_type": file_info.get("mime_type") if isinstance(file_info.get("mime_type"), str) else "application/octet-stream", + "size": len(content), + INBOUND_FILE_CONTENT_KEY: content, + } + ) + materialized_bytes += len(content) + + msg.files = materialized + if unavailable: + notice = f"[Slack attachment unavailable: {', '.join(unavailable)}]" + msg.text = f"{msg.text}\n\n{notice}" if msg.text else notice + return msg + + async def _get_web_client_for_inbound(self, msg: InboundMessage): + """Resolve the WebClient whose token can fetch private Slack files.""" + return await self._web_client_for_connection(msg.connection_id) + + @classmethod + def _is_allowed_private_file_url(cls, url: str) -> bool: + """Gate Bearer downloads to https://*.files.slack.com (and the bare host).""" + try: + parsed = urlsplit(url) + except ValueError: + return False + if parsed.scheme != "https": + return False + host = (parsed.hostname or "").lower() + if not host: + return False + return any(host == suffix or host.endswith(f".{suffix}") for suffix in SLACK_ALLOWED_FILE_HOST_SUFFIXES) + + async def _download_private_file(self, url: str, token: str, *, max_bytes: int = SLACK_MAX_INBOUND_FILE_BYTES) -> bytes | None: + """Stream a Slack private file URL with Bearer auth; never log the URL. + + Returns ``None`` only when the streamed body exceeds *max_bytes*. + A non-identity ``Content-Encoding`` raises so callers can report + ``(download failed)`` instead of a misleading size-limit notice. + """ + chunks: list[bytes] = [] + total = 0 + async with httpx.AsyncClient(timeout=httpx.Timeout(20.0)) as client: + async with client.stream( + "GET", + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept-Encoding": "identity", + }, + ) as response: + response.raise_for_status() + encoding = (response.headers.get("content-encoding") or "").strip().lower() + if encoding and encoding != "identity": + logger.warning( + "[Slack] inbound file response uses Content-Encoding %r, dropping before decode", + encoding, + ) + raise RuntimeError(f"unsupported content-encoding: {encoding}") + async for chunk in response.aiter_raw(): + total += len(chunk) + if total > max_bytes: + logger.warning( + "[Slack] inbound file exceeds %d bytes download limit, dropping", + max_bytes, + ) + return None + chunks.append(chunk) + return b"".join(chunks) + + @classmethod + def _extract_inbound_files(cls, event: dict) -> list[dict[str, Any]]: + """Build token-free file descriptors from a Slack message event.""" + raw_files = event.get("files") + if not isinstance(raw_files, list): + return [] + + files: list[dict[str, Any]] = [] + for index, raw in enumerate(raw_files): + if not isinstance(raw, dict): + continue + download_url = raw.get("url_private_download") or raw.get("url_private") + if not isinstance(download_url, str) or not download_url.strip(): + continue + file_id = raw.get("id") + mime_type = raw.get("mimetype") + if not isinstance(mime_type, str) or not mime_type: + mime_type = "application/octet-stream" + fallback = f"slack-file-{file_id if isinstance(file_id, str) and file_id else index}.bin" + filename = cls._safe_inbound_filename(raw.get("name") or raw.get("title"), fallback) + is_image = mime_type.startswith("image/") or str(raw.get("filetype") or "").lower() in { + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + } + files.append( + { + "type": "image" if is_image else "file", + "file_id": file_id if isinstance(file_id, str) else None, + "filename": filename, + "mime_type": mime_type, + "size": cls._file_size(raw.get("size")), + # Private URL only — Authorization is attached in receive_file. + "url_private_download": download_url.strip(), + } + ) + return files + + @staticmethod + def _safe_inbound_filename(value: Any, fallback: str) -> str: + if not isinstance(value, str): + return fallback + try: + candidate = normalize_filename(value.strip()) + except (UnicodeError, ValueError): + return fallback + if is_upload_staging_file(candidate) or any(ord(char) < 32 for char in candidate): + return fallback + return candidate + + @staticmethod + def _file_size(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value >= 0 else None + if isinstance(value, float) and value.is_integer() and value >= 0: + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + async def _publish_inbound_with_connection( self, inbound, diff --git a/backend/tests/test_slack_file_share.py b/backend/tests/test_slack_file_share.py new file mode 100644 index 0000000000..2f0eac3892 --- /dev/null +++ b/backend/tests/test_slack_file_share.py @@ -0,0 +1,471 @@ +"""Slack file_share / subtype handling for inbound uploads.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.channels.message_bus import INBOUND_FILE_CONTENT_KEY, InboundMessage, MessageBus + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _immediate_loop(): + loop = MagicMock() + loop.is_running.return_value = True + loop.call_soon_threadsafe.side_effect = lambda callback, *args: callback(*args) + return loop + + +def _submit_coro(coro, loop, **_kwargs): + coro.close() + return True + + +def _file_share_event(**overrides): + event = { + "type": "message", + "subtype": "file_share", + "user": "U123456", + "text": "please review", + "channel": "C123", + "ts": "1710000000.000100", + "files": [ + { + "id": "FABC", + "name": "report.pdf", + "title": "report.pdf", + "mimetype": "application/pdf", + "filetype": "pdf", + "size": 4, + "url_private": "https://files.slack.com/files-pri/T-F/report.pdf", + "url_private_download": "https://files.slack.com/files-pri/T-F/download/report.pdf", + } + ], + } + event.update(overrides) + return event + + +class TestSlackFileShareSubtype: + def _channel(self): + from app.channels.slack import SlackChannel + + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"}) + channel._loop = _immediate_loop() + channel._add_reaction = MagicMock() + channel._send_running_reply = MagicMock() + return bus, channel + + def test_file_share_with_caption_publishes_inbound_files(self): + bus, channel = self._channel() + with patch("app.channels.slack.asyncio.run_coroutine_threadsafe", side_effect=_submit_coro): + channel._handle_message_event(_file_share_event()) + + channel._add_reaction.assert_called_once() + inbound = bus.get_inbound_nowait() + assert inbound.text == "please review" + assert len(inbound.files) == 1 + assert inbound.files[0]["filename"] == "report.pdf" + assert inbound.files[0]["type"] == "file" + assert inbound.files[0]["url_private_download"].endswith("/download/report.pdf") + + def test_file_share_without_caption_still_publishes(self): + bus, channel = self._channel() + with patch("app.channels.slack.asyncio.run_coroutine_threadsafe", side_effect=_submit_coro): + channel._handle_message_event(_file_share_event(text="")) + + inbound = bus.get_inbound_nowait() + assert inbound.text == "" + assert inbound.files[0]["filename"] == "report.pdf" + + @pytest.mark.parametrize( + "subtype", + ["message_changed", "message_deleted", "channel_join", "bot_message", "thread_broadcast"], + ) + def test_non_file_share_subtypes_are_still_dropped(self, subtype): + bus, channel = self._channel() + with patch("app.channels.slack.asyncio.run_coroutine_threadsafe", side_effect=_submit_coro): + channel._handle_message_event( + { + "type": "message", + "subtype": subtype, + "user": "U123456", + "text": "should not publish", + "channel": "C123", + "ts": "1710000000.000100", + } + ) + + channel._add_reaction.assert_not_called() + assert bus.inbound_queue.empty() + + def test_bot_id_still_dropped_even_for_file_share(self): + bus, channel = self._channel() + with patch("app.channels.slack.asyncio.run_coroutine_threadsafe", side_effect=_submit_coro): + channel._handle_message_event(_file_share_event(bot_id="B123")) + + channel._add_reaction.assert_not_called() + assert bus.inbound_queue.empty() + + def test_plain_message_without_subtype_still_works(self): + bus, channel = self._channel() + with patch("app.channels.slack.asyncio.run_coroutine_threadsafe", side_effect=_submit_coro): + channel._handle_message_event( + { + "user": "U123456", + "text": "hello", + "channel": "C123", + "ts": "1710000000.000100", + } + ) + + inbound = bus.get_inbound_nowait() + assert inbound.text == "hello" + assert inbound.files == [] + + +class TestSlackExtractInboundFiles: + def test_extracts_image_and_document_descriptors(self): + from app.channels.slack import SlackChannel + + files = SlackChannel._extract_inbound_files( + { + "files": [ + { + "id": "FIMG", + "name": "photo.png", + "mimetype": "image/png", + "filetype": "png", + "size": 8, + "url_private_download": "https://files.slack.com/files-pri/T-F/download/photo.png", + }, + { + "id": "FDOC", + "name": "notes.txt", + "mimetype": "text/plain", + "size": 2, + "url_private_download": "https://files.slack.com/files-pri/T-F/download/notes.txt", + }, + { + "id": "FBAD", + "name": "missing-url.bin", + "mimetype": "application/octet-stream", + }, + ] + } + ) + + assert [item["filename"] for item in files] == ["photo.png", "notes.txt"] + assert files[0]["type"] == "image" + assert files[1]["type"] == "file" + + def test_rejects_upload_staging_filename(self): + from app.channels.slack import SlackChannel + + files = SlackChannel._extract_inbound_files( + { + "files": [ + { + "id": "F1", + "name": ".upload-hidden.part", + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/files-pri/T-F/download/x", + } + ] + } + ) + + assert files[0]["filename"] == "slack-file-F1.bin" + + +class TestSlackReceiveFile: + def test_downloads_bytes_with_bearer_token_and_strips_url(self, monkeypatch): + from app.channels import slack as slack_mod + from app.channels.slack import SlackChannel + + seen: dict[str, str] = {} + + class _FakeStreamResponse: + def __init__(self): + self.headers = {"content-encoding": "identity"} + + def raise_for_status(self) -> None: + return None + + async def aiter_raw(self): + yield b"da" + yield b"ta" + + class _FakeStreamContext: + async def __aenter__(self): + return _FakeStreamResponse() + + async def __aexit__(self, *exc_info): + return False + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + def stream(self, method: str, url: str, **kwargs): + seen["method"] = method + seen["url"] = url + seen["authorization"] = kwargs.get("headers", {}).get("Authorization", "") + seen["accept_encoding"] = kwargs.get("headers", {}).get("Accept-Encoding", "") + return _FakeStreamContext() + + monkeypatch.setattr(slack_mod.httpx, "AsyncClient", _FakeAsyncClient) + + async def go(): + channel = SlackChannel(MessageBus(), {"bot_token": "xoxb-test"}) + channel._web_client = SimpleNamespace(token="xoxb-test") + content = await channel._download_private_file( + "https://files.slack.com/files-pri/T-F/download/report.pdf", + "xoxb-test", + ) + assert content == b"data" + assert seen["method"] == "GET" + assert seen["authorization"] == "Bearer xoxb-test" + assert seen["accept_encoding"] == "identity" + + channel2 = SlackChannel(MessageBus(), {"bot_token": "xoxb-test"}) + channel2._web_client = SimpleNamespace(token="xoxb-test") + + async def fake_download(url: str, token: str, *, max_bytes: int = 0) -> bytes | None: + assert token == "xoxb-test" + assert url.endswith("/download/report.pdf") + assert max_bytes > 0 + return b"data" + + channel2._download_private_file = fake_download # type: ignore[method-assign] + msg = InboundMessage( + channel_name="slack", + chat_id="C123", + user_id="U123", + text="caption", + files=[ + { + "type": "file", + "file_id": "FABC", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 4, + "url_private_download": "https://files.slack.com/files-pri/T-F/download/report.pdf", + } + ], + ) + result = await channel2.receive_file(msg, "thread-1") + assert result.text == "caption" + assert result.files == [ + { + "type": "file", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 4, + INBOUND_FILE_CONTENT_KEY: b"data", + } + ] + + _run(go()) + + def test_missing_token_clears_files_and_notices(self): + from app.channels.slack import SlackChannel + + channel = SlackChannel(MessageBus(), {}) + channel._web_client = None + msg = InboundMessage( + channel_name="slack", + chat_id="C123", + user_id="U123", + text="caption", + files=[ + { + "type": "file", + "filename": "report.pdf", + "mime_type": "application/pdf", + "url_private_download": "https://files.slack.com/files-pri/T-F/download/report.pdf", + } + ], + ) + + result = _run(channel.receive_file(msg, "thread-1")) + + assert result.files == [] + assert "bot token missing" in result.text + + def test_rejects_non_slack_host_without_fetching(self): + from app.channels.slack import SlackChannel + + channel = SlackChannel(MessageBus(), {"bot_token": "xoxb-test"}) + channel._web_client = SimpleNamespace(token="xoxb-test") + called = {"download": False} + + async def fake_download(url: str, token: str, *, max_bytes: int = 0) -> bytes | None: + called["download"] = True + return b"nope" + + channel._download_private_file = fake_download # type: ignore[method-assign] + msg = InboundMessage( + channel_name="slack", + chat_id="C123", + user_id="U123", + text="caption", + files=[ + { + "type": "file", + "filename": "evil.pdf", + "mime_type": "application/pdf", + "size": 4, + "url_private_download": "https://evil.example/report.pdf", + } + ], + ) + result = _run(channel.receive_file(msg, "thread-1")) + assert called["download"] is False + assert result.files == [] + assert "download unavailable" in result.text + + def test_content_encoding_refusal_is_download_failed(self, monkeypatch): + from app.channels import slack as slack_mod + from app.channels.slack import SlackChannel + + class _FakeStreamResponse: + def __init__(self): + self.headers = {"content-encoding": "gzip"} + + def raise_for_status(self) -> None: + return None + + async def aiter_raw(self): + if False: + yield b"" + + class _FakeStreamContext: + async def __aenter__(self): + return _FakeStreamResponse() + + async def __aexit__(self, *exc_info): + return False + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + def stream(self, method: str, url: str, **kwargs): + return _FakeStreamContext() + + monkeypatch.setattr(slack_mod.httpx, "AsyncClient", _FakeAsyncClient) + + channel = SlackChannel(MessageBus(), {"bot_token": "xoxb-test"}) + channel._web_client = SimpleNamespace(token="xoxb-test") + msg = InboundMessage( + channel_name="slack", + chat_id="C123", + user_id="U123", + text="caption", + files=[ + { + "type": "file", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 4, + "url_private_download": "https://files.slack.com/files-pri/T-F/download/report.pdf", + } + ], + ) + result = _run(channel.receive_file(msg, "thread-1")) + assert result.files == [] + assert "download failed" in result.text + assert "exceeds the download limit" not in result.text + + def test_aggregate_message_byte_cap(self): + from app.channels import slack as slack_mod + from app.channels.slack import SlackChannel + + channel = SlackChannel(MessageBus(), {"bot_token": "xoxb-test"}) + channel._web_client = SimpleNamespace(token="xoxb-test") + payloads = { + "https://files.slack.com/a.bin": b"a" * 10, + "https://files.slack.com/b.bin": b"b" * 10, + } + + async def fake_download(url: str, token: str, *, max_bytes: int = 0) -> bytes | None: + body = payloads[url] + if len(body) > max_bytes: + return None + return body + + channel._download_private_file = fake_download # type: ignore[method-assign] + # Shrink aggregate so two tiny files exceed it. + monkey_limit = 15 + original = slack_mod.SLACK_MAX_INBOUND_MESSAGE_BYTES + slack_mod.SLACK_MAX_INBOUND_MESSAGE_BYTES = monkey_limit + try: + msg = InboundMessage( + channel_name="slack", + chat_id="C123", + user_id="U123", + text="caption", + files=[ + { + "type": "file", + "filename": "a.bin", + "mime_type": "application/octet-stream", + "size": 10, + "url_private_download": "https://files.slack.com/a.bin", + }, + { + "type": "file", + "filename": "b.bin", + "mime_type": "application/octet-stream", + "size": 10, + "url_private_download": "https://files.slack.com/b.bin", + }, + ], + ) + result = _run(channel.receive_file(msg, "thread-1")) + finally: + slack_mod.SLACK_MAX_INBOUND_MESSAGE_BYTES = original + + assert len(result.files) == 1 + assert result.files[0]["filename"] == "a.bin" + assert "message download limit" in result.text + + +class TestSlackPrivateFileUrlAllowlist: + def test_allows_files_slack_com_https(self): + from app.channels.slack import SlackChannel + + assert SlackChannel._is_allowed_private_file_url("https://files.slack.com/files-pri/T-F/download/x") + assert SlackChannel._is_allowed_private_file_url("https://cdn.files.slack.com/files-pri/T-F/download/x") + + def test_rejects_http_and_foreign_hosts(self): + from app.channels.slack import SlackChannel + + assert not SlackChannel._is_allowed_private_file_url("http://files.slack.com/files-pri/T-F/download/x") + assert not SlackChannel._is_allowed_private_file_url("https://evil.example/x") + assert not SlackChannel._is_allowed_private_file_url("https://notfiles.slack.com.evil.io/x")