From 919501917013576b364447e50b46029b049a48a1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 17:01:52 +0800 Subject: [PATCH] fix(t244): accept headerless ERP images safely --- .../internal/platform/shunyunbao/image.go | 72 +++++- .../platform/shunyunbao/image_test.go | 228 ++++++++++++++++++ docs/current-state.md | 11 +- docs/tasks/T-244.md | 31 ++- 4 files changed, 324 insertions(+), 18 deletions(-) diff --git a/backend-api/internal/platform/shunyunbao/image.go b/backend-api/internal/platform/shunyunbao/image.go index bd288fe..2e57c46 100644 --- a/backend-api/internal/platform/shunyunbao/image.go +++ b/backend-api/internal/platform/shunyunbao/image.go @@ -1,7 +1,11 @@ package shunyunbao import ( + "bufio" + "bytes" "context" + "errors" + "io" "mime" "net/http" "net/url" @@ -15,6 +19,7 @@ import ( const ( ProductImagePath = "/api/p/file" maxProductImageBytes = 20 << 20 + productImageSniffLen = 512 ) func (manager *SessionManager) FetchProductImage( @@ -86,11 +91,17 @@ func (manager *SessionManager) FetchProductImage( domain.ErrFreightSourceUnavailable } } - mediaType, _, err := mime.ParseMediaType( + if response.ContentLength > maxProductImageBytes { + response.Body.Close() + manager.logERPBinaryResponse(request, response) + return usecase.FreightSourceImage{}, + domain.ErrFreightImageInvalid + } + mediaType, content, err := productImageContent( response.Header.Get("Content-Type"), + response.Body, ) - if err != nil || !supportedProductImageType(mediaType) || - response.ContentLength > maxProductImageBytes { + if err != nil { response.Body.Close() manager.logERPBinaryResponse(request, response) return usecase.FreightSourceImage{}, @@ -98,11 +109,64 @@ func (manager *SessionManager) FetchProductImage( } manager.logERPBinaryResponse(request, response) return usecase.FreightSourceImage{ - Content: response.Body, + Content: content, MediaType: mediaType, }, nil } +func productImageContent( + contentType string, + body io.ReadCloser, +) (string, io.ReadCloser, error) { + contentType = strings.TrimSpace(contentType) + if contentType != "" { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "", nil, err + } + mediaType = strings.ToLower(mediaType) + if supportedProductImageType(mediaType) { + return mediaType, body, nil + } + if mediaType != "application/octet-stream" { + return "", nil, domain.ErrFreightImageInvalid + } + } + + reader := bufio.NewReaderSize(body, productImageSniffLen) + prefix, err := reader.Peek(productImageSniffLen) + if err != nil && !errors.Is(err, io.EOF) { + return "", nil, err + } + if len(prefix) == 0 { + return "", nil, domain.ErrFreightImageInvalid + } + mediaType := detectProductImageType(prefix) + if !supportedProductImageType(mediaType) { + return "", nil, domain.ErrFreightImageInvalid + } + return mediaType, struct { + io.Reader + io.Closer + }{ + Reader: reader, + Closer: body, + }, nil +} + +func detectProductImageType(prefix []byte) string { + mediaType := strings.ToLower(http.DetectContentType(prefix)) + if supportedProductImageType(mediaType) { + return mediaType + } + if len(prefix) >= 12 && + bytes.Equal(prefix[:4], []byte("RIFF")) && + bytes.Equal(prefix[8:12], []byte("WEBP")) { + return "image/webp" + } + return mediaType +} + func supportedProductImageType(value string) bool { switch strings.ToLower(value) { case "image/jpeg", "image/png", "image/webp": diff --git a/backend-api/internal/platform/shunyunbao/image_test.go b/backend-api/internal/platform/shunyunbao/image_test.go index b15a820..529396f 100644 --- a/backend-api/internal/platform/shunyunbao/image_test.go +++ b/backend-api/internal/platform/shunyunbao/image_test.go @@ -1,8 +1,12 @@ package shunyunbao import ( + "bytes" "context" "errors" + "image" + "image/color" + "image/jpeg" "io" "net/http" "net/http/httptest" @@ -10,6 +14,7 @@ import ( "testing" "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/assetstore" ) func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint( @@ -56,6 +61,219 @@ func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint( } } +func TestSessionManagerFetchProductImageSniffsMissingContentType( + t *testing.T, +) { + body := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'} + manager, closeServer := imageSessionManager( + t, + func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + }, + ) + defer closeServer() + + image, err := manager.FetchProductImage(context.Background(), "190") + if err != nil { + t.Fatalf("FetchProductImage() error = %v", err) + } + content, err := io.ReadAll(image.Content) + if closeErr := image.Content.Close(); closeErr != nil { + t.Fatalf("close image content: %v", closeErr) + } + if err != nil || image.MediaType != "image/jpeg" || + !bytes.Equal(content, body) { + t.Fatalf( + "image = %q / %x / %v", + image.MediaType, + content, + err, + ) + } +} + +func TestMissingContentTypeImageCanBeNormalizedAndStored(t *testing.T) { + var encoded bytes.Buffer + source := image.NewRGBA(image.Rect(0, 0, 2, 2)) + source.Set(0, 0, color.RGBA{R: 255, A: 255}) + source.Set(1, 0, color.RGBA{G: 255, A: 255}) + source.Set(0, 1, color.RGBA{B: 255, A: 255}) + source.Set(1, 1, color.RGBA{R: 255, G: 255, A: 255}) + if err := jpeg.Encode( + &encoded, + source, + &jpeg.Options{Quality: 90}, + ); err != nil { + t.Fatalf("jpeg.Encode() error = %v", err) + } + manager, closeServer := imageSessionManager( + t, + func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(encoded.Bytes()) + }, + ) + defer closeServer() + + sourceImage, err := manager.FetchProductImage( + context.Background(), + "190", + ) + if err != nil { + t.Fatalf("FetchProductImage() error = %v", err) + } + defer sourceImage.Content.Close() + files, err := assetstore.New(t.TempDir()) + if err != nil { + t.Fatalf("assetstore.New() error = %v", err) + } + stored, err := files.Put( + context.Background(), + "00000000-0000-4000-8000-000000000190", + sourceImage.MediaType, + sourceImage.Content, + ) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + if sourceImage.MediaType != "image/jpeg" || + stored.MediaType != domain.NormalizedImageMediaType || + stored.SizeBytes <= 0 || stored.StorageKey == "" { + t.Fatalf("stored image = %+v / %q", stored, sourceImage.MediaType) + } + content, err := files.Open(context.Background(), stored.StorageKey) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer content.Close() + _, format, err := image.Decode(content) + if err != nil || format != "jpeg" { + t.Fatalf("stored format = %q, %v", format, err) + } +} + +func TestProductImageContentSniffsGenericBinaryAndPreservesStream( + t *testing.T, +) { + tests := []struct { + name string + contentType string + body []byte + wantType string + }{ + { + name: "missing JPEG", + body: []byte{0xff, 0xd8, 0xff, 0xe1, 'j', 'p', 'e', 'g'}, + wantType: "image/jpeg", + }, + { + name: "generic PNG", + contentType: "application/octet-stream", + body: []byte{ + 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 'p', 'n', 'g', + }, + wantType: "image/png", + }, + { + name: "generic WebP", + contentType: "APPLICATION/OCTET-STREAM", + body: []byte{ + 'R', 'I', 'F', 'F', 0x04, 0x00, 0x00, 0x00, + 'W', 'E', 'B', 'P', + }, + wantType: "image/webp", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &trackingImageBody{ + Reader: bytes.NewReader(test.body), + } + mediaType, content, err := productImageContent( + test.contentType, + body, + ) + if err != nil { + t.Fatalf("productImageContent() error = %v", err) + } + got, err := io.ReadAll(content) + if err != nil || mediaType != test.wantType || + !bytes.Equal(got, test.body) { + t.Fatalf( + "content = %q / %x / %v", + mediaType, + got, + err, + ) + } + if err := content.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if !body.closed { + t.Fatal("source body was not closed") + } + }) + } +} + +func TestProductImageContentRejectsUntrustedFallbacks(t *testing.T) { + tests := []struct { + name string + contentType string + body []byte + }{ + { + name: "explicit JSON overrides image bytes", + contentType: "application/json", + body: []byte{0xff, 0xd8, 0xff}, + }, + { + name: "missing JSON", + body: []byte(`{"status":false}`), + }, + { + name: "generic HTML", + contentType: "application/octet-stream", + body: []byte("error"), + }, + { + name: "generic SVG", + contentType: "application/octet-stream", + body: []byte(``), + }, + { + name: "empty body", + }, + { + name: "malformed media type", + contentType: "image/jpeg; invalid", + body: []byte{0xff, 0xd8, 0xff}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &trackingImageBody{ + Reader: bytes.NewReader(test.body), + } + _, content, err := productImageContent( + test.contentType, + body, + ) + if err == nil || content != nil { + t.Fatalf( + "productImageContent() = %#v, %v", + content, + err, + ) + } + if body.closed { + t.Fatal("helper unexpectedly closed source body") + } + }) + } +} + func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) { manager, closeServer := imageSessionManager( t, @@ -115,6 +333,16 @@ func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) { } } +type trackingImageBody struct { + *bytes.Reader + closed bool +} + +func (body *trackingImageBody) Close() error { + body.closed = true + return nil +} + func TestSessionManagerFetchProductImageRejectsUntrustedReferences( t *testing.T, ) { diff --git a/docs/current-state.md b/docs/current-state.md index 8f72be8..b502603 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-243 已完成货运删除的采购需求草稿边界修复 +- 阶段:T-244 已完成 ERP 图片缺失媒体类型兼容 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -21,7 +21,9 @@ `FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情 `created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存 `productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权 - 本地路由、货运列表和详情中显示图片、状态与采购字段。T-242/T-243 允许 ADMIN 从 + 本地路由、货运列表和详情中显示图片、状态与采购字段。T-244 对缺失 `Content-Type` + 或通用二进制的 ERP 图片使用 512 字节受控嗅探,完整解码和 20 MB 限制不变。 + T-242/T-243 允许 ADMIN 从 详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成 采购任务时分别原子拒绝,不修改 ERP、同步运行历史或增量水位,完整订单号可重新导入。 - ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、 @@ -45,7 +47,7 @@ - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 -- 后端测试:T-226 至 T-243 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` +- 后端测试:T-226 至 T-244 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、 详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准 验证也通过,均未访问真实 ERP; @@ -60,7 +62,8 @@ - 货运管理展示:列表按外部商品 ID 确定性展示第一条当前商品的 56px 代表图、标题和 总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、 图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、 - 破图、遮挡和控制台错误。 + 破图、遮挡和控制台错误。ERP 缺失图片媒体类型时,JPEG/PNG 使用标准嗅探,WebP 使用 + 严格文件签名,再由 asset store 完整验证并保存为本地归一化 JPEG。 - 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE; 删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED` 采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理 diff --git a/docs/tasks/T-244.md b/docs/tasks/T-244.md index 3244dc4..76a568a 100644 --- a/docs/tasks/T-244.md +++ b/docs/tasks/T-244.md @@ -4,7 +4,7 @@ title: 兼容 ERP 图片响应缺少媒体类型 phase: 2 deps: - T-241 -status: TODO +status: DONE created: 2026-07-29 context_ref: 24eef29 work_branch: null @@ -32,7 +32,8 @@ write_paths: - HTTP 2xx 且 `Content-Type` 为 `image/jpeg`、`image/png`、`image/webp` 时沿用现有路径。 - `Content-Type` 缺失或规范化后为 `application/octet-stream` 时,只读取正文前最多 - 512 字节并使用 Go 标准库 `http.DetectContentType` 判定真实类型。 + 512 字节;JPEG/PNG 使用 Go 标准库 `http.DetectContentType`,WebP 使用严格 + `RIFF....WEBP` 文件签名判定。 - 嗅探只接受 JPEG、PNG、WebP;空正文、JSON、HTML、SVG、未知二进制继续返回 `ErrFreightImageInvalid`。 - 已读取的前缀必须无损拼回返回流,后续本地存储继续执行完整解码、声明类型一致性、 @@ -55,14 +56,14 @@ write_paths: ## 验收要点 -- [ ] 缺少 `Content-Type` 的有效 JPEG 保存为本系统归一化 JPEG,图片状态为 `READY`。 -- [ ] `application/octet-stream` 的有效 PNG/WebP 可以进入既有解码与存储路径。 -- [ ] 嗅探不会丢失正文前缀,完整内容只读取一次并最终关闭 ERP 响应 body。 -- [ ] JSON、HTML、SVG、空正文、未知二进制和明确不支持的媒体类型仍被拒绝。 -- [ ] 已知或流式超限图片仍受 20 MB 限制,不能绕过 asset store。 -- [ ] 401/403、404、非 2xx、会话清理、固定 URL 和数字 ID 校验不回归。 -- [ ] 货运同步仍为图片 best-effort,不因单张图片失败回滚业务数据。 -- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 +- [x] 缺少 `Content-Type` 的有效 JPEG 保存为本系统归一化 JPEG,图片状态为 `READY`。 +- [x] `application/octet-stream` 的有效 PNG/WebP 可以进入既有解码与存储路径。 +- [x] 嗅探不会丢失正文前缀,完整内容只读取一次并最终关闭 ERP 响应 body。 +- [x] JSON、HTML、SVG、空正文、未知二进制和明确不支持的媒体类型仍被拒绝。 +- [x] 已知或流式超限图片仍受 20 MB 限制,不能绕过 asset store。 +- [x] 401/403、404、非 2xx、会话清理、固定 URL 和数字 ID 校验不回归。 +- [x] 货运同步仍为图片 best-effort,不因单张图片失败回滚业务数据。 +- [x] 标准 Go 测试、race、vet 和三个入口构建通过。 ## 边界 @@ -78,3 +79,13 @@ write_paths: `ERP_IMAGE_INVALID`;受控响应探测确认 HTTP 200 正文为有效 JPEG,但 `Content-Type` 和 `Content-Length` 均缺失。根因位于 source adapter 的响应头强校验, 不是 ERP 鉴权、数字图片 ID、图片不存在、本地目录或图片体积。 +- 2026-07-29:适配器仅对缺失媒体类型或 `application/octet-stream` 响应使用固定 + 512 字节 buffered peek;支持头直接沿用,明确 JSON/HTML/SVG 等类型保持 fail-closed。 + 成功流组合 buffered reader 与原 body closer,既不丢前缀,也不复制完整图片。 +- 2026-07-29:测试确认 Go 1.23 的 `http.DetectContentType` 不识别 WebP,因此增加 + 严格 `RIFF....WEBP` 前缀检测;asset store 随后仍执行完整 WebP 解码、媒体类型一致性、 + 尺寸/像素与 20 MB 校验。端到端测试已将缺失响应头的有效 JPEG 归一化保存并重新解码。 +- 2026-07-29:`go test ./...`、`go test -race ./...`、`go vet ./...` 和 API、 + authctl、migrate 三个入口构建通过。测试覆盖缺失/通用媒体类型、JPEG/PNG/WebP、 + 前缀完整性、body 关闭语义、明确非图片、嗅探非图片、已知超限、401/403、404 与非 2xx; + 自动化验证未访问真实 ERP。