fix(gateway): chat 的 image 加上限并显式报错(G3,审计协同 §G)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 原状:image(base64/URL 字符串)在 API 层**无长度上限**、原样落库 ⇒ 一张 5 MiB 的图 ≈ 每页历史重传 6.7 MB
- 修法:入口层(handleChat,位于 stream 分叉**之前** ⇒ 同步与 stream:true 两条路径同拦)按**字符串长度零分配**判定,
  超限 → 400 `image_too_large`,文案给出上限并**指路 POST /api/v1/upload**;日志只记 session/chars/limit(不打图片内容)
- 上限口径:**与 POST /upload 的 maxUploadBytes = 5 MiB 取同一个答案**(否则内联 base64 可绕开上传限制);
  字符上限 = ceil(n/3)*4 + 64(覆盖 data URI 前缀),**恰好 5 MiB 的整幅图编码后 = 6,990,508 字符,绝不误拒**
- 复核:6 条用例(上限与 upload 同值、边界 ==limit 通过 / +1 拒、超限**0 个回合被驱动**、
  stream:true 同样被拒、恰好等于上限逐字节到达 runner、小图行为逐字节不变);
  **2 条变异反证**(忘接守卫 / 边界改 `<`)均已还原
- 实测附注:WS 侧今天**已被传输层关死**(wsReadLimit=4096,8KB chat 帧直接断连)⇒ 多 MB base64 无法经 WS 进入;
  若日后放宽该上限,必须在 ws 入口补同一个校验(回报里给了精确落点)
- 未做(登记):上限暂不可配置(等 config.go 空闲)、REST 请求体仍整体读入内存(MaxBytesReader 属行为变更)、
  vision 层 base64 路径无长度预检(纵深防御)、历史 base64 行需离线批处理(方案已出)
This commit is contained in:
AskaEth committed 2026-10-04 14:21:12 +08:00
1 parent fbad02899a
commit 3638ed7708
2 files changed
+267

No files matched your search

+57
View File
@@ -103,6 +103,8 @@ type loginResponse struct {
// 经 WebSocket 通道触发流式并立即返回。Image 是
// 为视觉路径附带的可选 base64 data: URI 或 http(s) URL;
// loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。
// Image 受 maxChatImageChars 限制(与 POST /api/v1/upload 的 5 MiB 同口径),
// 超限一律 400 image_too_large,不被静默吃下。
// DeviceID 是客户端产出的发送浏览器/设备标识
// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。
// Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16
@@ -124,6 +126,52 @@ type chatResponse struct {
StopReason string `json:"stopReason"`
}
// maxChatImageBytes 是 chat.image 允许的**解码后图像字节**上限,与
// POST /api/v1/upload 的 maxUploadBytes(5 MiB,见 upload.go)同口径:
// 图片有上传与内联两条入口,两边必须给出同一个「一张图能有多大」的答案,
// 否则上传的限制可被 base64 内联绕开 —— 客户端发来多大就落库多大,
// 每页历史(GET /sessions/{id}/messages)与每次 WS history 回放都要
// 把整段 base64 再传一遍(协同文档 G3)。
const maxChatImageBytes = 5 << 20
// chatImagePrefixAllowance 是 data: URI 前缀(如 "data:image/png;base64,")
// 所占的字符余量。按常见图片 MIME 的最长前缀取整,不含 base64 载荷本身。
const chatImagePrefixAllowance = 64
// maxChatImageChars 是同一口径换算到 base64 字符串长度的上限。base64 每
// 3 字节编码为 4 字符(膨胀 4/3),因此正好 5 MiB 的整幅图编码后是
// ceil(maxChatImageBytes/3)*4 = 6990508 字符(这就是 (n+2)/3*4 的由来:
// 余 1、2 字节时末组仍需补满 4 字符),再加 data: 前缀余量 —— 恰好 5 MiB
// 的合法图片绝不会被误拒。
//
// 校验按**字符串长度**做,是因为入口层只有这一步是零分配的:vision 层要先
// base64 解码整个字符串才拿到真实字节数(vision.decodeBase64 →
// base64.StdEncoding.DecodeString),也就是先分配再报错。在入口层按长度拦下,
// 超大字符串根本走不到解码那一步。
const maxChatImageChars = ((maxChatImageBytes+2)/3)*4 + chatImagePrefixAllowance
// chatImageTooLargeCode 是 image 超限时返回给客户端的机器可读错误码,
// 与其它 400 文案区分开:客户端只看 code 就能把「图太大,请改走上传」
// 与「请求体格式错」分开处理。
const chatImageTooLargeCode = "image_too_large"
// checkChatImageSize 校验 image 字符串长度是否在 limit 之内,超限时返回
// 400 + image_too_large 的显式错误(带上限与本次长度,并指明改用
// POST /api/v1/upload 取 URL 再发)。limit 作为参数传入,使该判定能被
// 用小上限直接单测(边界与比较运算符因此可被钉住),REST 入口传的是
// maxChatImageChars。
//
// 文案里的体积由 limit 反推(base64 膨胀 4/3 → 解码后约 limit*3/4 字节),
// 因此换上限时文案自动跟随,不会出现「写着 5 MiB、实际是别的数」。
func checkChatImageSize(image string, limit int) error {
if len(image) <= limit {
return nil
}
return apperr.Wrap(apperr.ErrBadRequest, chatImageTooLargeCode,
"图片过大:image 上限约 %.1f MiB(base64 字符串上限 %d 字符,本次 %d 字符);请先 POST /api/v1/upload 上传,再用返回的 url 作为 image 发送",
float64(limit)*3/4/(1<<20), limit, len(image))
}
// handleHealth 报告存活。它不认证,因为它是探针。
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
@@ -174,6 +222,15 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
// image 上限在**任何**解码/落库之前检查,且对同步与 stream 两条分支
// 都生效(stream 只是把该轮转发给 ws.chat,转发前必须先拒),
// 因此超限的图片既不进模型、也不落库、也不会被历史回放重传。
// 日志只带长度与上限,绝不带图片内容。
if err := checkChatImageSize(req.Image, maxChatImageChars); err != nil {
s.log.Warn("chat image rejected", "session", req.SessionID, "chars", len(req.Image), "limit", maxChatImageChars)
writeError(w, err)
return
}
if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
return