fix(slack): accept file_share events and download inbound files (#6855)

* fix(slack): accept file_share events and download inbound files

Slack was dropping every Events API message with a subtype, including
user uploads (subtype=file_share). Allowlist file_share, attach event
files to the inbound message, and download private URLs in receive_file
with the bot Bearer token (bytes via INBOUND_FILE_CONTENT_KEY).

Fixes #6853

* fix(slack): tighten inbound file download guards

Address review on #6855: allowlist https files.slack.com before Bearer
fetch, cap aggregate in-memory bytes per file_share message, report
Content-Encoding refusals as download failed, and share one
_web_client_for_connection helper for inbound/outbound.

* fix(slack): satisfy ruff format and AGENTS.md size gate

Reformat test_slack_file_share.py and drop the AGENTS.md file_share
note so channels/AGENTS.md stays under the 49152-byte hard limit
(file was already near the ceiling without it).
This commit is contained in:
Li-john1021 2026-10-15 08:28:36 +08:00 committed by GitHub
parent 2fe325f8ae
commit 585f87d3b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 736 additions and 11 deletions

View File

@ -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 &lt;@...&gt; 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,

View File

@ -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")