mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
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:
co-authored by
multica-agent
parent
01789e498a
commit
c759da50b1
@@ -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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Outbound fetch guards: SSRF (url) and arbitrary local file reads (path)."""
|
||||
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.utils.outbound_url import (
|
||||
UnsafeOutboundUrlError,
|
||||
assert_safe_local_media_path,
|
||||
assert_safe_outbound_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'url',
|
||||
[
|
||||
'http://127.0.0.1:5300/api',
|
||||
'http://localhost/x',
|
||||
'http://10.42.1.5:5300/',
|
||||
'http://192.168.1.1/',
|
||||
'http://172.16.0.10/',
|
||||
'http://169.254.169.254/latest/meta-data/',
|
||||
'http://[::1]/',
|
||||
'http://0.0.0.0/',
|
||||
],
|
||||
)
|
||||
def test_internal_addresses_are_refused(url):
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_outbound_url(url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['file:///etc/passwd', 'gopher://x/', 'ftp://example.com/', '', ' '])
|
||||
def test_non_http_schemes_and_empty_are_refused(url):
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_outbound_url(url)
|
||||
|
||||
|
||||
def test_public_url_is_allowed():
|
||||
assert assert_safe_outbound_url('https://example.com/image.png') == 'https://example.com/image.png'
|
||||
|
||||
|
||||
def test_hostname_resolving_to_internal_is_refused(monkeypatch):
|
||||
"""A public-looking name pointing at a private address must be refused."""
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
'getaddrinfo',
|
||||
lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('10.0.0.7', 80))],
|
||||
)
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_outbound_url('http://internal.example.com/x')
|
||||
|
||||
|
||||
def test_allowed_domains_restricts_destination(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
'getaddrinfo',
|
||||
lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', 443))],
|
||||
)
|
||||
assert assert_safe_outbound_url('https://oapi.dingtalk.com/robot/sendBySession', allowed_domains=('dingtalk.com',))
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_outbound_url('https://evil.example.com/x', allowed_domains=('dingtalk.com',))
|
||||
|
||||
|
||||
def test_allowed_domain_does_not_match_suffix_impostor(monkeypatch):
|
||||
"""evil-dingtalk.com must not satisfy the dingtalk.com rule."""
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
'getaddrinfo',
|
||||
lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', 443))],
|
||||
)
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_outbound_url('https://evil-dingtalk.com/x', allowed_domains=('dingtalk.com',))
|
||||
|
||||
|
||||
def test_local_media_path_inside_root_is_allowed(tmp_path):
|
||||
media = tmp_path / 'image.png'
|
||||
media.write_bytes(b'png')
|
||||
assert assert_safe_local_media_path(str(media), allowed_roots=(str(tmp_path),)) == str(media.resolve())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', ['/etc/passwd', '/etc/shadow', '//etc/passwd'])
|
||||
def test_local_media_path_outside_root_is_refused(path):
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_local_media_path(path, allowed_roots=('/tmp',))
|
||||
|
||||
|
||||
def test_relative_local_media_path_is_refused():
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_local_media_path('../../etc/passwd', allowed_roots=('/tmp',))
|
||||
|
||||
|
||||
def test_traversal_out_of_root_is_refused(tmp_path):
|
||||
(tmp_path / 'sub').mkdir()
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_local_media_path(str(tmp_path / 'sub' / '..' / '..' / 'etc' / 'passwd'), allowed_roots=(str(tmp_path),))
|
||||
|
||||
|
||||
def test_symlink_escaping_root_is_refused(tmp_path):
|
||||
outside = tmp_path / 'secret.txt'
|
||||
outside.write_text('secret')
|
||||
root = tmp_path / 'root'
|
||||
root.mkdir()
|
||||
link = root / 'link.txt'
|
||||
link.symlink_to(outside)
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_local_media_path(str(link), allowed_roots=(str(root),))
|
||||
|
||||
|
||||
def test_directory_is_not_accepted_as_media(tmp_path):
|
||||
with pytest.raises(UnsafeOutboundUrlError):
|
||||
assert_safe_local_media_path(str(tmp_path), allowed_roots=(str(tmp_path),))
|
||||
Reference in New Issue
Block a user