From b21dff12f4b96ae5d2d97e6501c821075f424e66 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 6 Nov 2026 09:42:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(T+=E6=89=98=E7=AE=A1):=20=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supervisor 的健康探测只允许访问本机地址,避免本机端点重定向到外部网络。\n增加重定向处理器和回归测试,保留现有依赖健康及错误脱敏行为。\n\n影响范围:T+ supervisor 健康检查与运行控制单元测试。\n\nCo-Authored-By: Claude Code --- backend/app/services/tplus/supervisor.py | 22 +++++++++++++-- .../tests/unit/test_tplus_runtime_control.py | 27 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/app/services/tplus/supervisor.py b/backend/app/services/tplus/supervisor.py index 92b64d9..16c0357 100644 --- a/backend/app/services/tplus/supervisor.py +++ b/backend/app/services/tplus/supervisor.py @@ -10,7 +10,7 @@ import time from pathlib import Path from urllib.error import URLError from urllib.parse import urlsplit -from urllib.request import urlopen +from urllib.request import HTTPRedirectHandler, build_opener from uuid import uuid4 from sqlalchemy import create_engine, select, text, update @@ -26,6 +26,24 @@ logger = logging.getLogger(__name__) LOCK_NAME = "ys:tplus:supervisor" +class _LocalOnlyRedirectHandler(HTTPRedirectHandler): + """拒绝健康检查从本机地址跳转到外部地址。""" + + def redirect_request(self, req, fp, code, msg, headers, new): + parsed = urlsplit(new) + if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"): + return None + return super().redirect_request(req, fp, code, msg, headers, new) + + +_health_opener = build_opener(_LocalOnlyRedirectHandler) + + +def _open_health(url, *, timeout): + return _health_opener.open(url, timeout=timeout) + + + def health_error(settings): """只访问本机健康端点,禁止通过配置探测外部网络。""" try: @@ -33,7 +51,7 @@ def health_error(settings): parsed = urlsplit(url) if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"): return "后端健康检查地址必须是本机 HTTP 地址" - with urlopen(url, timeout=3) as response: + with _open_health(url, timeout=3) as response: body = json.load(response) data = body.get("data") if isinstance(body, dict) else None if not isinstance(data, dict) or not all(data.get(key) is True for key in ("app", "db", "redis")): diff --git a/backend/tests/unit/test_tplus_runtime_control.py b/backend/tests/unit/test_tplus_runtime_control.py index 894692f..9966b48 100644 --- a/backend/tests/unit/test_tplus_runtime_control.py +++ b/backend/tests/unit/test_tplus_runtime_control.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from threading import Event from unittest.mock import MagicMock +from urllib.error import HTTPError import pytest from sqlalchemy import select @@ -58,6 +59,28 @@ def test_健康检查拒绝外部地址(): assert supervisor.health_error(settings) == "后端健康检查地址必须是本机 HTTP 地址" +def test_健康检查处理器拒绝外部重定向(): + request = MagicMock() + response = MagicMock() + assert supervisor._LocalOnlyRedirectHandler().redirect_request( + request, response, 302, "Found", {}, "http://example.com/api/health" + ) is None + + +def test_健康检查重定向到外部地址时拒绝(monkeypatch): + from app.core.config import Settings + + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr( + supervisor, + "_open_health", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + HTTPError("http://127.0.0.1/api/health", 302, "Found", {}, None) + ), + ) + assert supervisor.health_error(settings) == "系统健康检查或 T+ 连接配置未就绪" + + @pytest.mark.parametrize("body", [b'[]', b'null', b'{"data":null}', b'{"data":[]}', b'{"data":"bad"}']) def test_健康检查结构异常时返回未就绪(monkeypatch, body): from app.core.config import Settings @@ -65,7 +88,7 @@ def test_健康检查结构异常时返回未就绪(monkeypatch, body): settings = Settings(_env_file=None, app_secret_key="x" * 32) response = MagicMock() response.__enter__.return_value.read.return_value = body - monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response) + monkeypatch.setattr(supervisor, "_open_health", lambda *_args, **_kwargs: response) assert supervisor.health_error(settings) == "系统或依赖尚未就绪" @@ -78,7 +101,7 @@ def test_健康检查实际探测来源连接且不泄露错误(monkeypatch, con t_plus_odbc_driver="test-driver") response = MagicMock() response.__enter__.return_value.read.return_value = b'{"data":{"app":true,"db":true,"redis":true}}' - monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response) + monkeypatch.setattr(supervisor, "_open_health", lambda *_args, **_kwargs: response) import pyodbc monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"]) engine = MagicMock()