mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
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]>
167 lines
5.9 KiB
Python
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}')
|