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 篇同步
This commit is contained in:
1 parent
7342de421b
commit
bbea1ee9d8
6 files changed
+821
-157
No files matched your search
@@ -17,7 +17,7 @@ tests/blackbox/
|
||||
├── config.py # 服务地址与端口映射
|
||||
├── helpers.py # HTTP/TCP 请求封装 + 断言工具 + 计数器
|
||||
├── test_runner.py # 统一入口,--url 自动识别目标服务
|
||||
├── test_gateway.py # 用户注册 → 登录 → Token → 代理转发
|
||||
├── test_gateway.py # 输入校验/Token契约/代理路径安全/限流/CORS(v0.8.0 契约,认证链路待测试钩子)
|
||||
├── test_captcha.py # 获取验证码 → 校验 → 一次性消费
|
||||
├── test_smtp.py # 发送邮箱验证码 → 校验
|
||||
├── test_dnfs.py # 创建笔记 → 查询 → 更新 → 删除(含内容审核)
|
||||
|
||||
@@ -7,6 +7,7 @@ micro_servers 黑盒测试 — 共享工具模块。
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
@@ -326,3 +327,50 @@ def check_service(url: str, timeout: float = 8.0) -> tuple:
|
||||
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)
|
||||
+323
-153
@@ -1,211 +1,381 @@
|
||||
"""
|
||||
gateway 黑盒用户模拟测试 — 注册 → 登录 → Token → 代理转发。
|
||||
gateway 黑盒用户模拟测试(v2 — 对齐 gateway v0.8.0 契约)
|
||||
|
||||
用户旅程:
|
||||
1. 注册新账号(含密码强度校验、邮箱格式校验)
|
||||
2. 登录获取 Token
|
||||
3. Token 用于代理访问后端服务
|
||||
4. 重复注册(应失败)
|
||||
5. 错误密码登录(应失败)
|
||||
6. 无效 Token 代理(应拒绝)
|
||||
7. 密码强度不足(应拒绝)
|
||||
8. 邮箱格式错误(应拒绝)
|
||||
覆盖范围(P0,无需测试钩子):
|
||||
A. 输入校验与错误语义(v0.8.0 新契约:/login 强制 CAPTCHA、/register 强制 email_code)
|
||||
B. /whoami 与 /admin/verify(fail-closed 契约)
|
||||
C. /captcha/image 代理路由(captcha2 在线时)
|
||||
D. 反向代理与路径安全(路径遍历矩阵,含 URL 编码变体)
|
||||
E. 速率限制(随机 XFF 隔离限流桶,避免用例互斥)
|
||||
F. CORS 预检现状固化 + 错误响应脱敏回归(P0-2/P0-3)
|
||||
|
||||
已知待修复缺陷(断言目标行为,gateway 修复前会 FAIL):
|
||||
- A3: 缺失 JSON 字段 → 当前 500,应为 400(场景 A5/A8)
|
||||
- A2: 白名单外 Target-Url → 当前 500,应为 403(需有效 Token 才能触达,待测试钩子)
|
||||
|
||||
认证 happy path(注册/登录/Token 生命周期/Cookie/jti 吊销/refresh)依赖
|
||||
captcha2/smtp 测试模式(固定验证码答案钩子),落地前黑盒无法覆盖(P1 清单)。
|
||||
|
||||
依赖说明:
|
||||
- gateway 必须运行,否则全部 SKIP
|
||||
- captcha2 (pymsg:8009) 不在线时 C/E5 段 SKIP(其余用例不受影响)
|
||||
- static (:9200) 不在线时 D3 段 SKIP
|
||||
- smtp 不在线不影响本文件(相关用例均为 fail-closed 400 路径)
|
||||
"""
|
||||
|
||||
import json
|
||||
import http.client
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from helpers import (
|
||||
http_get, http_post, try_parse_json,
|
||||
assert_eq, assert_status, assert_true, assert_json_key,
|
||||
Counter, check_service,
|
||||
http_get, http_post, http_request, try_parse_json,
|
||||
assert_eq, assert_true, assert_no_internal_leak,
|
||||
skip, Counter, check_service, check_tcp_service,
|
||||
random_xff, db_execute,
|
||||
)
|
||||
from config import get_url
|
||||
from config import SERVICES, get_url
|
||||
|
||||
GATEWAY_URL = get_url("gateway")
|
||||
STATIC_URL = get_url("static")
|
||||
|
||||
# 本测试文件使用的账号前缀(P0 用例不会真正创建用户,清理为防御性执行)
|
||||
TEST_USER_PREFIX = "testgw_"
|
||||
|
||||
|
||||
def _random_email():
|
||||
"""生成随机测试邮箱。"""
|
||||
"""生成随机测试邮箱(带本文件专属前缀,便于清理)。"""
|
||||
suffix = ''.join(random.choices(string.ascii_lowercase, k=8))
|
||||
return f"test_{suffix}@example.com"
|
||||
return f"{TEST_USER_PREFIX}{suffix}@example.com"
|
||||
|
||||
|
||||
def _random_password():
|
||||
"""生成符合强度要求的随机密码。"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%"
|
||||
return ''.join(random.choices(chars, k=12))
|
||||
def _strong_password():
|
||||
"""生成必过强度校验的密码(含大写/小写/数字/特殊四类)。"""
|
||||
body = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
|
||||
return f"Gw{body}!1"
|
||||
|
||||
|
||||
def _raw_get_status(host: str, port: int, raw_path: str, headers: dict = None):
|
||||
"""发送原始 GET(不做任何路径规范化),返回 (status, body)。
|
||||
|
||||
urllib/curl 默认会折叠 ".." 路径段,路径遍历测试必须用原始请求行。
|
||||
"""
|
||||
conn = http.client.HTTPConnection(host, port, timeout=10)
|
||||
try:
|
||||
conn.putrequest("GET", raw_path, skip_accept_encoding=True)
|
||||
for k, v in (headers or {}).items():
|
||||
conn.putheader(k, v)
|
||||
conn.endheaders()
|
||||
resp = conn.getresponse()
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
return resp.status, body
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def run(url: str = None):
|
||||
"""gateway 用户模拟测试入口。返回 (passed, failed, skipped)。"""
|
||||
gw = url or GATEWAY_URL
|
||||
c = Counter()
|
||||
err_bodies = [] # 收集错误响应体,末尾统一做脱敏回归(F2)
|
||||
|
||||
print(f"\n--- gateway 用户注册与登录流程 ---\n")
|
||||
print(f"\n--- gateway 输入校验/Token契约/代理/限流 测试 ---\n")
|
||||
|
||||
parsed = urllib.parse.urlparse(gw)
|
||||
gw_host, gw_port = parsed.hostname, parsed.port or 80
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 检查服务可用性
|
||||
# 0. 依赖可用性探测
|
||||
# ------------------------------------------------------------------
|
||||
if not check_service(gw)[0]:
|
||||
print(" gateway 服务不可达,跳过所有测试")
|
||||
c.add_skip()
|
||||
return c.passed, c.failed, c.skipped
|
||||
|
||||
email = _random_email()
|
||||
password = _random_password()
|
||||
token = None
|
||||
cap_svc = SERVICES["captcha"]
|
||||
captcha_up = check_tcp_service(cap_svc["host"], cap_svc["port"])[0]
|
||||
if not captcha_up:
|
||||
print(" [WARN] captcha 服务不可达,/captcha/image 相关用例将 SKIP")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 1: 注册 — 密码强度不足
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_post(f"{gw}/register", {
|
||||
"username": email,
|
||||
"password": "123",
|
||||
})
|
||||
static_up = check_service(STATIC_URL)[0]
|
||||
if not static_up:
|
||||
print(" [WARN] static 服务不可达,默认代理转发用例将 SKIP")
|
||||
|
||||
# ==================================================================
|
||||
# A. 输入校验与错误语义(v0.8.0 新契约)
|
||||
# ==================================================================
|
||||
print(" [A] 输入校验与错误语义")
|
||||
|
||||
# A1: 注册-密码太短 → 401(密码强度校验先于邮箱验证码校验)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register",
|
||||
{"username": _random_email(), "password": "123"},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 401, "注册-密码太短 (401)", c)
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and not data.get("success"),
|
||||
"注册-密码太短-拒绝", c,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 2: 注册 — 邮箱格式错误
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_post(f"{gw}/register", {
|
||||
"username": "not-an-email",
|
||||
"password": _random_password(),
|
||||
})
|
||||
# A2: 注册-邮箱格式错误 → 400(须先过密码强度校验)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register",
|
||||
{"username": "not-an-email", "password": _strong_password()},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "注册-邮箱格式错误 (400)", c)
|
||||
|
||||
# A3: 注册-缺 email_code → 400(v0.8.0 强制邮箱验证码)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register",
|
||||
{"username": _random_email(), "password": _strong_password()},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "注册-缺email_code (400)", c)
|
||||
|
||||
# A4: 注册-错误 email_code → 400(smtp 不在线时 fail-closed 同为 400)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register",
|
||||
{"username": _random_email(), "password": _strong_password(),
|
||||
"email_code": "000000"},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "注册-错误email_code (400)", c)
|
||||
|
||||
# A5: 登录-空 JSON → 400(审计 A3:当前实现返回 500,修复前 FAIL)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/login", {},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "登录-缺字段 (400) [A3: 当前500,待修复]", c)
|
||||
|
||||
# A6: 登录-缺 captcha 字段 → 400(v0.8.0 强制 CAPTCHA)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/login",
|
||||
{"username": _random_email(), "password": _strong_password()},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "登录-缺captcha (400)", c)
|
||||
|
||||
# A7: 登录-错误 captcha → 400(captcha 不可用 fail-closed 同为 400)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/login",
|
||||
{"username": _random_email(), "password": _strong_password(),
|
||||
"captcha_token": "0" * 64, "captcha_answer": "AAAAA"},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "登录-错误captcha (400)", c)
|
||||
|
||||
# A8: 注册-空 JSON → 400(审计 A3:当前实现返回 500,修复前 FAIL)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register", {},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, "注册-缺字段 (400) [A3: 当前500,待修复]", c)
|
||||
|
||||
# ==================================================================
|
||||
# B. /whoami 与 /admin/verify(fail-closed 契约)
|
||||
# ==================================================================
|
||||
print(" [B] whoami / admin.verify")
|
||||
|
||||
# B1: whoami 无 Token → 200 + {"login": false}
|
||||
status, body, _ = http_post(f"{gw}/whoami")
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and not data.get("success"),
|
||||
"注册-邮箱格式错误-拒绝", c,
|
||||
)
|
||||
status == 200 and data is not None and data.get("login") is False,
|
||||
"whoami-无Token (200, login:false)", c, f"status={status} body={body[:80]}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 3: 注册 — 成功
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_post(f"{gw}/register", {
|
||||
"username": email,
|
||||
"password": password,
|
||||
})
|
||||
assert_eq(status, 200, "注册-成功 (200)", c)
|
||||
# B2: whoami 伪造 Token → 200 + {"login": false}(不泄露验签细节)
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/whoami", headers={"Authorization": f"Bearer {'A'*40}_{'B'*40}"})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and data.get("success") is True,
|
||||
"注册-返回 success=true", c,
|
||||
)
|
||||
status == 200 and data is not None and data.get("login") is False,
|
||||
"whoami-伪造Token (login:false)", c, f"status={status} body={body[:80]}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 4: 注册 — 重复用户名
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_post(f"{gw}/register", {
|
||||
"username": email,
|
||||
"password": password,
|
||||
})
|
||||
# 期望注册失败(用户名已存在)
|
||||
assert_true(
|
||||
status != 200 or (try_parse_json(body) or {}).get("success") is not True,
|
||||
"注册-重复用户名-拒绝", c,
|
||||
f"status={status}, body={body[:100]}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 5: 登录 — 错误密码
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_post(f"{gw}/login", {
|
||||
"username": email,
|
||||
"password": "WrongPassword1",
|
||||
})
|
||||
assert_eq(status, 401, "登录-错误密码 (401)", c)
|
||||
# B3: admin/verify 无 Token → 401 fail-closed
|
||||
status, body, _ = http_post(f"{gw}/admin/verify")
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and not data.get("success"),
|
||||
"登录-错误密码-拒绝", c,
|
||||
)
|
||||
status == 401 and data is not None and data.get("success") is False,
|
||||
"admin/verify-无Token (401)", c, f"status={status}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 6: 登录 — 成功获取 Token
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_post(f"{gw}/login", {
|
||||
"username": email,
|
||||
"password": password,
|
||||
})
|
||||
assert_eq(status, 200, "登录-成功 (200)", c)
|
||||
# B4: admin/verify 伪造 Token → 401
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/admin/verify",
|
||||
headers={"Authorization": f"Bearer {'A'*40}_{'B'*40}"})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and data.get("success") is True,
|
||||
"登录-返回 success=true", c,
|
||||
)
|
||||
token_data = (data or {}).get("data", {})
|
||||
token = token_data.get("token", "")
|
||||
assert_true(
|
||||
len(token) > 20 if token else False,
|
||||
"登录-Token 不为空", c,
|
||||
)
|
||||
status == 401 and data is not None and data.get("success") is False,
|
||||
"admin/verify-伪造Token (401)", c, f"status={status}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 7: 代理 — 使用 Token 访问静态服务
|
||||
# ------------------------------------------------------------------
|
||||
if token:
|
||||
# ==================================================================
|
||||
# C. /captcha/image 代理路由(captcha2 在线时)
|
||||
# ==================================================================
|
||||
print(" [C] /captcha/image 代理")
|
||||
if captcha_up:
|
||||
status, body, headers = http_get(
|
||||
f"{gw}/",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Target-Url": STATIC_URL,
|
||||
},
|
||||
)
|
||||
f"{gw}/captcha/image",
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
assert_eq(status, 200, "captcha/image-代理 (200)", c)
|
||||
content_type = headers.get("Content-Type", headers.get("content-type", ""))
|
||||
assert_true("image/png" in content_type,
|
||||
"captcha/image-Content-Type(png)", c, f"ct={content_type}")
|
||||
cap_token = headers.get("X-Captcha-Token", headers.get("x-captcha-token", ""))
|
||||
assert_true(
|
||||
status in (200, 302, 301),
|
||||
"代理-GET静态首页", c,
|
||||
f"status={status}",
|
||||
)
|
||||
bool(re.fullmatch(r"[0-9a-f]{64}", cap_token)),
|
||||
"captcha/image-Token(64位hex)", c, f"token={cap_token[:16]}...")
|
||||
cache_ctl = headers.get("Cache-Control", headers.get("cache-control", ""))
|
||||
assert_true("no-store" in cache_ctl,
|
||||
"captcha/image-禁缓存(no-store)", c, f"cc={cache_ctl}")
|
||||
else:
|
||||
skip("captcha/image 代理", "captcha 服务不可达", c)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 8: 代理 — 无 Token 应拒绝
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_get(
|
||||
f"{gw}/some-path",
|
||||
headers={"Target-Url": STATIC_URL},
|
||||
)
|
||||
# 缺少 Token 应返回 400/401
|
||||
# ==================================================================
|
||||
# D. 反向代理与路径安全
|
||||
# ==================================================================
|
||||
print(" [D] 反向代理与路径安全")
|
||||
|
||||
# D1: 显式 Target-Url + 无 Token → 401(Token 校验先于转发,不依赖 static)
|
||||
status, body, _ = http_get(f"{gw}/some-path",
|
||||
headers={"Target-Url": STATIC_URL})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 401, "代理-无Token拒绝 (401)", c)
|
||||
|
||||
# D2: 路径遍历矩阵 → 400(路径校验先于 Token 校验;原始请求行防客户端折叠)
|
||||
traversal_paths = [
|
||||
"/../../../etc/passwd", # 明文 ..
|
||||
"/%2e%2e/%2e%2e/etc/passwd", # URL 编码 .
|
||||
"/%2f..%2f..%2fetc", # 编码 /
|
||||
"/%5c..%5cwindows", # 编码反斜杠
|
||||
]
|
||||
for p in traversal_paths:
|
||||
status, body = _raw_get_status(gw_host, gw_port, p,
|
||||
headers={"Target-Url": STATIC_URL})
|
||||
err_bodies.append(body)
|
||||
assert_eq(status, 400, f"代理-路径遍历拒绝 {p[:24]} (400)", c)
|
||||
|
||||
# D3: 默认代理(无 Target-Url,公开目标不需 Token)
|
||||
if static_up:
|
||||
status, body, _ = http_get(f"{gw}/")
|
||||
assert_true(status in (200, 301, 302),
|
||||
"代理-默认路由(static)", c, f"status={status}")
|
||||
else:
|
||||
skip("代理-默认路由", "static 服务不可达", c)
|
||||
|
||||
# ==================================================================
|
||||
# E. 速率限制(随机 XFF 隔离限流桶)
|
||||
# ==================================================================
|
||||
print(" [E] 速率限制")
|
||||
|
||||
# E1: 登录限流 5 次/5 分钟/IP —— 同 XFF 前 5 次 400,第 6 次 429
|
||||
xff = random_xff()
|
||||
results = []
|
||||
for _ in range(6):
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/login",
|
||||
{"username": _random_email(), "password": _strong_password()},
|
||||
headers={"X-Forwarded-For": xff})
|
||||
results.append(status)
|
||||
err_bodies.append(body)
|
||||
assert_true(all(s == 400 for s in results[:5]),
|
||||
"限流-登录前5次放行(400)", c, f"results={results}")
|
||||
assert_eq(results[5], 429, "限流-登录第6次 (429)", c)
|
||||
|
||||
# E2: 限流桶隔离 —— 换 XFF 后不再 429(固化审计 A4:直连模式 XFF 客户端可控)
|
||||
status, _, _ = http_post(
|
||||
f"{gw}/login",
|
||||
{"username": _random_email(), "password": _strong_password()},
|
||||
headers={"X-Forwarded-For": random_xff()})
|
||||
assert_eq(status, 400, "限流-换XFF重置桶 (400≠429)", c)
|
||||
|
||||
# E3: 注册限流 3 次/10 分钟/IP —— 前 3 次 400,第 4 次 429
|
||||
xff = random_xff()
|
||||
results = []
|
||||
for _ in range(4):
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register",
|
||||
{"username": _random_email(), "password": _strong_password()},
|
||||
headers={"X-Forwarded-For": xff})
|
||||
results.append(status)
|
||||
err_bodies.append(body)
|
||||
assert_true(all(s == 400 for s in results[:3]),
|
||||
"限流-注册前3次放行(400)", c, f"results={results}")
|
||||
assert_eq(results[3], 429, "限流-注册第4次 (429)", c)
|
||||
|
||||
# E4: 发码限流 5 次/10 分钟/IP —— 缺 captcha 每次 400,第 6 次 429
|
||||
xff = random_xff()
|
||||
results = []
|
||||
for _ in range(6):
|
||||
status, body, _ = http_post(
|
||||
f"{gw}/register/send_code",
|
||||
{"email": _random_email()},
|
||||
headers={"X-Forwarded-For": xff})
|
||||
results.append(status)
|
||||
err_bodies.append(body)
|
||||
assert_true(all(s == 400 for s in results[:5]),
|
||||
"限流-发码前5次放行(400)", c, f"results={results}")
|
||||
assert_eq(results[5], 429, "限流-发码第6次 (429)", c)
|
||||
|
||||
# E5: /captcha/image 限流 10 次/分钟/IP —— 前 10 次 200,第 11 次 429
|
||||
# (captcha2 全局限流 600/分钟,不干扰本用例)
|
||||
if captcha_up:
|
||||
xff = random_xff()
|
||||
results = []
|
||||
for _ in range(11):
|
||||
status, _, _ = http_get(f"{gw}/captcha/image",
|
||||
headers={"X-Forwarded-For": xff})
|
||||
results.append(status)
|
||||
assert_true(all(s == 200 for s in results[:10]),
|
||||
"限流-验证码前10次放行(200)", c, f"results={results}")
|
||||
assert_eq(results[10], 429, "限流-验证码第11次 (429)", c)
|
||||
else:
|
||||
skip("限流-验证码", "captcha 服务不可达", c)
|
||||
|
||||
# ==================================================================
|
||||
# F. CORS 预检现状 + 错误响应脱敏回归
|
||||
# ==================================================================
|
||||
print(" [F] CORS 与错误脱敏")
|
||||
|
||||
# F1: OPTIONS 预检 —— 固化当前行为:反射任意 Origin(P2-3 未修复)。
|
||||
# TODO(P2-3): 白名单生效后应翻转断言为「白名单外 Origin 无 ACAO 头」。
|
||||
status, _, headers = http_request(
|
||||
"OPTIONS", f"{gw}/login",
|
||||
headers={"Origin": "https://test-orig.example",
|
||||
"Access-Control-Request-Method": "POST"})
|
||||
acao = headers.get("Access-Control-Allow-Origin",
|
||||
headers.get("access-control-allow-origin", ""))
|
||||
assert_true(
|
||||
status in (400, 401, 403),
|
||||
"代理-无Token拒绝", c,
|
||||
f"status={status}",
|
||||
)
|
||||
acao == "https://test-orig.example",
|
||||
"CORS-预检反射Origin(现状,P2-3待修)", c, f"ACAO={acao!r} status={status}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 9: 代理 — 无效路径(路径遍历)
|
||||
# ------------------------------------------------------------------
|
||||
if token:
|
||||
status, body, _ = http_get(
|
||||
f"{gw}/../../../etc/passwd",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Target-Url": STATIC_URL,
|
||||
},
|
||||
)
|
||||
assert_true(
|
||||
status in (400, 403),
|
||||
"代理-路径遍历拒绝", c,
|
||||
f"status={status}",
|
||||
)
|
||||
# F2: 错误响应脱敏 —— 所有收集到的 4xx/5xx 响应体不得含源码路径/类型名
|
||||
assert_no_internal_leak("\n".join(err_bodies),
|
||||
f"错误响应脱敏(共{len(err_bodies)}条样本)", c)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 10: 默认代理 (无 Target-Url,无需 Token)
|
||||
# ------------------------------------------------------------------
|
||||
status, body, _ = http_get(f"{gw}/")
|
||||
assert_true(
|
||||
status in (200, 302, 301),
|
||||
"代理-默认路由", c,
|
||||
f"status={status}",
|
||||
)
|
||||
# ==================================================================
|
||||
# 测试后清理(P0 用例不创建用户,防御性执行;db 不可达则跳过)
|
||||
# ==================================================================
|
||||
db_svc = SERVICES["db"]
|
||||
if check_tcp_service(db_svc["host"], db_svc["port"])[0]:
|
||||
resp = db_execute(
|
||||
"delete", "Users",
|
||||
where={"username": {"like": f"{TEST_USER_PREFIX}%"}},
|
||||
host=db_svc["host"], port=db_svc["port"])
|
||||
if not resp.get("success") and "table not found" in str(resp.get("error", "")):
|
||||
# 当前环境 db 无 Users 表(如本地开发库),清理无意义
|
||||
skip("清理-删除本测试用户记录", "当前环境无 Users 表", c)
|
||||
else:
|
||||
assert_true(resp.get("success") is True,
|
||||
"清理-删除本测试用户记录", c, f"resp={str(resp)[:100]}")
|
||||
# 旧版测试遗留污染(test_%@example.com)仅报告数量,不自动删除
|
||||
legacy = db_execute(
|
||||
"select", "Users",
|
||||
where={"username": {"like": "test_%@example.com"}},
|
||||
host=db_svc["host"], port=db_svc["port"])
|
||||
if legacy.get("success") and legacy.get("data"):
|
||||
print(f" [WARN] 发现 {len(legacy['data'])} 条旧版测试遗留用户 "
|
||||
f"(test_*@example.com),建议人工清理")
|
||||
else:
|
||||
skip("清理-测试用户", "db 服务不可达", c)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 汇总
|
||||
|
||||
Reference in new issue
Block a user