Files
qing bbea1ee9d8 test(gateway): 黑盒测试重写 34 断言 + v0.8.0 增量审计闭环文档
- tests/blackbox/test_gateway.py 重写为 6 段 34 断言(输入校验/whoami+admin.verify/
  captcha-image 代理/路径遍历矩阵/限流随机XFF隔离/CORS+脱敏回归),实测 32 passed/2 skipped
- helpers.py 新增 random_xff/assert_no_internal_leak/db_execute
- docs/security/gateway_SECURITY_AUDIT_20260809.md:A1-A8 增量复审(已全部修复)
- docs/reports/libhttp_remote_addr_dev_doc.md:A4 根治方案(X-Remote-Addr 可信头注入)
- docs/INDEX.md:security 29 篇/reports 12 篇同步
2026-08-09 12:15:32 +00:00

377 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
micro_servers 黑盒测试 — 共享工具模块。
提供: HTTP 请求封装、断言工具、测试计数器、pymsg 协议客户端。
纯标准库实现,零外部依赖。
"""
import http.client
import json
import random
import socket
import struct
import time
import urllib.request
import urllib.error
import urllib.parse
import ssl
# ---------------------------------------------------------------------------
# 测试计数器
# ---------------------------------------------------------------------------
class Counter:
"""每个测试模块一个计数器实例,统计 passed/failed/skipped。"""
def __init__(self):
self.passed = 0
self.failed = 0
self.skipped = 0
self._failures = []
def add_pass(self):
self.passed += 1
def add_fail(self, name: str, reason: str = ""):
self.failed += 1
self._failures.append((name, reason))
def add_skip(self):
self.skipped += 1
@property
def total(self):
return self.passed + self.failed + self.skipped
def summary(self) -> str:
return f"{self.total} tests: {self.passed} passed, {self.failed} failed, {self.skipped} skipped"
def failures(self) -> list:
return self._failures
# ---------------------------------------------------------------------------
# 断言工具
# ---------------------------------------------------------------------------
def assert_eq(actual, expected, name: str, counter: Counter):
"""断言 actual == expected。"""
if actual == expected:
print(f" [PASS] {name}")
counter.add_pass()
return True
else:
print(f" [FAIL] {name}: expected {expected!r}, got {actual!r}")
counter.add_fail(name, f"expected {expected!r}, got {actual!r}")
return False
def assert_contains(haystack: str, needle: str, name: str, counter: Counter):
"""断言 needle in haystack(不区分大小写)。"""
if needle.lower() in haystack.lower():
print(f" [PASS] {name}")
counter.add_pass()
return True
else:
snippet = haystack[:200] if len(haystack) > 200 else haystack
print(f" [FAIL] {name}: '{needle}' not found in response")
counter.add_fail(name, f"'{needle}' not found in response")
return False
def assert_status(status: int, expected: int, name: str, counter: Counter):
"""断言 HTTP 状态码。"""
if status == expected:
print(f" [PASS] {name}")
counter.add_pass()
return True
else:
print(f" [FAIL] {name}: expected status {expected}, got {status}")
counter.add_fail(name, f"expected status {expected}, got {status}")
return False
def assert_true(condition: bool, name: str, counter: Counter, detail: str = ""):
"""断言条件为真。"""
if condition:
print(f" [PASS] {name}")
counter.add_pass()
return True
else:
msg = f" [FAIL] {name}"
if detail:
msg += f": {detail}"
print(msg)
counter.add_fail(name, detail)
return False
def assert_json_key(data: dict, key: str, name: str, counter: Counter):
"""断言 JSON 对象包含指定 key。"""
if key in data:
print(f" [PASS] {name}")
counter.add_pass()
return True
else:
print(f" [FAIL] {name}: key '{key}' not found in {json.dumps(data)[:200]}")
counter.add_fail(name, f"key '{key}' not found")
return False
def skip(name: str, reason: str, counter: Counter):
"""标记测试为跳过。"""
print(f" [SKIP] {name}: {reason}")
counter.add_skip()
# ---------------------------------------------------------------------------
# HTTP 请求封装
# ---------------------------------------------------------------------------
def http_request(method: str, url: str, *,
body: dict = None,
headers: dict = None,
timeout: float = 10.0,
raw_body: bytes = None,
raw_body_content_type: str = None):
"""发送 HTTP 请求,返回 (status, body_str, resp_headers)。
支持:
- JSON body (body 参数)
- 原始二进制 body (raw_body + raw_body_content_type)
- 任意自定义 headers
- 超时控制
"""
parsed = urllib.parse.urlparse(url)
data = None
hdrs = {}
if headers:
hdrs.update(headers)
if raw_body is not None:
data = raw_body
if raw_body_content_type:
hdrs["Content-Type"] = raw_body_content_type
elif body is not None:
data = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
ctx = ssl._create_unverified_context() if parsed.scheme == "https" else None
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
resp_body = resp.read().decode("utf-8", errors="replace")
resp_headers = dict(resp.headers)
return resp.status, resp_body, resp_headers
except urllib.error.HTTPError as e:
resp_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
resp_headers = dict(e.headers) if e.headers else {}
return e.code, resp_body, resp_headers
except Exception as e:
return 0, str(e), {}
def http_get(url: str, headers: dict = None, timeout: float = 10.0):
"""HTTP GET 请求。"""
return http_request("GET", url, headers=headers, timeout=timeout)
def http_post(url: str, body: dict = None, headers: dict = None,
timeout: float = 10.0):
"""HTTP POST 请求(JSON body)。"""
return http_request("POST", url, body=body, headers=headers, timeout=timeout)
def http_post_raw(url: str, raw_body: bytes, content_type: str,
headers: dict = None, timeout: float = 10.0):
"""HTTP POST 请求(原始二进制 body)。"""
return http_request("POST", url, raw_body=raw_body,
raw_body_content_type=content_type,
headers=headers, timeout=timeout)
def try_parse_json(text: str):
"""尝试解析 JSON,失败返回 None。"""
try:
return json.loads(text)
except (json.JSONDecodeError, ValueError):
return None
# ---------------------------------------------------------------------------
# pymsg 协议客户端
# ---------------------------------------------------------------------------
class PymsgClient:
"""pymsg TCP 协议客户端(用于直接与 db:8005 通信)。
协议格式: [4字节大端长度][UTF-8 JSON payload]
"""
def __init__(self, host: str = "127.0.0.1", port: int = 8005,
name: str = "test_client"):
self.host = host
self.port = port
self.name = name
self._sock = None
def _ensure_connected(self):
if self._sock is None:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(10)
self._sock.connect((self.host, self.port))
def _close(self):
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
def _send_frame(self, data: str):
"""发送一帧 pymsg 数据。"""
payload = data.encode("utf-8")
header = struct.pack(">I", len(payload))
self._sock.sendall(header + payload)
def _recv_frame(self, timeout: float = 10.0) -> str:
"""接收一帧 pymsg 数据。"""
self._sock.settimeout(timeout)
header = self._recv_exact(4, timeout)
if header is None:
raise ConnectionError("Failed to receive pymsg header")
length = struct.unpack(">I", header)[0]
payload = self._recv_exact(length, timeout)
if payload is None:
raise ConnectionError("Failed to receive pymsg payload")
return payload.decode("utf-8")
def _recv_exact(self, n: int, timeout: float = 10.0):
"""精确接收 n 字节。"""
self._sock.settimeout(timeout)
data = b""
while len(data) < n:
try:
chunk = self._sock.recv(n - len(data))
except socket.timeout:
return None
if not chunk:
return None
data += chunk
return data
def call(self, payload: dict, timeout: float = 10.0) -> dict:
"""发送请求并接收响应(短连接模式)。
payload 必须包含 "_route": "execute"。
返回 {"success": true/false, "data": [...], "error": "..."}
"""
try:
self._ensure_connected()
msg = json.dumps(payload)
self._send_frame(msg)
resp_text = self._recv_frame(timeout)
return json.loads(resp_text)
except Exception as e:
return {"success": False, "error": str(e)}
finally:
self._close()
# ---------------------------------------------------------------------------
# 服务可用性检查
# ---------------------------------------------------------------------------
def check_tcp_service(host: str, port: int, timeout: float = 5.0) -> tuple:
"""检查纯 TCP 服务(如 pymsg 协议服务 db/captcha/smtp)是否可达。
返回 (reachable: bool, detail: str)。
"""
try:
with socket.create_connection((host, port), timeout=timeout):
return True, "TCP 连接成功"
except ConnectionRefusedError:
return False, "连接被拒绝 (服务未启动或端口未监听)"
except socket.timeout:
return False, f"TCP 连接超时 ({timeout}s)"
except OSError as e:
return False, f"网络错误: {e}"
def check_service(url: str, timeout: float = 8.0) -> tuple:
"""检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
返回 (reachable: bool, detail: str)。
任何 HTTP 响应(包括 200/302/401/403 等)均视为可达。
"""
parsed = urllib.parse.urlparse(url)
ctx = ssl._create_unverified_context() if parsed.scheme == "https" else None
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return True, f"HTTP {resp.status}"
except urllib.error.HTTPError as e:
# 任何 HTTP 错误码都说明服务在运行
return True, f"HTTP {e.code}"
except urllib.error.URLError as e:
reason = str(e.reason) if hasattr(e, 'reason') else str(e)
return False, reason
except socket.timeout:
return False, f"HTTP 请求超时 ({timeout}s)"
except ConnectionRefusedError:
return False, "连接被拒绝 (服务未启动或端口未监听)"
except socket.gaierror:
return False, "DNS 解析失败"
except OSError as e:
return False, f"网络错误: {e.strerror if hasattr(e, 'strerror') else str(e)}"
except Exception as e:
return False, str(e)
# ---------------------------------------------------------------------------
# gateway 测试专用工具
# ---------------------------------------------------------------------------
def random_xff() -> str:
"""生成随机 X-Forwarded-For 值,用于隔离 gateway 的 IP 级限流桶。
背景:gateway 直连模式下信任客户端 XFF 头(审计发现 A4),每个测试场景
使用独立随机 XFF,可避免限流用例之间互相消耗配额(如登录 5 次/5 分钟)。
"""
return f"10.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}"
# 响应体泄露特征:源码文件名/路径/C++ 类型签名(P0-2/P0-3 脱敏回归基线)
_LEAK_PATTERNS = ("main.cpp", ".hpp", ".cpp:", "/opt/", "boost::", "std::",
"source_location", "typeid", "__cxa")
def assert_no_internal_leak(body: str, name: str, counter: Counter):
"""断言响应体不含内部实现信息(源码路径/C++ 类型名等,P0-2/P0-3 回归)。"""
hits = [p for p in _LEAK_PATTERNS if p in body]
if not hits:
print(f" [PASS] {name}")
counter.add_pass()
return True
else:
print(f" [FAIL] {name}: 响应泄露内部信息 {hits}")
counter.add_fail(name, f"leak patterns {hits}")
return False
def db_execute(action: str, table: str, *, where: dict = None,
values: dict = None, host: str = "127.0.0.1",
port: int = 8005, timeout: float = 10.0) -> dict:
"""通过 pymsg 调 db execute 路由(测试数据准备/清理用)。
返回 db 完整响应 {"success": ..., "data": ...};db 不可达时
返回 {"success": False, "error": ...}(PymsgClient 内部兜底)。
"""
payload = {"_route": "execute", "action": action, "table": table}
if where is not None:
payload["where"] = where
if values is not None:
payload["values"] = values
return PymsgClient(host, port, "test_blackbox").call(payload, timeout)