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

@@ -0,0 +1,210 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
// 上限(与 POST /api/v1/upload 的 5 MiB 同口径),超限回 400 +
// image_too_large 的明确错误,而不是把几 MB 的 base64 原样吃下、落库、
// 再被每页历史重传一遍。
// chatImageRecordingRunner 记录每次 Run 看到的完整消息(不只是 sessionID),
// 因此既能断言「超限的轮次从未被驱动」,也能逐字节比对正常小图的图片载荷。
type chatImageRecordingRunner struct {
calls [][]model.Message
res *loop.Result
}
func (r *chatImageRecordingRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
r.calls = append(r.calls, msgs)
if r.res != nil {
return r.res, nil
}
return &loop.Result{Text: "收到", Steps: 1, StopReason: "stop"}, nil
}
// images 返回每次 Run 中首个图片内容的 ImageURL。
func (r *chatImageRecordingRunner) images() []string {
out := make([]string, 0, len(r.calls))
for _, msgs := range r.calls {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeImage {
out = append(out, c.ImageURL)
}
}
}
}
return out
}
// oversizeImage 返回一个长度恰为 maxChatImageChars+1 的 image 串(内容本身
// 是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。
func oversizeImage() string {
return strings.Repeat("A", maxChatImageChars+1)
}
// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:它必须与
// POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成
// base64 字符串长度后既**装得下**一张满额的 5 MiB 图,也不留下荒谬的余量。
// 这条不变量让「有人改了上限或改歪了膨胀系数」在测试里立刻暴露(反证)。
func TestChatImageLimitMatchesUploadBudget(t *testing.T) {
t.Parallel()
if maxChatImageBytes != maxUploadBytes {
t.Fatalf("maxChatImageBytes = %d, want %d (与 POST /upload 同口径)", maxChatImageBytes, maxUploadBytes)
}
// 上限必须装得下一张满额图:base64 长度对应的解码字节数 ≥ 预算。
if got := maxChatImageChars / 4 * 3; got < maxChatImageBytes {
t.Fatalf("maxChatImageChars 只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes)
}
// 余量只能是 data: 前缀的余量,不能顺带塞进一整张图。
if maxChatImageChars > ((maxChatImageBytes+2)/3)*4+4096 {
t.Fatalf("maxChatImageChars = %d 的余量过大(前缀余量应 ≤4096)", maxChatImageChars)
}
}
// TestChatImageSizeBoundary 用极小上限直接钉住比较运算符与错误载荷:恰好
// 等于上限通过,多一个字符就 400 + image_too_large。若判定被改成 >= / <
// (即有人「顺手」把边界挪一位),这里立刻红。
func TestChatImageSizeBoundary(t *testing.T) {
t.Parallel()
const limit = 8
if err := checkChatImageSize(strings.Repeat("A", limit), limit); err != nil {
t.Fatalf("恰好等于上限必须通过,得到 %v", err)
}
err := checkChatImageSize(strings.Repeat("A", limit+1), limit)
if err == nil {
t.Fatal("超过上限一个字符必须报错")
}
if !errors.Is(err, apperr.ErrBadRequest) {
t.Fatalf("错误必须 wrap ErrBadRequest(400),得到 %v", err)
}
var target *apperr.AppError
if !errors.As(err, &target) || target.Code != chatImageTooLargeCode {
t.Fatalf("错误码必须为 %q,得到 %v", chatImageTooLargeCode, err)
}
if got := apperr.HTTPStatus(err); got != http.StatusBadRequest {
t.Fatalf("HTTP 状态 = %d, want 400", got)
}
}
// TestChatImageTooLargeRejected 是主用例:超限的 image 必须 400 +
// image_too_large,且**从未驱动过任何回合** —— 也就不会进模型、不会落库、
// 不会被历史回放重传。变异反证:把入口的校验删掉,这条测试会因为 turn 被
// 驱动而红。
func TestChatImageTooLargeRejected(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage()}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
code, msg := chatImageErrorOf(t, body)
if code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if !strings.Contains(msg, "upload") || !strings.Contains(msg, "/api/v1/upload") {
t.Fatalf("错误文案必须指路 POST /api/v1/upload,得到 %q", msg)
}
if strings.Contains(msg, "AAAA") {
t.Fatalf("错误文案不得回显图片内容:%q", msg)
}
if len(runner.calls) != 0 {
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
// TestChatImageTooLargeRejectedOnStreamPath 覆盖 REST 的 stream 分支:它会把
// 该轮转发给 ws.chat,转发前必须同样被拒(否则流式成为绕开上限的后门)。
func TestChatImageTooLargeRejectedOnStreamPath(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage(), "stream": true}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if len(runner.calls) != 0 {
t.Fatalf("超限的 stream 请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
// TestChatImageAtLimitAccepted 是边界:长度恰好等于上限的 image 照常通过
// (合法客户端在 5 MiB 整幅图上绝不能吃到误拒)。
func TestChatImageAtLimitAccepted(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
image := "data:image/png;base64," + strings.Repeat("A", maxChatImageChars-len("data:image/png;base64,"))
if len(image) != maxChatImageChars {
t.Fatalf("构造长度 = %d, want %d", len(image), maxChatImageChars)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": image}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := runner.images(); len(got) != 1 || got[0] != image {
t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got))
}
}
// TestChatSmallImageUnchanged 锁定「正常小图行为与今天逐字节一致」:小
// data: URI 原样进入图片内容部分,文本与图片两部分都在。
func TestChatSmallImageUnchanged(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
image := "data:image/png;base64,iVBORw0KGgo="
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "text": "看看这张", "image": image}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(runner.calls) != 1 {
t.Fatalf("turn 数 = %d, want 1", len(runner.calls))
}
msgs := runner.calls[0]
if len(msgs) != 1 {
t.Fatalf("消息数 = %d, want 1", len(msgs))
}
if len(msgs[0].Content) != 2 || msgs[0].Content[0].Text != "看看这张" ||
msgs[0].Content[1].Type != model.ContentTypeImage || msgs[0].Content[1].ImageURL != image {
t.Fatalf("图片回合内容变了:%+v", msgs[0].Content)
}
}
// chatImageErrorOf 取出 {error:{code,message}} 的 code 与 message。
func chatImageErrorOf(t *testing.T, body []byte) (code, message string) {
t.Helper()
env := decodeBody(t, body)
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(env["error"], &eobj); err != nil {
t.Fatalf("error envelope: %v (body=%s)", err, body)
}
return eobj.Code, eobj.Message
}