mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
fix(security): SSRF protection for vision tool + path traversal guard for skill install
1. Vision SSRF (#2878, #2872): Add _validate_url_safe() that resolves the target hostname via DNS and rejects any IP in private (RFC1918), loopback, link-local, or reserved ranges before requests.get() is called. This blocks attacks that use attacker-controlled image URLs to probe internal services or cloud metadata endpoints (169.254.169.254). 2. Skill install path traversal (#2873): Add _safe_skill_dir() that validates the skill name cannot escape the skills/ root directory. Rejects names containing '..', absolute paths, and any resolved path that falls outside the custom_dir boundary. Applied to _add_url(), _add_package(), and delete(). Both fixes include comprehensive unit tests (19 test cases) covering blocked patterns, edge cases, and allowed legitimate usage. Closes #2878 Closes #2873 Ref: #2872
This commit is contained in:
@@ -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}")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
194
tests/test_security_ssrf_path_traversal.py
Normal file
194
tests/test_security_ssrf_path_traversal.py
Normal 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()
|
||||||
Reference in New Issue
Block a user