Files
cad-agent/verify_mcp_tcp_hardening.py
T
gjm 4167ea033f fix(frame): 加固 TCP 收发路径(大响应、分帧判定、超限回包、PostMessage 泄漏)
- 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 正常。
2026-10-14 15:49:15 +08:00

178 lines
6.0 KiB
Python
Raw 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.
"""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())