Files
LangBot/src/langbot/pkg/utils/outbound_url.py
T
RockChinQandmultica-agent c759da50b1 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]>
2026-10-22 20:46:27 +08:00

167 lines
5.9 KiB
Python

"""Outbound URL safety checks for server-side fetches (SSRF guard).
The platform adapters fetch URLs that come from inbound platform messages
(attachment URLs, session webhooks, media links). Those values are attacker
influenced, so the server must not be tricked into requesting internal
infrastructure: cloud metadata (169.254.169.254), the cluster network, or
loopback services.
Call :func:`assert_safe_outbound_url` before every such request. It checks the
scheme, rejects literal private/loopback/link-local addresses, and resolves the
hostname so a public-looking name that points at an internal address is
rejected too.
"""
from __future__ import annotations
import ipaddress
import socket
import tempfile
import urllib.parse
from pathlib import Path
_ALLOWED_SCHEMES = ('http', 'https')
class UnsafeOutboundUrlError(ValueError):
"""Raised when an outbound URL would reach a non-public destination."""
def _reject_reason(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str | None:
"""Return why this address is not a safe outbound destination, if it is not."""
mapped = getattr(ip, 'ipv4_mapped', None)
if mapped is not None:
ip = mapped
if ip.is_private:
return 'private address'
if ip.is_loopback:
return 'loopback address'
if ip.is_link_local:
return 'link-local address'
if ip.is_multicast:
return 'multicast address'
if ip.is_reserved:
return 'reserved address'
if ip.is_unspecified:
return 'unspecified address'
return None
def _parse_and_check_scheme(url: str) -> urllib.parse.SplitResult:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
raise UnsafeOutboundUrlError(f'unsupported URL scheme: {parsed.scheme or "<none>"}')
if not parsed.hostname:
raise UnsafeOutboundUrlError('URL has no host')
return parsed
def _host_allowed(host: str, allowed_domains: tuple[str, ...] | None) -> bool:
if allowed_domains is None:
return True
host = host.lower().rstrip('.')
for domain in allowed_domains:
domain = domain.lower().lstrip('.')
if host == domain or host.endswith('.' + domain):
return True
return False
def assert_safe_outbound_url(
url: str,
*,
allowed_domains: tuple[str, ...] | None = None,
) -> str:
"""Validate an outbound URL, returning it unchanged when it is safe.
``allowed_domains`` restricts the destination to those domain suffixes (used
where the legitimate destination is a fixed platform domain). When omitted,
any public destination is allowed but internal addresses are still refused.
"""
if not isinstance(url, str) or not url.strip():
raise UnsafeOutboundUrlError('URL is empty')
parsed = _parse_and_check_scheme(url)
host = parsed.hostname or ''
if not _host_allowed(host, allowed_domains):
raise UnsafeOutboundUrlError(f'host is not an allowed destination: {host}')
try:
literal = ipaddress.ip_address(host)
except ValueError:
literal = None
if literal is not None:
reason = _reject_reason(literal)
if reason is not None:
raise UnsafeOutboundUrlError(f'refusing to fetch {host}: {reason}')
return url
# Resolve so a public-looking name pointing at an internal address is caught.
try:
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == 'https' else 80))
except socket.gaierror as exc:
raise UnsafeOutboundUrlError(f'cannot resolve host {host}: {exc}') from exc
addresses = {info[4][0] for info in infos}
if not addresses:
raise UnsafeOutboundUrlError(f'host {host} did not resolve to an address')
for address in addresses:
try:
ip = ipaddress.ip_address(address)
except ValueError:
continue
reason = _reject_reason(ip)
if reason is not None:
raise UnsafeOutboundUrlError(f'refusing to fetch {host} ({address}): {reason}')
return url
def assert_safe_local_media_path(file_path: str, *, allowed_roots: tuple[str, ...] | None = None) -> str:
"""Validate a local file path used as outbound media, returning it resolved.
Message components can carry a ``path``/``file_path`` field. A platform
adapter that opens it blindly lets a crafted component read any file the
process can see (``/etc/passwd``, credentials, another tenant's data) and
upload it to the chat platform.
The path must stay inside one of ``allowed_roots`` — by default the LangBot
data directory and the system temporary directory, which is where plugins and
the pipeline legitimately write generated media. Symlinks are resolved first,
so a link pointing outside the root is rejected too.
"""
from langbot.pkg.utils.paths import get_data_root
if not isinstance(file_path, str) or not file_path.strip():
raise UnsafeOutboundUrlError('file path is empty')
roots = allowed_roots or (get_data_root(), tempfile.gettempdir())
resolved_roots = []
for root in roots:
try:
resolved_roots.append(Path(root).resolve())
except OSError: # pragma: no cover - defensive
continue
candidate = Path(file_path)
if not candidate.is_absolute():
# Relative paths resolve against an unpredictable cwd; refuse them.
raise UnsafeOutboundUrlError(f'refusing to read a relative media path: {file_path}')
try:
resolved = candidate.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise UnsafeOutboundUrlError(f'cannot resolve media path: {file_path}') from exc
for root in resolved_roots:
if resolved == root or root in resolved.parents:
if not resolved.is_file():
raise UnsafeOutboundUrlError(f'media path is not a regular file: {file_path}')
return str(resolved)
raise UnsafeOutboundUrlError(f'refusing to read media path outside the allowed roots: {file_path}')