From 165053375072506f22f3a5f0009edb61039c1b12 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 5 Mar 2026 17:56:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=20SOCKS=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81=E5=B9=B6=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=8F=8B=E5=A5=BD=E7=9A=84=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 pyproject.toml 中添加 socksio 依赖以支持 SOCKS 代理 - 在 create_proxy_client 中检测 SOCKS 代理并在缺少 socksio 时提供清晰的安装指引 - 统一 openai_embedding_source 和 openai_tts_api_source 使用 create_proxy_client Fixes #5757 Co-Authored-By: Claude Opus 4.6 --- .../sources/openai_embedding_source.py | 6 +-- .../provider/sources/openai_tts_api_source.py | 6 +-- astrbot/core/utils/network_utils.py | 41 +++++++++++++++++-- pyproject.toml | 1 + 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 8bf92ef4d5..00a08292ae 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -2,6 +2,7 @@ from openai import AsyncOpenAI from astrbot import logger +from astrbot.core.utils.network_utils import create_proxy_client from ..entities import ProviderType from ..provider import EmbeddingProvider @@ -19,10 +20,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.provider_config = provider_config self.provider_settings = provider_settings proxy = provider_config.get("proxy", "") - http_client = None - if proxy: - logger.info(f"[OpenAI Embedding] 使用代理: {proxy}") - http_client = httpx.AsyncClient(proxy=proxy) + http_client = create_proxy_client("OpenAI Embedding", proxy) api_base = provider_config.get("embedding_api_base", "").strip() if not api_base: api_base = "https://api.openai.com/v1" diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 217b189251..a72bd18199 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -6,6 +6,7 @@ from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.network_utils import create_proxy_client from ..entities import ProviderType from ..provider import TTSProvider @@ -32,10 +33,7 @@ def __init__( timeout = int(timeout) proxy = provider_config.get("proxy", "") - http_client = None - if proxy: - logger.info(f"[OpenAI TTS] 使用代理: {proxy}") - http_client = httpx.AsyncClient(proxy=proxy) + http_client = create_proxy_client("OpenAI TTS", proxy) self.client = AsyncOpenAI( api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 727f3762ae..f21dc631a5 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -80,6 +80,21 @@ def log_connection_failure( logger.error(f"[{provider_label}] 网络连接失败 ({error_type})。错误: {error}") +def _is_socks_proxy(proxy: str) -> bool: + """Check if the proxy URL is a SOCKS proxy. + + Args: + proxy: The proxy URL string + + Returns: + True if the proxy is a SOCKS proxy (socks4://, socks5://, socks5h://) + """ + proxy_lower = proxy.lower() + return proxy_lower.startswith("socks4://") or proxy_lower.startswith( + "socks5://" + ) or proxy_lower.startswith("socks5h://") + + def create_proxy_client( provider_label: str, proxy: str | None = None, @@ -95,8 +110,26 @@ def create_proxy_client( Returns: An httpx.AsyncClient configured with the proxy, or None if no proxy + + Raises: + ImportError: If SOCKS proxy is used but socksio is not installed """ - if proxy: - logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy) - return None + if not proxy: + return None + + logger.info(f"[{provider_label}] 使用代理: {proxy}") + + # Check for SOCKS proxy and provide helpful error if socksio is not installed + if _is_socks_proxy(proxy): + try: + import socksio # noqa: F401 + except ImportError: + raise ImportError( + f"使用 SOCKS 代理需要安装 socksio 包。请运行以下命令安装:\n" + f" pip install 'httpx[socks]'\n" + f"或者:\n" + f" pip install socksio\n" + f"代理地址: {proxy}" + ) from None + + return httpx.AsyncClient(proxy=proxy) diff --git a/pyproject.toml b/pyproject.toml index 463da49556..9f27454549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dependencies = [ "shipyard-python-sdk>=0.2.4", "shipyard-neo-sdk>=0.2.0", "python-socks>=2.8.0", + "socksio>=1.0.0", "packaging>=24.2", ] From f71eeda72f4ac7a44b39af9e3fbadc023fb8ef43 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 5 Mar 2026 18:57:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E8=84=B1=E6=95=8F=E4=BB=A3=E7=90=86?= =?UTF-8?q?=20URL=20=E4=BB=A5=E9=98=B2=E6=AD=A2=E5=87=AD=E6=8D=AE=E6=B3=84?= =?UTF-8?q?=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 _sanitize_proxy_url 函数,在日志和错误消息中隐藏代理密码 - 简化 _is_socks_proxy 函数,使用元组参数 - 修复 Gemini Code Assist 指出的安全问题 Co-Authored-By: Claude Opus 4.6 --- astrbot/core/utils/network_utils.py | 40 ++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index f21dc631a5..c6193d5ffd 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -72,9 +72,10 @@ def log_connection_failure( ) if effective_proxy: + sanitized_proxy = _sanitize_proxy_url(effective_proxy) logger.error( f"[{provider_label}] 网络/代理连接失败 ({error_type})。" - f"代理地址: {effective_proxy},错误: {error}" + f"代理地址: {sanitized_proxy},错误: {error}" ) else: logger.error(f"[{provider_label}] 网络连接失败 ({error_type})。错误: {error}") @@ -89,10 +90,34 @@ def _is_socks_proxy(proxy: str) -> bool: Returns: True if the proxy is a SOCKS proxy (socks4://, socks5://, socks5h://) """ - proxy_lower = proxy.lower() - return proxy_lower.startswith("socks4://") or proxy_lower.startswith( - "socks5://" - ) or proxy_lower.startswith("socks5h://") + return proxy.lower().startswith(("socks4://", "socks5://", "socks5h://")) + + +def _sanitize_proxy_url(proxy: str) -> str: + """Sanitize proxy URL by masking password for safe logging. + + Args: + proxy: The proxy URL string + + Returns: + Sanitized proxy URL with password masked (e.g., "http://user:****@host:port") + """ + try: + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(proxy) + if parsed.password: + # Replace password with asterisks + netloc = f"{parsed.username}:****@{parsed.hostname}" + if parsed.port: + netloc += f":{parsed.port}" + sanitized = urlunparse( + (parsed.scheme, netloc, parsed.path, "", "", "") + ) + return sanitized + except Exception: + pass + return proxy def create_proxy_client( @@ -117,7 +142,8 @@ def create_proxy_client( if not proxy: return None - logger.info(f"[{provider_label}] 使用代理: {proxy}") + sanitized_proxy = _sanitize_proxy_url(proxy) + logger.info(f"[{provider_label}] 使用代理: {sanitized_proxy}") # Check for SOCKS proxy and provide helpful error if socksio is not installed if _is_socks_proxy(proxy): @@ -129,7 +155,7 @@ def create_proxy_client( f" pip install 'httpx[socks]'\n" f"或者:\n" f" pip install socksio\n" - f"代理地址: {proxy}" + f"代理地址: {sanitized_proxy}" ) from None return httpx.AsyncClient(proxy=proxy) From 4ddb17f3444ca9aa020f108b2b784de1fb83309f Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 5 Mar 2026 20:19:16 +0800 Subject: [PATCH 3/4] fix: harden proxy redaction and add regression tests --- .../sources/openai_embedding_source.py | 2 - .../provider/sources/openai_tts_api_source.py | 2 - astrbot/core/utils/network_utils.py | 30 +++++++++---- tests/unit/test_network_utils.py | 44 +++++++++++++++++++ 4 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_network_utils.py diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 00a08292ae..5c8dd7d79b 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,7 +1,5 @@ -import httpx from openai import AsyncOpenAI -from astrbot import logger from astrbot.core.utils.network_utils import create_proxy_client from ..entities import ProviderType diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index a72bd18199..6527f3eb73 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -1,10 +1,8 @@ import os import uuid -import httpx from openai import NOT_GIVEN, AsyncOpenAI -from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.network_utils import create_proxy_client diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index c6193d5ffd..4a85552929 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -73,9 +73,12 @@ def log_connection_failure( if effective_proxy: sanitized_proxy = _sanitize_proxy_url(effective_proxy) + error_text = str(error) + if effective_proxy: + error_text = error_text.replace(effective_proxy, sanitized_proxy) logger.error( f"[{provider_label}] 网络/代理连接失败 ({error_type})。" - f"代理地址: {sanitized_proxy},错误: {error}" + f"代理地址: {sanitized_proxy},错误: {error_text}" ) else: logger.error(f"[{provider_label}] 网络连接失败 ({error_type})。错误: {error}") @@ -94,27 +97,36 @@ def _is_socks_proxy(proxy: str) -> bool: def _sanitize_proxy_url(proxy: str) -> str: - """Sanitize proxy URL by masking password for safe logging. + """Sanitize proxy URL by masking credentials for safe logging. Args: proxy: The proxy URL string Returns: - Sanitized proxy URL with password masked (e.g., "http://user:****@host:port") + Sanitized proxy URL with credentials masked (e.g., "http://****@host:port") """ try: from urllib.parse import urlparse, urlunparse parsed = urlparse(proxy) - if parsed.password: - # Replace password with asterisks - netloc = f"{parsed.username}:****@{parsed.hostname}" + # Any userinfo in netloc should be masked to avoid leaking tokens/passwords. + if "@" in parsed.netloc and parsed.hostname: + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + netloc = f"****@{host}" if parsed.port: netloc += f":{parsed.port}" - sanitized = urlunparse( - (parsed.scheme, netloc, parsed.path, "", "", "") + return urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) ) - return sanitized except Exception: pass return proxy diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py new file mode 100644 index 0000000000..fa63cefb40 --- /dev/null +++ b/tests/unit/test_network_utils.py @@ -0,0 +1,44 @@ +"""Tests for network utility helpers.""" + +from astrbot.core.utils import network_utils + + +def test_sanitize_proxy_url_masks_password_credentials(): + proxy = "http://user:secret@127.0.0.1:1080" + assert network_utils._sanitize_proxy_url(proxy) == "http://****@127.0.0.1:1080" + + +def test_sanitize_proxy_url_masks_username_only_credentials(): + proxy = "http://token@127.0.0.1:1080" + assert network_utils._sanitize_proxy_url(proxy) == "http://****@127.0.0.1:1080" + + +def test_sanitize_proxy_url_masks_empty_password_credentials(): + proxy = "http://user:@127.0.0.1:1080" + assert network_utils._sanitize_proxy_url(proxy) == "http://****@127.0.0.1:1080" + + +def test_is_socks_proxy_detects_supported_schemes(): + assert network_utils._is_socks_proxy("socks5://127.0.0.1:1080") + assert network_utils._is_socks_proxy("socks4://127.0.0.1:1080") + assert network_utils._is_socks_proxy("socks5h://127.0.0.1:1080") + assert not network_utils._is_socks_proxy("http://127.0.0.1:1080") + + +def test_log_connection_failure_redacts_proxy_in_error_text(monkeypatch): + proxy = "http://token@127.0.0.1:1080" + captured = {} + + def fake_error(message: str): + captured["message"] = message + + monkeypatch.setattr(network_utils.logger, "error", fake_error) + + network_utils.log_connection_failure( + provider_label="OpenAI", + error=RuntimeError(f"proxy connect failed: {proxy}"), + proxy=proxy, + ) + + assert "http://token@127.0.0.1:1080" not in captured["message"] + assert "http://****@127.0.0.1:1080" in captured["message"] From 729363f5968a16308e104f98f459945cbe114cc9 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 5 Mar 2026 20:45:23 +0800 Subject: [PATCH 4/4] fix: harden proxy sanitize fallback and extend tests --- astrbot/core/utils/network_utils.py | 2 +- tests/unit/test_network_utils.py | 74 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 4a85552929..a9654a2418 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -128,7 +128,7 @@ def _sanitize_proxy_url(proxy: str) -> str: ) ) except Exception: - pass + return "****" return proxy diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py index fa63cefb40..6504114b25 100644 --- a/tests/unit/test_network_utils.py +++ b/tests/unit/test_network_utils.py @@ -1,5 +1,7 @@ """Tests for network utility helpers.""" +import builtins + from astrbot.core.utils import network_utils @@ -18,6 +20,39 @@ def test_sanitize_proxy_url_masks_empty_password_credentials(): assert network_utils._sanitize_proxy_url(proxy) == "http://****@127.0.0.1:1080" +def test_sanitize_proxy_url_returns_original_when_no_credentials(): + proxy = "http://127.0.0.1:1080" + assert network_utils._sanitize_proxy_url(proxy) == proxy + + +def test_sanitize_proxy_url_returns_original_for_non_url_text(): + proxy = "not a url" + assert network_utils._sanitize_proxy_url(proxy) == proxy + + +def test_sanitize_proxy_url_returns_original_for_empty_string(): + assert network_utils._sanitize_proxy_url("") == "" + + +def test_sanitize_proxy_url_masks_credentials_for_ipv6_host(): + proxy = "http://user:secret@[::1]:1080" + assert network_utils._sanitize_proxy_url(proxy) == "http://****@[::1]:1080" + + +def test_sanitize_proxy_url_falls_back_to_placeholder_on_parse_error(monkeypatch): + proxy = "http://user:secret@127.0.0.1:1080" + original_import = builtins.__import__ + + def guarded_import(name, globals_=None, locals_=None, fromlist=(), level=0): + if name == "urllib.parse": + raise ImportError("boom") + return original_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + + assert network_utils._sanitize_proxy_url(proxy) == "****" + + def test_is_socks_proxy_detects_supported_schemes(): assert network_utils._is_socks_proxy("socks5://127.0.0.1:1080") assert network_utils._is_socks_proxy("socks4://127.0.0.1:1080") @@ -42,3 +77,42 @@ def fake_error(message: str): assert "http://token@127.0.0.1:1080" not in captured["message"] assert "http://****@127.0.0.1:1080" in captured["message"] + + +def test_log_connection_failure_without_proxy_does_not_log_proxy_label(monkeypatch): + captured = {} + + def fake_error(message: str): + captured["message"] = message + + monkeypatch.setattr(network_utils.logger, "error", fake_error) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + + network_utils.log_connection_failure( + provider_label="OpenAI", + error=RuntimeError("connection failed"), + proxy=None, + ) + + assert "代理地址" not in captured["message"] + assert "connection failed" in captured["message"] + + +def test_log_connection_failure_keeps_error_text_when_no_proxy_text(monkeypatch): + proxy = "http://token@127.0.0.1:1080" + captured = {} + + def fake_error(message: str): + captured["message"] = message + + monkeypatch.setattr(network_utils.logger, "error", fake_error) + + network_utils.log_connection_failure( + provider_label="OpenAI", + error=RuntimeError("connect timeout"), + proxy=proxy, + ) + + assert "http://****@127.0.0.1:1080" in captured["message"] + assert "connect timeout" in captured["message"]