- SendAll:发送改为循环直到发完并检查返回值。原实现单次 send 且忽略返回值, 截图这类大响应可能被截断;同时 accept 时补设 SO_SNDTIMEO——响应由 AutoCAD 主线程发送,慢客户端若把 send 卡住会直接拖死 CAD 的 UI。 - RecvOneFrame:去掉"收完 select 探测 150ms 无后续数据即判定整帧"的启发式。 该做法与分片时机强相关,真实网络上分片间隔 >150ms 的大报文会被误截断成 -32700。 改为「零超时 select 探测 + JSON 完整性判定」:数据仍在流式到达时不解析, 与分片间隔无关;且仅在内容以 '}'/']' 收尾时才尝试解析,避免大报文反复解析(O(n^2))。 - 帧超限:不再静默断开,回顶层 -32700。回包后先 shutdown(SD_SEND) 再有界排空剩余 输入再关闭——否则接收缓冲仍有未读数据会触发 RST,可能把刚发出的响应丢掉。 - PostMessage:检查返回值。原实现只在窗口为空时 delete,投递失败(窗口已销毁 / 队列满)会让 TaskData 永久泄漏;现在失败即释放(析构会关闭 socket)。 验证(关 CAD -> 重建 -> 重启后实测): - 新增 verify_mcp_tcp_hardening.py 8/8 PASS:截图 base64 解码为完整 PNG(92175 字节, 含文件头与 IEND);>8MB 帧回 -32700;不带换行的旧式请求仍可用;分片间隔 450ms 不被截断(旧实现会失败);客户端中途断开后基座存活;常规路径无回归。 - 回归:verify_mcp_frame 4/4、verify_mcp_fault_injection 22/22、verify_stdio_bridge 24/24、 verify_annotate 9/9、dwg_review --check 正常。
178 lines
6.0 KiB
Python
178 lines
6.0 KiB
Python
"""TCP 收发路径加固验证。
|
||
|
||
对应 mcp_server.cpp 的四项加固:
|
||
1) SendAll 循环发送、检查返回值(大响应不被截断);
|
||
2) RecvOneFrame 用「零超时探测 + JSON 完整性」替代旧的 150ms select 探测;
|
||
3) 帧超限回 -32700 而非静默断开;
|
||
4) PostMessage 失败释放 TaskData(无法从外部观测,靠代码审查)。
|
||
|
||
前置条件:CAD 已启动并加载 cad_mcp_frame.arx,监听 127.0.0.1:8080。
|
||
运行:python verify_mcp_tcp_hardening.py
|
||
"""
|
||
|
||
import base64
|
||
import json
|
||
import socket
|
||
import sys
|
||
import time
|
||
|
||
from dwg_review import config
|
||
|
||
PASS = 0
|
||
FAIL = 0
|
||
|
||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||
PNG_IEND = b"IEND\xaeB`\x82"
|
||
|
||
|
||
def check(name, cond, detail=""):
|
||
global PASS, FAIL
|
||
if cond:
|
||
PASS += 1
|
||
print(f"[PASS] {name}")
|
||
else:
|
||
FAIL += 1
|
||
print(f"[FAIL] {name} {detail!r}")
|
||
|
||
|
||
def connect():
|
||
return socket.create_connection((config.HOST, config.PORT), timeout=20)
|
||
|
||
|
||
def read_frame(sock):
|
||
"""读到一帧(以换行结尾);返回原始 bytes(空表示对端已关闭/无数据)。"""
|
||
buf = b""
|
||
while b"\n" not in buf:
|
||
try:
|
||
chunk = sock.recv(65536)
|
||
except (socket.timeout, OSError):
|
||
break
|
||
if not chunk:
|
||
break
|
||
buf += chunk
|
||
return buf
|
||
|
||
|
||
def parse(raw):
|
||
text = raw.decode("utf-8", errors="replace").strip()
|
||
try:
|
||
return json.loads(text) if text else None
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def rpc_once(payload):
|
||
"""发一帧(自带换行),读回一帧,返回解析后的 dict(无响应返回 None)。"""
|
||
sock = connect()
|
||
try:
|
||
sock.sendall(payload + b"\n")
|
||
return parse(read_frame(sock))
|
||
finally:
|
||
sock.close()
|
||
|
||
|
||
def rpc(method, params=None, rid=1):
|
||
msg = {"jsonrpc": "2.0", "id": rid, "method": method}
|
||
if params is not None:
|
||
msg["params"] = params
|
||
return rpc_once(json.dumps(msg, ensure_ascii=False).encode("utf-8"))
|
||
|
||
|
||
def main():
|
||
print("=" * 68)
|
||
print(" TCP 收发路径加固验证")
|
||
print("=" * 68)
|
||
|
||
# ---- T1 大响应完整性:截图 base64 必须解码为完整 PNG ----------------
|
||
r = rpc("tools/call", {"name": "get_viewport_screenshot", "arguments": {}}, rid=1)
|
||
content = ((r or {}).get("result") or {}).get("content") or []
|
||
img = next((c for c in content if c.get("type") == "image"), None)
|
||
check("T1 截图返回 image 内容块", img is not None, r)
|
||
if img:
|
||
raw = base64.b64decode(img.get("data", ""))
|
||
check("T1 截图解码为完整 PNG(含文件头与 IEND)",
|
||
raw.startswith(PNG_MAGIC) and raw.rstrip().endswith(PNG_IEND),
|
||
f"len={len(raw)} head={raw[:8]!r} tail={raw[-12:]!r}")
|
||
print(f" 截图 {len(raw)} 字节")
|
||
|
||
# ---- T2 超限帧 -> -32700(而非静默断开)------------------------------
|
||
padding = "A" * (8 * 1024 * 1024 + 4096) # 超过 8MB 上限
|
||
oversized = json.dumps({
|
||
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||
"params": {"name": "__pad__", "arguments": {"pad": padding}},
|
||
}, ensure_ascii=False).encode("utf-8")
|
||
check("T2 构造的报文确实超过 8MB", len(oversized) > 8 * 1024 * 1024, len(oversized))
|
||
|
||
sock = connect()
|
||
try:
|
||
# 服务端可能在客户端发完前就回包并关闭,sendall 会抛异常——忽略,随后仍尝试读响应
|
||
try:
|
||
sock.sendall(oversized + b"\n")
|
||
except OSError:
|
||
pass
|
||
resp = parse(read_frame(sock))
|
||
code = ((resp or {}).get("error") or {}).get("code")
|
||
check("T2 超限帧 -> 顶层 error -32700", code == -32700, resp)
|
||
finally:
|
||
sock.close()
|
||
|
||
# ---- T3 不发换行的旧式请求仍可用(规则 2)----------------------------
|
||
legacy = json.dumps({"jsonrpc": "2.0", "id": 3, "method": "tools/list",
|
||
"params": {}}, ensure_ascii=False).encode("utf-8")
|
||
sock = connect()
|
||
try:
|
||
sock.sendall(legacy) # 不带换行,且保持连接
|
||
resp = parse(read_frame(sock))
|
||
tools = ((resp or {}).get("result") or {}).get("tools") or []
|
||
check("T3 无换行的旧式请求仍能取到 tools/list", len(tools) == 26, f"got {len(tools)}")
|
||
finally:
|
||
sock.close()
|
||
|
||
# ---- T4 分片间隔 >150ms 不被截断(旧实现会在此误判为整帧)------------
|
||
part1 = (b'{"jsonrpc":"2.0","id":4,"method":"tools/call",'
|
||
b'"params":{"name":"get_drawing_info","arguments":{}}')
|
||
part2 = b"}\n"
|
||
sock = connect()
|
||
try:
|
||
sock.sendall(part1)
|
||
time.sleep(0.45) # 远超旧实现的 150ms 探测窗口
|
||
sock.sendall(part2)
|
||
resp = parse(read_frame(sock))
|
||
ok = (resp or {}).get("result") is not None
|
||
check("T4 分片间隔 450ms 的请求未被截断", ok, resp)
|
||
finally:
|
||
sock.close()
|
||
|
||
# ---- T5 发送后立即断开:基座不崩 -------------------------------------
|
||
for i in range(3):
|
||
s = connect()
|
||
try:
|
||
s.sendall(json.dumps({
|
||
"jsonrpc": "2.0", "id": 50 + i, "method": "tools/call",
|
||
"params": {"name": "get_drawing_info", "arguments": {}},
|
||
}).encode("utf-8") + b"\n")
|
||
except OSError:
|
||
pass
|
||
s.close() # 不读响应直接断开
|
||
|
||
time.sleep(0.5)
|
||
r = rpc("tools/list", {}, rid=60)
|
||
tools = ((r or {}).get("result") or {}).get("tools") or []
|
||
check("T5 客户端中途断开后基座仍存活(tools/list 正常)", len(tools) == 26, f"got {len(tools)}")
|
||
|
||
# ---- T6 常规路径无回归 -----------------------------------------------
|
||
r = rpc("tools/call", {"name": "get_drawing_info", "arguments": {}}, rid=70)
|
||
check("T6 常规请求正常返回", (r or {}).get("result") is not None, r)
|
||
|
||
print("-" * 68)
|
||
print(f"通过 {PASS} / 失败 {FAIL}")
|
||
if FAIL:
|
||
print("结果:FAIL")
|
||
return 1
|
||
print("结果:全部通过")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|