fix(tools): make web SSRF protection opt-in, disabled by default

This commit is contained in:
zhayujie
2026-06-24 19:40:55 +08:00
parent 0c20c5c159
commit 915edbe145
3 changed files with 87 additions and 9 deletions

View File

@@ -1,18 +1,41 @@
"""
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
A URL is only considered safe when it uses an http/https scheme, has a
hostname, that hostname resolves, and every resolved address is a public
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
addresses are rejected, for both IPv4 and IPv6.
SSRF protection is OPT-IN and disabled by default, because legitimate use
cases (local dev servers, LAN services, proxy fake-ip resolution) need to
reach non-public addresses. Enable it by setting the config option
``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
When enabled, a URL is only considered safe when it uses an http/https
scheme, has a hostname, that hostname resolves, and every resolved address
is a public (internet-routable) address. Loopback, private (RFC1918 / ULA),
link-local (incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise
reserved addresses are rejected, for both IPv4 and IPv6.
"""
import ipaddress
import os
import socket
from urllib.parse import urlparse
def _ssrf_protection_enabled() -> bool:
"""Return True only when SSRF protection is explicitly turned on.
Disabled by default. Reads the env var first, then falls back to the
global config; any failure to read config is treated as "disabled" so
the guard never breaks normal fetching.
"""
env = os.getenv("WEB_SECURITY_SSRF_PROTECTION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
try:
from config import conf
return bool(conf().get("web_security_ssrf_protection", False))
except Exception:
return False
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
"""Return True if the address is not safe to connect to (non-public)."""
return (
@@ -28,8 +51,11 @@ def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
def assert_public_ip(ip_str: str) -> None:
"""Raise ValueError if the given literal IP is a non-public address.
Used to re-validate the concrete address a redirect resolved to.
No-op when SSRF protection is disabled (the default). Used to re-validate
the concrete address a redirect resolved to.
"""
if not _ssrf_protection_enabled():
return
ip = ipaddress.ip_address(ip_str)
if _is_blocked_ip(ip):
raise ValueError(
@@ -41,13 +67,17 @@ def assert_public_ip(ip_str: str) -> None:
def validate_url_safe(url: str) -> None:
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that fall
No-op when SSRF protection is disabled (the default). When enabled,
resolves the hostname to its IP address(es) and blocks any that fall
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Raises:
ValueError: if the URL targets a disallowed address.
"""
if not _ssrf_protection_enabled():
return
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")