fix(adapters): stop SSRF and arbitrary local file reads in media handling (#2753)

The platform adapters fetch URLs and open paths that come from inbound
messages, so both are attacker-influenced.

New guard module pkg/utils/outbound_url.py:

- assert_safe_outbound_url: checks the scheme, rejects literal private /
  loopback / link-local / reserved / multicast addresses, and resolves the
  hostname so a public-looking name pointing at an internal address is refused
  too. Optional allowed_domains confines a fetch to a known prefix.
- assert_safe_local_media_path: resolves a caller-supplied path and requires it
  to stay inside the media roots (data dir + temp dir), so symlinks and .. cannot
  escape. Directories are refused.

Wired in:

- DingTalk send_message: session_webhook is taken from the inbound event and was
  POSTed verbatim, so a forged event could redirect reply content (including LLM
  output) to any host. Now restricted to dingtalk.com.
- DingTalk send_attachment: component.url was fetched with no destination check;
  now goes through the SSRF guard, which also covers cloud metadata.
- DingTalk upload_image_media: caller-supplied file_path was opened directly; now
  confined to the media roots.
- Slack attachment_bytes: component.path was opened directly and uploaded to the
  channel (arbitrary file exfiltration); now confined to the media roots.
- Slack download_media: redirects were followed with no destination check. Every
  hop is now validated, the hop count is bounded, and a redirect off the Slack
  domain drops the bearer token.

Tests: 25 new cases for the guard (internal address forms, scheme rejection,
suffix impostor, traversal, symlink escape, directory). The Slack credential test
now stubs DNS because the guard resolves before fetching, and asserts
allow_redirects is False for every case since redirects are handled manually.

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-22 20:46:27 +08:00
committed by GitHub
co-authored by multica-agent
parent 01789e498a
commit c759da50b1
5 changed files with 348 additions and 15 deletions
@@ -1,3 +1,4 @@
import socket
import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -188,13 +189,18 @@ async def test_download_credentials_are_scoped_to_slack(url, authenticated):
with (
patch('langbot.libs.slack_api.media.httpclient.get_session') as session,
patch('langbot.libs.slack_api.media.httpclient.read_limited', new=AsyncMock(return_value=b'png')),
# The SSRF guard resolves the host before fetching; the fake hosts in this
# test have no DNS record, so resolution is stubbed to a public address.
patch(
'langbot.pkg.utils.outbound_url.socket.getaddrinfo',
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', 443))],
),
):
session.return_value.get.return_value = context
assert await download_media(url, 'test-token') == (b'png', 'image/png')
kwargs = session.return_value.get.call_args.kwargs
assert bool(kwargs['headers']) == authenticated
if authenticated:
assert kwargs['allow_redirects'] is False
assert kwargs['allow_redirects'] is False
@pytest.mark.asyncio