fix(T+托管): 限制健康检查重定向范围

supervisor 的健康探测只允许访问本机地址,避免本机端点重定向到外部网络。\n增加重定向处理器和回归测试,保留现有依赖健康及错误脱敏行为。\n\n影响范围:T+ supervisor 健康检查与运行控制单元测试。\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-11-06 09:42:07 +08:00
parent fc4428abb5
commit b21dff12f4
2 changed files with 45 additions and 4 deletions

View File

@ -10,7 +10,7 @@ import time
from pathlib import Path from pathlib import Path
from urllib.error import URLError from urllib.error import URLError
from urllib.parse import urlsplit from urllib.parse import urlsplit
from urllib.request import urlopen from urllib.request import HTTPRedirectHandler, build_opener
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import create_engine, select, text, update from sqlalchemy import create_engine, select, text, update
@ -26,6 +26,24 @@ logger = logging.getLogger(__name__)
LOCK_NAME = "ys:tplus:supervisor" 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): def health_error(settings):
"""只访问本机健康端点,禁止通过配置探测外部网络。""" """只访问本机健康端点,禁止通过配置探测外部网络。"""
try: try:
@ -33,7 +51,7 @@ def health_error(settings):
parsed = urlsplit(url) parsed = urlsplit(url)
if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"): if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
return "后端健康检查地址必须是本机 HTTP 地址" return "后端健康检查地址必须是本机 HTTP 地址"
with urlopen(url, timeout=3) as response: with _open_health(url, timeout=3) as response:
body = json.load(response) body = json.load(response)
data = body.get("data") if isinstance(body, dict) else None 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")): if not isinstance(data, dict) or not all(data.get(key) is True for key in ("app", "db", "redis")):

View File

@ -3,6 +3,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Event from threading import Event
from unittest.mock import MagicMock from unittest.mock import MagicMock
from urllib.error import HTTPError
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
@ -58,6 +59,28 @@ def test_健康检查拒绝外部地址():
assert supervisor.health_error(settings) == "后端健康检查地址必须是本机 HTTP 地址" 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"}']) @pytest.mark.parametrize("body", [b'[]', b'null', b'{"data":null}', b'{"data":[]}', b'{"data":"bad"}'])
def test_健康检查结构异常时返回未就绪(monkeypatch, body): def test_健康检查结构异常时返回未就绪(monkeypatch, body):
from app.core.config import Settings from app.core.config import Settings
@ -65,7 +88,7 @@ def test_健康检查结构异常时返回未就绪(monkeypatch, body):
settings = Settings(_env_file=None, app_secret_key="x" * 32) settings = Settings(_env_file=None, app_secret_key="x" * 32)
response = MagicMock() response = MagicMock()
response.__enter__.return_value.read.return_value = body 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) == "系统或依赖尚未就绪" assert supervisor.health_error(settings) == "系统或依赖尚未就绪"
@ -78,7 +101,7 @@ def test_健康检查实际探测来源连接且不泄露错误(monkeypatch, con
t_plus_odbc_driver="test-driver") t_plus_odbc_driver="test-driver")
response = MagicMock() response = MagicMock()
response.__enter__.return_value.read.return_value = b'{"data":{"app":true,"db":true,"redis":true}}' 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 import pyodbc
monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"]) monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"])
engine = MagicMock() engine = MagicMock()