security: 白板 WS 加单帧大小限制与单 board 连接数上限
安全审计发现: - 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 仅用于静态文案。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 删除以防内存泄漏。"""
|
||||
|
||||
Reference in New Issue
Block a user