fix(t244): accept headerless ERP images safely

This commit is contained in:
QiuSW
2026-07-29 17:01:52 +08:00
parent 3691e791e9
commit 9195019170
4 changed files with 324 additions and 18 deletions
@@ -1,7 +1,11 @@
package shunyunbao package shunyunbao
import ( import (
"bufio"
"bytes"
"context" "context"
"errors"
"io"
"mime" "mime"
"net/http" "net/http"
"net/url" "net/url"
@@ -15,6 +19,7 @@ import (
const ( const (
ProductImagePath = "/api/p/file" ProductImagePath = "/api/p/file"
maxProductImageBytes = 20 << 20 maxProductImageBytes = 20 << 20
productImageSniffLen = 512
) )
func (manager *SessionManager) FetchProductImage( func (manager *SessionManager) FetchProductImage(
@@ -86,11 +91,17 @@ func (manager *SessionManager) FetchProductImage(
domain.ErrFreightSourceUnavailable 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.Header.Get("Content-Type"),
response.Body,
) )
if err != nil || !supportedProductImageType(mediaType) || if err != nil {
response.ContentLength > maxProductImageBytes {
response.Body.Close() response.Body.Close()
manager.logERPBinaryResponse(request, response) manager.logERPBinaryResponse(request, response)
return usecase.FreightSourceImage{}, return usecase.FreightSourceImage{},
@@ -98,11 +109,64 @@ func (manager *SessionManager) FetchProductImage(
} }
manager.logERPBinaryResponse(request, response) manager.logERPBinaryResponse(request, response)
return usecase.FreightSourceImage{ return usecase.FreightSourceImage{
Content: response.Body, Content: content,
MediaType: mediaType, MediaType: mediaType,
}, nil }, 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 { func supportedProductImageType(value string) bool {
switch strings.ToLower(value) { switch strings.ToLower(value) {
case "image/jpeg", "image/png", "image/webp": case "image/jpeg", "image/png", "image/webp":
@@ -1,8 +1,12 @@
package shunyunbao package shunyunbao
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"image"
"image/color"
"image/jpeg"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,6 +14,7 @@ import (
"testing" "testing"
"cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/assetstore"
) )
func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint( 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("<!doctype html><title>error</title>"),
},
{
name: "generic SVG",
contentType: "application/octet-stream",
body: []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`),
},
{
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) { func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
manager, closeServer := imageSessionManager( manager, closeServer := imageSessionManager(
t, 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( func TestSessionManagerFetchProductImageRejectsUntrustedReferences(
t *testing.T, t *testing.T,
) { ) {
+7 -4
View File
@@ -5,7 +5,7 @@
## 当前快照 ## 当前快照
- 日期:2026-07-29 - 日期:2026-07-29
- 阶段:T-243 已完成货运删除的采购需求草稿边界修复 - 阶段:T-244 已完成 ERP 图片缺失媒体类型兼容
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史 均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码
@@ -21,7 +21,9 @@
`FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情 `FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情
`created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存 `created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存
`productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权 `productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权
本地路由、货运列表和详情中显示图片、状态与采购字段。T-242/T-243 允许 ADMIN 从 本地路由、货运列表和详情中显示图片、状态与采购字段。T-244 对缺失 `Content-Type`
或通用二进制的 ERP 图片使用 512 字节受控嗅探,完整解码和 20 MB 限制不变。
T-242/T-243 允许 ADMIN 从
详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成 详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成
采购任务时分别原子拒绝,不修改 ERP、同步运行历史或增量水位,完整订单号可重新导入。 采购任务时分别原子拒绝,不修改 ERP、同步运行历史或增量水位,完整订单号可重新导入。
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、 - ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
@@ -45,7 +47,7 @@
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 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 会话预检、完整单号、日期分页去重、 和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准 详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
验证也通过,均未访问真实 ERP; 验证也通过,均未访问真实 ERP;
@@ -60,7 +62,8 @@
- 货运管理展示:列表按外部商品 ID 确定性展示第一条当前商品的 56px 代表图、标题和 - 货运管理展示:列表按外部商品 ID 确定性展示第一条当前商品的 56px 代表图、标题和
总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、 总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、 图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
破图、遮挡和控制台错误。 破图、遮挡和控制台错误。ERP 缺失图片媒体类型时,JPEG/PNG 使用标准嗅探,WebP 使用
严格文件签名,再由 asset store 完整验证并保存为本地归一化 JPEG。
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE; - 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED` 删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED`
采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理 采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理
+21 -10
View File
@@ -4,7 +4,7 @@ title: 兼容 ERP 图片响应缺少媒体类型
phase: 2 phase: 2
deps: deps:
- T-241 - T-241
status: TODO status: DONE
created: 2026-07-29 created: 2026-07-29
context_ref: 24eef29 context_ref: 24eef29
work_branch: null work_branch: null
@@ -32,7 +32,8 @@ write_paths:
- HTTP 2xx 且 `Content-Type` 为 `image/jpeg`、`image/png`、`image/webp` 时沿用现有路径。 - HTTP 2xx 且 `Content-Type` 为 `image/jpeg`、`image/png`、`image/webp` 时沿用现有路径。
- `Content-Type` 缺失或规范化后为 `application/octet-stream` 时,只读取正文前最多 - `Content-Type` 缺失或规范化后为 `application/octet-stream` 时,只读取正文前最多
512 字节并使用 Go 标准库 `http.DetectContentType` 判定真实类型。 512 字节;JPEG/PNG 使用 Go 标准库 `http.DetectContentType`,WebP 使用严格
`RIFF....WEBP` 文件签名判定。
- 嗅探只接受 JPEG、PNG、WebP;空正文、JSON、HTML、SVG、未知二进制继续返回 - 嗅探只接受 JPEG、PNG、WebP;空正文、JSON、HTML、SVG、未知二进制继续返回
`ErrFreightImageInvalid`。 `ErrFreightImageInvalid`。
- 已读取的前缀必须无损拼回返回流,后续本地存储继续执行完整解码、声明类型一致性、 - 已读取的前缀必须无损拼回返回流,后续本地存储继续执行完整解码、声明类型一致性、
@@ -55,14 +56,14 @@ write_paths:
## 验收要点 ## 验收要点
- [ ] 缺少 `Content-Type` 的有效 JPEG 保存为本系统归一化 JPEG,图片状态为 `READY`。 - [x] 缺少 `Content-Type` 的有效 JPEG 保存为本系统归一化 JPEG,图片状态为 `READY`。
- [ ] `application/octet-stream` 的有效 PNG/WebP 可以进入既有解码与存储路径。 - [x] `application/octet-stream` 的有效 PNG/WebP 可以进入既有解码与存储路径。
- [ ] 嗅探不会丢失正文前缀,完整内容只读取一次并最终关闭 ERP 响应 body。 - [x] 嗅探不会丢失正文前缀,完整内容只读取一次并最终关闭 ERP 响应 body。
- [ ] JSON、HTML、SVG、空正文、未知二进制和明确不支持的媒体类型仍被拒绝。 - [x] JSON、HTML、SVG、空正文、未知二进制和明确不支持的媒体类型仍被拒绝。
- [ ] 已知或流式超限图片仍受 20 MB 限制,不能绕过 asset store。 - [x] 已知或流式超限图片仍受 20 MB 限制,不能绕过 asset store。
- [ ] 401/403、404、非 2xx、会话清理、固定 URL 和数字 ID 校验不回归。 - [x] 401/403、404、非 2xx、会话清理、固定 URL 和数字 ID 校验不回归。
- [ ] 货运同步仍为图片 best-effort,不因单张图片失败回滚业务数据。 - [x] 货运同步仍为图片 best-effort,不因单张图片失败回滚业务数据。
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 - [x] 标准 Go 测试、race、vet 和三个入口构建通过。
## 边界 ## 边界
@@ -78,3 +79,13 @@ write_paths:
`ERP_IMAGE_INVALID`;受控响应探测确认 HTTP 200 正文为有效 JPEG,但 `ERP_IMAGE_INVALID`;受控响应探测确认 HTTP 200 正文为有效 JPEG,但
`Content-Type` 和 `Content-Length` 均缺失。根因位于 source adapter 的响应头强校验, `Content-Type` 和 `Content-Length` 均缺失。根因位于 source adapter 的响应头强校验,
不是 ERP 鉴权、数字图片 ID、图片不存在、本地目录或图片体积。 不是 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。