fix(gateway): chat 的 image 加上限并显式报错(G3,审计协同 §G)
- 原状: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:
1 parent
fbad02899a
commit
3638ed7708
2 files changed
+267
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user