fix(T+托管): 限制健康检查重定向范围
supervisor 的健康探测只允许访问本机地址,避免本机端点重定向到外部网络。\n增加重定向处理器和回归测试,保留现有依赖健康及错误脱敏行为。\n\n影响范围:T+ supervisor 健康检查与运行控制单元测试。\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
fc4428abb5
commit
b21dff12f4
@ -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")):
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user