From da7d2fb4b4f97009c09f3e6fb5e21d5d7696f527 Mon Sep 17 00:00:00 2001 From: zikai Date: Tue, 21 Jul 2026 15:09:13 +0000 Subject: [PATCH] =?UTF-8?q?security:=20=E7=99=BD=E6=9D=BF=20WS=20=E5=8A=A0?= =?UTF-8?q?=E5=8D=95=E5=B8=A7=E5=A4=A7=E5=B0=8F=E9=99=90=E5=88=B6=E4=B8=8E?= =?UTF-8?q?=E5=8D=95=20board=20=E8=BF=9E=E6=8E=A5=E6=95=B0=E4=B8=8A?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全审计发现: - WS receive_text 无应用层大小限制,恶意客户端可发超大帧(uvicorn 默认 16MB 才拦截)占内存。加 512KB 单帧检查(与 content 256KB 限制对齐留余量)。 - 单 board 无连接数上限,恶意脚本可开海量连接耗尽资源。config 加 max_connections_per_board(默认 50),hub.register 超限返回 False, controller 回 error 帧并关闭连接。 审计结论(无需修复): - SQL:全部 SQLAlchemy ORM 参数化,无注入。 - 路径穿越:storage_path 服务端生成(uuid+basename(ext)),用户不可控分隔符。 - 鉴权:管理类操作均有 require_docs_auth,公开写接口符合设计。 - 下载 filename CRLF:Starlette FileResponse 用 quote() 编码,无响应拆分。 - XSS:前端 el() 用 createTextNode,textarea 纯文本,innerHTML 仅用于静态文案。 --- app/config.py | 2 ++ app/controllers/whiteboard_controller.py | 11 ++++++++++- app/services/whiteboard_hub.py | 11 +++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/config.py b/app/config.py index a80d508..98a03ec 100644 --- a/app/config.py +++ b/app/config.py @@ -102,6 +102,8 @@ class WhiteboardConfig(BaseModel): heartbeat_miss_threshold: int = 5 # board_id 合法字符集与长度上限,防路径/注入 max_board_id_length: int = 64 + # 单 board 并发连接上限,防资源耗尽(同 board 同时在线人数) + max_connections_per_board: int = 50 # 列表/管理页分页默认值 list_limit: int = 100 diff --git a/app/controllers/whiteboard_controller.py b/app/controllers/whiteboard_controller.py index ae840dc..9be4490 100644 --- a/app/controllers/whiteboard_controller.py +++ b/app/controllers/whiteboard_controller.py @@ -124,7 +124,11 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: # 注册连接并下发 init conn = Connection(websocket=websocket, board_id=board_id, client_id=client_id) - await hub.register(conn) + ok = await hub.register(conn) + if not ok: + await _safe_send(websocket, {"type": "error", "msg": "该记事本在线人数已满"}) + await _safe_close(websocket) + return await _safe_send(websocket, { "type": "init", "content": board.content, @@ -133,9 +137,14 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: }) # 主循环:收消息 -> 处理 -> 广播 + # 单帧大小上限:与 content 限制对齐(256KB 文本 + JSON 开销,留余量到 512KB) + MAX_FRAME = 512 * 1024 try: while True: raw = await websocket.receive_text() + if len(raw) > MAX_FRAME: + await _safe_send(websocket, {"type": "error", "msg": "消息过大"}) + continue msg = _parse(raw) if msg is None: continue diff --git a/app/services/whiteboard_hub.py b/app/services/whiteboard_hub.py index 5925089..1010c28 100644 --- a/app/services/whiteboard_hub.py +++ b/app/services/whiteboard_hub.py @@ -58,19 +58,26 @@ class WhiteboardHub: self.heartbeat_interval = cfg.heartbeat_interval_seconds self.heartbeat_miss_threshold = cfg.heartbeat_miss_threshold self.timeout_seconds = self.heartbeat_interval * self.heartbeat_miss_threshold + self.max_connections_per_board = cfg.max_connections_per_board # {board_id: set[Connection]} self._boards: dict[str, set[Connection]] = {} self._lock = asyncio.Lock() # ---------------- 连接生命周期 ---------------- - async def register(self, conn: Connection) -> None: - """把已 accept 的连接加入 board 集合(WebSocket accept 由 controller 负责)。""" + async def register(self, conn: Connection) -> bool: + """把已 accept 的连接加入 board 集合。 + + 返回 False 表示该 board 连接数已达上限(调用方应关闭连接)。 + """ async with self._lock: conns = self._boards.setdefault(conn.board_id, set()) + if len(conns) >= self.max_connections_per_board: + return False conns.add(conn) logger.info("连接接入 board=%s client=%s(当前 %d 人)", conn.board_id, conn.client_id, self.connection_count(conn.board_id)) + return True async def disconnect(self, conn: Connection) -> None: """幂等移除连接;空 set 从 dict 删除以防内存泄漏。"""