Merge pull request #2886 from kirs-hi/fix/ssrf-and-path-traversal

fix(security): SSRF protection for vision tool + path traversal guard for skill install
This commit is contained in:
zhayujie
2026-06-11 18:24:09 +08:00
committed by GitHub
3 changed files with 256 additions and 3 deletions

View File

@@ -34,6 +34,27 @@ class SkillService:
""" """
self.manager = skill_manager self.manager = skill_manager
def _safe_skill_dir(self, name: str) -> str:
"""Derive and validate the skill directory path.
Ensures the resolved path stays within the custom_dir root,
preventing path traversal via names like ``../escaped``.
:raises ValueError: if the name would escape the skills root.
"""
if not name or not name.strip():
raise ValueError("skill name is required")
# Reject obvious traversal components.
if ".." in name or name.startswith("/") or name.startswith("\\"):
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
root = os.path.realpath(self.manager.custom_dir)
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
raise ValueError(
f"skill name {name!r} resolves outside the skills directory"
)
return skill_dir
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# query # query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -107,7 +128,7 @@ class SkillService:
if not files: if not files:
raise ValueError("skill files list is empty") raise ValueError("skill files list is empty")
skill_dir = os.path.join(self.manager.custom_dir, name) skill_dir = self._safe_skill_dir(name)
tmp_dir = skill_dir + ".tmp" tmp_dir = skill_dir + ".tmp"
if os.path.exists(tmp_dir): if os.path.exists(tmp_dir):
@@ -146,7 +167,7 @@ class SkillService:
raise ValueError("package url is required") raise ValueError("package url is required")
url = files[0]["url"] url = files[0]["url"]
skill_dir = os.path.join(self.manager.custom_dir, name) skill_dir = self._safe_skill_dir(name)
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "package.zip") zip_path = os.path.join(tmp_dir, "package.zip")
@@ -217,7 +238,7 @@ class SkillService:
if not name: if not name:
raise ValueError("skill name is required") raise ValueError("skill name is required")
skill_dir = os.path.join(self.manager.custom_dir, name) skill_dir = self._safe_skill_dir(name)
if os.path.exists(skill_dir): if os.path.exists(skill_dir):
shutil.rmtree(skill_dir) shutil.rmtree(skill_dir)
logger.info(f"[SkillService] delete: removed directory {skill_dir}") logger.info(f"[SkillService] delete: removed directory {skill_dir}")

View File

@@ -17,11 +17,14 @@ Provider resolution:
""" """
import base64 import base64
import ipaddress
import os import os
import socket
import subprocess import subprocess
import tempfile import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
import requests import requests
@@ -654,6 +657,40 @@ class Vision(BaseTool):
return api_base return api_base
return api_base.rstrip("/") + "/v1" return api_base.rstrip("/") + "/v1"
@staticmethod
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
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.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(
f"URL resolves to a non-public address ({ip_str}), "
f"request blocked for security"
)
def _build_image_content(self, image: str) -> dict: def _build_image_content(self, image: str) -> dict:
""" """
Build the image_url content block. Build the image_url content block.
@@ -661,6 +698,7 @@ class Vision(BaseTool):
so every bot backend can consume them without extra downloads. so every bot backend can consume them without extra downloads.
""" """
if image.startswith(("http://", "https://")): if image.startswith(("http://", "https://")):
self._validate_url_safe(image)
return self._download_to_data_url(image) return self._download_to_data_url(image)
if not os.path.isfile(image): if not os.path.isfile(image):

View File

@@ -0,0 +1,194 @@
# encoding:utf-8
"""
Unit tests for security fixes:
1. Vision tool SSRF protection (issue #2878, #2872)
2. Skill service path traversal protection (issue #2873)
"""
import os
import sys
import tempfile
import types
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Stub 'requests' if not installed so vision.py can be imported for testing.
if "requests" not in sys.modules:
_requests_stub = types.ModuleType("requests")
_requests_stub.get = lambda *a, **k: None
sys.modules["requests"] = _requests_stub
# =============================================================================
# Vision SSRF tests
# =============================================================================
class TestVisionSSRFValidation(unittest.TestCase):
"""Test that _validate_url_safe blocks internal/private URLs."""
def setUp(self):
from agent.tools.vision.vision import Vision
self.validate = Vision._validate_url_safe
def test_loopback_ipv4_blocked(self):
"""127.0.0.1 must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.validate("http://127.0.0.1/canary.png")
self.assertIn("non-public", str(ctx.exception))
def test_loopback_localhost_blocked(self):
"""localhost must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.validate("http://localhost/canary.png")
self.assertIn("non-public", str(ctx.exception))
def test_private_10_network_blocked(self):
"""10.x.x.x RFC1918 must be rejected."""
with patch("socket.getaddrinfo") as mock_gai:
mock_gai.return_value = [
(2, 1, 6, "", ("10.0.0.1", 0)),
]
with self.assertRaises(ValueError) as ctx:
self.validate("http://internal.corp/image.png")
self.assertIn("non-public", str(ctx.exception))
def test_private_172_network_blocked(self):
"""172.16.x.x RFC1918 must be rejected."""
with patch("socket.getaddrinfo") as mock_gai:
mock_gai.return_value = [
(2, 1, 6, "", ("172.16.0.1", 0)),
]
with self.assertRaises(ValueError) as ctx:
self.validate("http://internal.corp/image.png")
self.assertIn("non-public", str(ctx.exception))
def test_private_192_168_blocked(self):
"""192.168.x.x RFC1918 must be rejected."""
with patch("socket.getaddrinfo") as mock_gai:
mock_gai.return_value = [
(2, 1, 6, "", ("192.168.1.1", 0)),
]
with self.assertRaises(ValueError) as ctx:
self.validate("http://router.local/image.png")
self.assertIn("non-public", str(ctx.exception))
def test_link_local_blocked(self):
"""169.254.x.x (link-local / cloud metadata) must be rejected."""
with patch("socket.getaddrinfo") as mock_gai:
mock_gai.return_value = [
(2, 1, 6, "", ("169.254.169.254", 0)),
]
with self.assertRaises(ValueError) as ctx:
self.validate("http://metadata.google.internal/image.png")
self.assertIn("non-public", str(ctx.exception))
def test_ipv6_loopback_blocked(self):
"""::1 (IPv6 loopback) must be rejected."""
with patch("socket.getaddrinfo") as mock_gai:
mock_gai.return_value = [
(10, 1, 6, "", ("::1", 0, 0, 0)),
]
with self.assertRaises(ValueError) as ctx:
self.validate("http://[::1]/image.png")
self.assertIn("non-public", str(ctx.exception))
def test_public_url_allowed(self):
"""A URL resolving to a public IP should pass validation."""
with patch("socket.getaddrinfo") as mock_gai:
mock_gai.return_value = [
(2, 1, 6, "", ("151.101.1.140", 0)),
]
# Should not raise
self.validate("https://cdn.example.com/image.png")
def test_no_hostname_rejected(self):
"""A URL with no host must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.validate("http:///path/to/image.png")
self.assertIn("no hostname", str(ctx.exception))
def test_non_http_scheme_rejected(self):
"""file:// and ftp:// schemes must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.validate("file:///etc/passwd")
self.assertIn("scheme", str(ctx.exception))
def test_dns_failure_rejected(self):
"""Unresolvable hostname must be rejected."""
import socket as sock_mod
with patch("socket.getaddrinfo", side_effect=sock_mod.gaierror("Name does not resolve")):
with self.assertRaises(ValueError) as ctx:
self.validate("http://nonexistent.invalid/img.png")
self.assertIn("Cannot resolve", str(ctx.exception))
# =============================================================================
# Skill service path traversal tests
# =============================================================================
class TestSkillServicePathTraversal(unittest.TestCase):
"""Test that _safe_skill_dir blocks path traversal attempts."""
def setUp(self):
self.tmp_root = tempfile.mkdtemp()
# Create a minimal SkillManager mock with custom_dir set.
from agent.skills.service import SkillService
mock_manager = MagicMock()
mock_manager.custom_dir = self.tmp_root
self.svc = SkillService(mock_manager)
def tearDown(self):
import shutil
shutil.rmtree(self.tmp_root, ignore_errors=True)
def test_normal_name_allowed(self):
"""A simple name like 'my-skill' should produce a valid path."""
result = self.svc._safe_skill_dir("my-skill")
expected = os.path.realpath(os.path.join(self.tmp_root, "my-skill"))
self.assertEqual(result, expected)
def test_dotdot_traversal_blocked(self):
"""'../escaped' must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.svc._safe_skill_dir("../escaped")
self.assertIn("path traversal", str(ctx.exception))
def test_nested_dotdot_blocked(self):
"""'foo/../../escaped' must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.svc._safe_skill_dir("foo/../../escaped")
self.assertIn("path traversal", str(ctx.exception))
def test_absolute_path_blocked(self):
"""'/tmp/evil' must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.svc._safe_skill_dir("/tmp/evil")
self.assertIn("path traversal", str(ctx.exception))
def test_backslash_path_blocked(self):
r"""'\\server\share' must be rejected."""
with self.assertRaises(ValueError) as ctx:
self.svc._safe_skill_dir("\\server\\share")
self.assertIn("path traversal", str(ctx.exception))
def test_empty_name_blocked(self):
"""Empty name must be rejected."""
with self.assertRaises(ValueError):
self.svc._safe_skill_dir("")
def test_whitespace_only_blocked(self):
"""Whitespace-only name must be rejected."""
with self.assertRaises(ValueError):
self.svc._safe_skill_dir(" ")
def test_subdir_name_allowed(self):
"""A name with a forward slash but no traversal is allowed if it stays in root."""
# e.g. "category/skill-name" is a valid nested skill directory
result = self.svc._safe_skill_dir("category/skill-name")
expected = os.path.realpath(os.path.join(self.tmp_root, "category/skill-name"))
self.assertEqual(result, expected)
if __name__ == "__main__":
unittest.main()