diff --git a/internal/channel/mediafetch/inline.go b/internal/channel/mediafetch/inline.go index 248da68..e5be659 100644 --- a/internal/channel/mediafetch/inline.go +++ b/internal/channel/mediafetch/inline.go @@ -7,6 +7,8 @@ package mediafetch // // 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回 // 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。 +// 图片附件同理由 `inline_image.go` 承载(切片 IM-IMG):独立的图片白名单 + 图片口径的 +// 上限(5 MiB)+ 字节嗅探裁决,与上面两份名单互不污染。 import ( "context" @@ -33,16 +35,18 @@ type Fetcher interface { Fetch(ctx context.Context, media adapter.Media) ([]byte, error) } -// Inline 把消息里**文件类**与**语音类**媒体在受支持格式与限额内的字节内联进 -// `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一)拿到字节。它是四个适配器 -// 共享的唯一实现: +// Inline 把消息里**文件类**、**图片类**与**语音类**媒体在受支持格式与限额内的字节 +// 内联进 `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一 / 图片内联成 +// data: URI)拿到字节。它是四个适配器共享的唯一实现: // -// - 文件类走 `inlineFiles`(图片仍走各自既有链路:图片进视觉链路,design/24 §4.2 末); +// - 文件类走 `inlineFiles`(`documents.ExtAllowed` 白名单,20 MiB); +// - 图片类走 `inlineImages`(切片 IM-IMG,`inline_image.go`):它用一份**独立的 +// 图片白名单**与**图片口径的上限**(5 MiB),并以字节嗅探为最终裁决; // - 语音类走 `inlineVoices`(切片 IM-ASR,design/06 §1.1、design/08 §4.1):它用一份 // **独立的音频白名单**,绝不与 `documents.ExtAllowed` 混淆——后者是 Web 文档上传的 // 口径,往里加音频格式会污染文档白名单; -// - 两条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记 -// 来源不记完整 URL),因此四个适配器零改动即可拿到语音字节。 +// - 三条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记 +// 来源不记完整 URL),因此四个适配器零改动即可拿到图片与语音字节。 // // 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不 // 让一条读不到的附件拖垮整个回合(失败不炸)。 @@ -51,9 +55,32 @@ func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, return } inlineFiles(ctx, log, msg, f) + inlineImages(ctx, log, msg, f) inlineVoices(ctx, log, msg, f) } +// MaxBytesFor 返回一条媒体取回时该用的**字节上限**,按类型分档:图片 5 MiB +// (`MaxImageBytes`)、语音 5 MiB(`MaxVoiceBytes`)、其余(文档类附件)20 MiB +// (`documents.DefaultMaxBytes`)。 +// +// 它给各适配器的 Fetcher 用(`mediafetch.Request.MaxBytes`):取回发生在 Fetcher 里, +// 而「同一份取回口径服务三条链路」意味着上限不能由取回层替调用方猜 —— 这一份映射是 +// 唯一出处,渠道侧只需 `MaxBytes: mediafetch.MaxBytesFor(m)`。 +// +// `Kind` 为空的媒体(只给了消息级内容类型的形状)无法在这一层判出类型,回落到文档 +// 上限;此时图片仍由 `inlineImages` 的**取回后**复核(> MaxImageBytes 一律不内联) +// 兜住,因此不会出现「巨图进了上下文」。 +func MaxBytesFor(m adapter.Media) int64 { + switch m.Kind { + case adapter.MediaImage: + return MaxImageBytes + case adapter.MediaVoice: + return MaxVoiceBytes + default: + return documents.DefaultMaxBytes + } +} + // inlineFiles 内联**文件类**媒体: // // - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单): diff --git a/internal/channel/mediafetch/inline_image.go b/internal/channel/mediafetch/inline_image.go new file mode 100644 index 0000000..055041b --- /dev/null +++ b/internal/channel/mediafetch/inline_image.go @@ -0,0 +1,241 @@ +package mediafetch + +// 本文件承载**入站图片附件的字节内联**(切片 IM-IMG):四个适配器共用同一份 +// `Inline`,因此图片只需在这里补一条分支,渠道侧**零改动**即可把图片字节送进 +// `Media.Data` —— 桥接器再把它内联成 data: URI 挂上既有视觉链路(design/24 §4.2 末)。 +// +// 五条口径(每条都是本切片刻意钉死的): +// +// 1. **图片放行集合独立于文档白名单与音频白名单**:`documents.ExtAllowed` 是 Web +// 文档上传的口径(纯文本类 + PDF),`audioExtAllowed` 是语音转写的口径。把图片 +// 扩展名塞进任何一份都会悄悄扩大另一个面(Web 上传能收 .png、或语音白名单里混进 +// 图片),因此这里是**第三份、互不引用**的集合。 +// +// 2. **声明只用于预筛,字节嗅探是唯一裁决权**:平台的扩展名/MIME 命中图片集合就值得 +// 取字节(省掉明显不该取的那些),但**取回后必须重新按真实字节判**——改名成 `.png` +// 的 HTML 错误页、或顶着 `image/png` 下发的文本,一律**不内联**(项目既有口径: +// 嗅探是唯一裁决权,与 vision 的 `imageMIMEFor`、documents 的 `ContentAllowed` +// 同一纪律)。绝不静默当图片塞进上下文。 +// +// ⚠️ 与 vision 的 `imageMIMEFor` 有一处**刻意不同**:那边在「字节判不定」时会采信 +// 来源声明的 `image/*`(为了不无谓拒绝 avif 一类嗅探不出的格式);这里**不采信** +// 声明兜底 —— 入站图片的字节已经在我们手上,判不定就诚实说「这不是白名单光栅图」, +// 绝不让一份判不定的载荷顶着 `image/png` 进上下文。 +// +// 3. **平台没给名字也没给类型时以「平台类型」为声明**:微信 iLink 的 image_item 只有 +// `media`(引用与密钥),**既没有 file_name 也没有 mime**,只认扩展名会把它整条 +// 挡掉(与 telegram 语音只有 `file_id` + `audio/ogg` 是同一类形状)。此时 +// `IsImageMedia` 判定的那个类型标记**同样来自平台载荷**,与资产地址处于同一信任域, +// 因此它是声明;字节嗅探仍是最终裁决(判不定就不内联)。 +// +// 4. **上限按图片口径**:图片走 `MaxImageBytes`(5 MiB,与 `CHAT_IMAGE_MAX_BYTES` +// 默认值、vision 解码层硬顶同值),**绝不用文档的 20 MiB** —— 一张图能被内联多大 +// 只能有一个答案。 +// +// 5. **失败不炸、也不静默**:取不到字节/超限/字节不是图片时,该媒体保持无字节,记一条 +// 带来源的 Warn(`host` 只记来源,**绝不记 URL 原串**:平台资产地址可能带签名凭据), +// 由下游照实说「读不到」。 + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision" +) + +// MaxImageBytes 是**单张入站图片**内联的字节上限。取值直接复用 vision 解码层的 +// 硬顶(`vision.MaxInlineImageBytes`,与 `CHAT_IMAGE_MAX_BYTES` 的默认值 5242880 +// 逐字节相同)——上限只在这里有一个出处:入站取回放行的字节,解码层一定接得住; +// 若各自写一个数,就会出现「页面上传 5 MiB 能过、IM 收进来的 5.1 MiB 被解码层拒」 +// 这种自相矛盾的行为。 +const MaxImageBytes int64 = vision.MaxInlineImageBytes + +// imageSniffPrefix 是字节嗅探读取的前缀长度:`http.DetectContentType` 只看前 512 字节。 +const imageSniffPrefix = 512 + +// IsImageMedia 报告一条媒体是不是**图片类**。判定与文件类/语音类同形 +// (`MediaImage`,或「只给了内容类型、附件就在 Media 里」的 `ContentImage`)。 +func IsImageMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool { + if m.Kind == adapter.MediaImage { + return true + } + return m.Kind == "" && msg.ContentType == adapter.ContentImage +} + +// ImageMIMEOf 是**图片字节的唯一裁决**:按真实字节判定它是不是白名单内的光栅图, +// 是则返回归一后的 MIME。它同时给两处用——本包的字节内联(不信任声明),以及桥接器 +// 构造 data: URI 时(`vision.DataURI(mime, data)`)。两处共用它,就不可能出现 +// 「取回层说这是图片、构造 URI 的那一段却按别的类型拼前缀」这种漂移。 +// +// 白名单:png / jpeg / gif / webp / bmp / tiff。**SVG 不在内**:它是可执行内容 +// (与 vision / 网关上传同一口径),且 `DetectContentType` 只会把它判成文本/XML。 +func ImageMIMEOf(data []byte) (string, bool) { + if len(data) == 0 { + return "", false + } + if tiffHeader(data) { + return "image/tiff", true + } + return knownImageMIME(normalizedMIME(http.DetectContentType(prefixBytes(data, imageSniffPrefix)))) +} + +// tiffHeader 按 TIFF 的两种字节序头(`II*\0` 小端 / `MM\0*` 大端)判定是不是 TIFF。 +// +// ⚠️ **为什么这里要自己认签名**:Go 的 `http.DetectContentType` 的表里**没有** TIFF +// (png/jpeg/gif/webp/bmp 都有,TIFF 没有),只看它会把一份 TIFF 判成 +// `application/octet-stream` ⇒ 白名单里的 `.tiff` 会被字节永久否决(声明永远过不了 +// 字节这一关,等于白写在白名单里)。补齐这 4 个字节的签名让「嗅探是唯一裁决权」对 +// TIFF 也成立;判成 TIFF 的载荷仍受 `MaxImageBytes` 与来源白名单约束。 +func tiffHeader(data []byte) bool { + if len(data) < 4 { + return false + } + le := data[0] == 'I' && data[1] == 'I' && data[2] == 0x2a && data[3] == 0x00 + be := data[0] == 'M' && data[1] == 'M' && data[2] == 0x00 && data[3] == 0x2a + return le || be +} + +// imageExtAllowed 报告扩展名(含点、小写)是否在**图片**放行集合内。 +// +// ⚠️ 这一份**独立**于 `documents.ExtAllowed` 与 `audioExtAllowed`:往任一份里加图片 +// 格式都会污染另一条链路(Web 文档上传 / 语音转写)。 +func imageExtAllowed(ext string) bool { + switch strings.ToLower(ext) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff": + return true + default: + return false + } +} + +// knownImageMIME 把 MIME 归一到图片白名单内的类型。白名单只有一处(这里), +// 扩展名预筛、声明 MIME 预筛与字节嗅探三条路径都经它判定,因此三者不可能给出 +// 互相矛盾的答案。SVG(`image/svg+xml`)**不在内**:可执行内容绝不进上下文。 +func knownImageMIME(mime string) (string, bool) { + switch mime { + case "image/png": + return "image/png", true + case "image/jpeg", "image/jpg": + // image/jpg 不是标准写法,但确有其事(vision 的 knownRasterMIME 同口径)。 + return "image/jpeg", true + case "image/gif": + return "image/gif", true + case "image/webp": + return "image/webp", true + case "image/bmp": + return "image/bmp", true + case "image/tiff": + return "image/tiff", true + default: + return "", false + } +} + +// imageFetchAllowed 是**预筛**:报告一条图片媒体值不值得取字节。它只是省一次白跑的 +// 下载(字节嗅探才是最终裁决),但方向必须是 deny-by-default 的: +// +// - 平台给了扩展名(文件名或资产地址末段)⇒ 以它为准:不在图片集合里就**不取**。 +// 伪造 `payload.html` / `x.exe` 的图片 item 因此连请求都不会发出去; +// - 没有扩展名但声明了 MIME ⇒ 以声明为准:不是 `image/*`(例如 `text/html`)就不取; +// - 两者都没有 ⇒ 以**平台类型**为声明(口径 3):值得一取,取回后由字节裁决。 +func imageFetchAllowed(m adapter.Media) bool { + if ext := documents.ExtOf(displayName(m)); ext != "" { + return imageExtAllowed(ext) + } + if declared := normalizedMIME(m.MIMEType); declared != "" { + _, ok := knownImageMIME(declared) + return ok + } + return true +} + +// normalizedMIME 归一一个 MIME 串:截掉 `;charset=…` 参数、去空白、转小写。 +func normalizedMIME(mime string) string { + base, _, _ := strings.Cut(mime, ";") + return strings.ToLower(strings.TrimSpace(base)) +} + +// prefixBytes 取字节前缀(不足则原样)。嗅探只看前缀,因此绝不为了判定复制整份字节。 +func prefixBytes(data []byte, n int) []byte { + if len(data) > n { + return data[:n] + } + return data +} + +// inlineImages 内联**图片类**媒体:预筛声明 → 取回(来源白名单 + 逐跳校验 + 边读边限 +// + 超时)→ **字节嗅探裁决** → 写回 `Media.Data`。 +// +// - 只内联前 `documents.MaxPerMessage` 张(与文件/语音各自计一条额度:同一条消息里的 +// 文件、图片、语音互不吃掉对方的额度),超出者不下载并记一条 Warn; +// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载; +// - 与语音不同,**图片不因「消息已有正文」而跳过**:配文的图仍然是一份内容 +// (语音有平台转写时取字节是白花钱,图片没有替身); +// - 任何一步失败都只留空字节 + 一条带来源的日志,**消息照收、回合继续**。 +func inlineImages(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) { + seen := 0 + for i := range msg.Media { + m := &msg.Media[i] + if !IsImageMedia(msg, *m) { + continue + } + if seen >= documents.MaxPerMessage { + log.Warn("inbound image attachments beyond per-message limit not inlined", + "platform", msg.Platform, "media", len(msg.Media), "limit", documents.MaxPerMessage) + return + } + seen++ + if len(m.Data) > 0 { + continue + } + if !imageFetchAllowed(*m) { + // 预筛未过:**一个字节都不发出去**。这不是失败,是「这个类型本来就不读」。 + log.Debug("inbound image not inlined: declared type is not an allowed image", + "platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, "size", m.Size) + continue + } + if m.Size > MaxImageBytes { + log.Warn("inbound image not inlined: declared size over limit", + "platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, + "size", m.Size, "limit", MaxImageBytes) + continue + } + data, err := f.Fetch(ctx, *m) + if err != nil { + // 只记来源(HostOf),绝不记 URL 原串:平台资产地址可能带签名凭据。 + log.Warn("inbound image inline failed", + "platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, + "size", m.Size, "host", HostOf(m.URL), "err", err) + continue + } + if len(data) == 0 { + log.Warn("inbound image inline returned no bytes", + "platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, + "host", HostOf(m.URL)) + continue + } + // 纵深:取回器可能没按图片口径设上限(各渠道 Fetcher 不都读过 MaxBytesFor), + // 因此这里再判一次——超大的一律不内联,绝不把一份巨图塞进上下文。 + if int64(len(data)) > MaxImageBytes { + log.Warn("inbound image not inlined: fetched size over limit", + "platform", msg.Platform, "name", displayName(*m), "size", len(data), "limit", MaxImageBytes) + continue + } + mime, ok := ImageMIMEOf(data) + if !ok { + // 声明说它是图片、字节说不是(HTML 错误页 / 二进制垃圾 / 判不定): + // **不内联**并如实记一条。绝不静默当图片送进上下文。 + log.Warn("inbound image not inlined: bytes are not a whitelisted raster image", + "platform", msg.Platform, "name", displayName(*m), "declared_mime", m.MIMEType, + "size", len(data), "host", HostOf(m.URL)) + continue + } + m.Data = data + log.Debug("inbound image inlined", + "platform", msg.Platform, "mime", mime, "size", len(data)) + } +} diff --git a/internal/channel/mediafetch/inline_image_test.go b/internal/channel/mediafetch/inline_image_test.go new file mode 100644 index 0000000..f81233e --- /dev/null +++ b/internal/channel/mediafetch/inline_image_test.go @@ -0,0 +1,485 @@ +package mediafetch + +// 本文件锁定**入站图片字节内联**的口径(切片 IM-IMG): +// - 正向:白名单声明的图片、以及「平台类型即声明」的无名无 MIME 图片(微信 iLink 的形状) +// 都会被取回并写进 `Media.Data`(①); +// - 反证:非白名单声明/非图片字节 ⇒ **0 次取回**;顶着图片声明的非图片字节取回后 +// **绝不内联**(②); +// - 超限:声明超限不取回、取回的字节超限不内联,两条都留一条诚实日志(③); +// - 失败/超时:不炸、不静默(日志带来源,**绝不记 URL 原串**)(④); +// - **图片白名单绝不污染文档白名单与音频白名单**(⑤)。 + +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" +) + +// 各格式的**最小字节样本**:`http.DetectContentType` 只认前缀签名,因此这些就是 +// 「真图片」在该函数眼中的全部依据(TIFF 由本包自己的 tiffHeader 补签名)。 +var ( + pngSample = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body") + jpegSample = []byte("\xff\xd8\xff\xe0" + "fake-jpeg-body") + gifSample = []byte("GIF89a" + "fake-gif-body") + webpSample = []byte("RIFF\x00\x00\x00\x00WEBPVP8 " + "fake-webp-body") + bmpSample = []byte("BM\x00\x00\x00\x00" + "fake-bmp-body") + tiffSample = []byte("II\x2a\x00\x08\x00\x00\x00" + "fake-tiff-body") + tiffBE = []byte("MM\x00\x2a\x00\x00\x00\x08" + "fake-tiff-body") + htmlSample = []byte("
PROXY error page") + textSample = []byte("这不是图片,只是一段文本。") +) + +// imageMessage 构造一条「只有一张图片」的入站消息(Kind 明确,与 QQ/kook 的形状一致)。 +func imageMessage(m adapter.Media) *adapter.UnifiedMessage { + return &adapter.UnifiedMessage{ + Platform: "test", + ContentType: adapter.ContentImage, + Media: []adapter.Media{m}, + } +} + +// logBuffer 返回一个把日志写进内存的带来源日志器:用例据此断言「失败不静默」。 +func logBuffer(t *testing.T) (*slog.Logger, *bytes.Buffer) { + t.Helper() + var buf bytes.Buffer + return logger.WithSource(logger.New(logger.Options{Level: "debug", Out: &buf}), + "internal/channel/mediafetch"), &buf +} + +// TestInlineImageFetchesWhitelistedImage 是正向用例:扩展名与字节都是白名单图片 ⇒ 取回一次 +// 并写进 Data(并且把**嗅探所得**的 MIME 记进日志,不是平台声明的那一个)。 +func TestInlineImageFetchesWhitelistedImage(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png", + }) + + Inline(context.Background(), testLogger(t), msg, f) + + if f.calls() != 1 { + t.Fatalf("取回次数 = %d, want 1", f.calls()) + } + if !bytes.Equal(msg.Media[0].Data, pngSample) { + t.Fatalf("Data = %q, want 取回的图片字节", msg.Media[0].Data) + } +} + +// TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME 是**微信 iLink 的形状**: +// image_item 既没有 file_name 也没有 mime,只有平台类型与 CDN 引用 —— 这正是 +// 「只认扩展名就会把微信图片整条挡掉」的那个缺口。 +func TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + data []byte + mime string + }{ + {"gif", gifSample, "image/gif"}, + {"jpeg", jpegSample, "image/jpeg"}, + {"webp", webpSample, "image/webp"}, + {"bmp", bmpSample, "image/bmp"}, + {"tiff 小端", tiffSample, "image/tiff"}, + {"tiff 大端", tiffBE, "image/tiff"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: tc.data} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, + // 无 Name、无 MIMEType:引用形如 CDN 的相对下载引用(归一后是绝对地址)。 + URL: "https://cdn.example.com/c2c/download?encrypted_query_param=SECRET-VALUE", + }) + + Inline(context.Background(), testLogger(t), msg, f) + + if f.calls() != 1 { + t.Fatalf("取回次数 = %d, want 1(平台类型即声明)", f.calls()) + } + if !bytes.Equal(msg.Media[0].Data, tc.data) { + t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data) + } + if got, ok := ImageMIMEOf(msg.Media[0].Data); !ok || got != tc.mime { + t.Fatalf("ImageMIMEOf = %q/%v, want %s/true", got, ok, tc.mime) + } + }) + } +} + +// TestInlineImageWithCaptionIsStillInlined 钉住与语音**刻意不同**的一条:消息带正文时图片 +// 仍要取(配文的图同样是一份内容),绝不能照语音那条「已有文字就跳过」的口径走。 +func TestInlineImageWithCaptionIsStillInlined(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", + }) + msg.Content = "看看这张图 [图片]" + + Inline(context.Background(), testLogger(t), msg, f) + + if f.calls() != 1 || len(msg.Media[0].Data) == 0 { + t.Fatalf("带正文的图片没有被内联:calls=%d data=%d 字节", f.calls(), len(msg.Media[0].Data)) + } +} + +// TestInlineImageSkipsNonImageDeclarationWithoutFetch 是**反证**(②):声明不指向白名单 +// 图片时**一个字节都不发出去**(取回次数 0,Data 为空)。 +func TestInlineImageSkipsNonImageDeclarationWithoutFetch(t *testing.T) { + t.Parallel() + cases := []struct { + name string + url string + m adapter.Media + }{ + {"扩展名是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "page.html"}}, + {"扩展名是 exe", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "payload.exe"}}, + {"扩展名是 zip", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "bundle.zip"}}, + {"地址末段是 zip", "https://cdn.example.com/bundle.zip", adapter.Media{Kind: adapter.MediaImage}}, + {"声明 MIME 是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "text/html"}}, + {"声明 MIME 是 svg", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "image/svg+xml"}}, + {"声明 MIME 是 pdf", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "application/pdf"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + m := tc.m + m.URL = tc.url + msg := imageMessage(m) + + Inline(context.Background(), testLogger(t), msg, f) + + if f.calls() != 0 { + t.Fatalf("非图片声明发起了 %d 次取回,want 0(反证)", f.calls()) + } + if len(msg.Media[0].Data) != 0 { + t.Fatal("非图片声明被内联了") + } + }) + } +} + +// TestInlineImageSniffVetoesMislabeledBytes 钉住「字节嗅探是唯一裁决权」(②的后半): +// 声明是 `.png`/`image/png`,字节却是 HTML ⇒ 取回发生过(必须看字节才知道),但**绝不内联**, +// 并且留一条诚实日志。去掉嗅探(只信声明)这条用例就会红。 +func TestInlineImageSniffVetoesMislabeledBytes(t *testing.T) { + t.Parallel() + cases := []struct { + name string + data []byte + want string + }{ + {"HTML 错误页", htmlSample, "not a whitelisted raster image"}, + {"纯文本", textSample, "not a whitelisted raster image"}, + {"空响应体", nil, "returned no bytes"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: tc.data} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png", + }) + log, buf := logBuffer(t) + + Inline(context.Background(), log, msg, f) + + if len(msg.Media[0].Data) != 0 { + t.Fatalf("非图片字节(len=%d)被内联进了上下文", len(msg.Media[0].Data)) + } + if !strings.Contains(buf.String(), tc.want) { + t.Fatalf("字节被否决时没有留下诚实日志(want %q):%q", tc.want, buf.String()) + } + }) + } +} + +// TestInlineImageDeclaredOversizeNotFetched 钉住声明超限不取回(③前半)。 +func TestInlineImageDeclaredOversizeNotFetched(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/big.png", Name: "big.png", + Size: MaxImageBytes + 1, + }) + log, buf := logBuffer(t) + + Inline(context.Background(), log, msg, f) + + if f.calls() != 0 { + t.Fatalf("声明超限的图片仍被取回 %d 次,want 0", f.calls()) + } + if !strings.Contains(buf.String(), "declared size over limit") { + t.Fatalf("声明超限没有留下日志:%q", buf.String()) + } +} + +// TestInlineImageFetchedOversizeNotInlined 钉住「取回的字节超限一律不内联」(③后半,纵深): +// 渠道 Fetcher 没按图片口径设上限时(各渠道尚未全部改用 MaxBytesFor),取回层这一道 +// 仍然拦得住,绝不会把一份巨图塞进上下文。 +func TestInlineImageFetchedOversizeNotInlined(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: append(append([]byte(nil), pngSample...), make([]byte, MaxImageBytes)...)} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/huge.png", Name: "huge.png", + }) + log, buf := logBuffer(t) + + Inline(context.Background(), log, msg, f) + + if len(msg.Media[0].Data) != 0 { + t.Fatal("超限的图片字节被内联了") + } + if !strings.Contains(buf.String(), "fetched size over limit") { + t.Fatalf("超限没有留下日志:%q", buf.String()) + } +} + +// TestInlineImageSkipsExistingBytesAndCapsPerMessage 钉住两条省流与限额:已有字节不重复 +// 下载;同一条消息里最多内联 `documents.MaxPerMessage` 张。 +func TestInlineImageSkipsExistingBytesAndCapsPerMessage(t *testing.T) { + t.Parallel() + t.Run("已有字节", func(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", Data: []byte("local"), + }) + + Inline(context.Background(), testLogger(t), msg, f) + + if f.calls() != 0 || string(msg.Media[0].Data) != "local" { + t.Fatalf("已有字节的图片被重复下载或覆盖:calls=%d data=%q", f.calls(), msg.Media[0].Data) + } + }) + t.Run("条数上限", func(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/0.png", Name: "0.png", + }) + for i := 1; i <= documents.MaxPerMessage+2; i++ { + msg.Media = append(msg.Media, adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/x.png", Name: "x.png", + }) + } + + Inline(context.Background(), testLogger(t), msg, f) + + if f.calls() != documents.MaxPerMessage { + t.Fatalf("取回次数 = %d, want %d", f.calls(), documents.MaxPerMessage) + } + }) +} + +// TestInlineImageQuotaIsSeparateFromFilesAndVoices 钉住「三条链路各自计额度」:同一条消息里 +// 的文件、图片、语音互不吃掉对方的条数额度。 +func TestInlineImageQuotaIsSeparateFromFilesAndVoices(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := &adapter.UnifiedMessage{Platform: "test"} + for i := 0; i < documents.MaxPerMessage; i++ { + msg.Media = append(msg.Media, + adapter.Media{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"}) + } + msg.Media = append(msg.Media, + adapter.Media{Kind: adapter.MediaImage, URL: "https://cdn.example.com/p.png", Name: "p.png"}) + msg.Media = append(msg.Media, + adapter.Media{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.ogg", Name: "v.ogg"}) + + Inline(context.Background(), testLogger(t), msg, f) + + // 5 个文件 + 1 张图 + 1 条语音:三条链路各自在额度内,全部取回。 + if f.calls() != documents.MaxPerMessage+2 { + t.Fatalf("取回次数 = %d, want %d(三条链路各自的额度不许互相吃掉)", + f.calls(), documents.MaxPerMessage+2) + } +} + +// TestInlineImageFailureIsHonestNotSilent 钉住失败/超时路径(④):不炸、消息其余字段不动、 +// 留一条**带来源**的 Warn;并且日志里**只有来源主机**,绝不出现完整 URL(可能带签名凭据)。 +func TestInlineImageFailureIsHonestNotSilent(t *testing.T) { + t.Parallel() + const secret = "encrypted_query_param=SUPER_SECRET_VALUE" + cases := []struct { + name string + err error + }{ + {"平台返回非成功状态", ErrBadStatus}, + {"超时", context.DeadlineExceeded}, + {"来源不在白名单", ErrOriginNotAllowed}, + {"超过体积上限", ErrTooLarge}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := &countingFetcher{err: tc.err} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/c2c/download?" + secret, Name: "a.png", + }) + msg.Content = "[图片]" + log, buf := logBuffer(t) + + Inline(context.Background(), log, msg, f) + + if len(msg.Media[0].Data) != 0 { + t.Fatal("失败时仍写入了字节") + } + if msg.Content != "[图片]" { + t.Fatalf("失败路径改动了正文:%q(诚实提示由桥接器负责)", msg.Content) + } + out := buf.String() + if !strings.Contains(out, "inbound image inline failed") { + t.Fatalf("失败路径静默了:%q", out) + } + if !strings.Contains(out, "source=internal/channel/mediafetch") { + t.Fatalf("日志没有带来源(AGENTS §10):%q", out) + } + if !strings.Contains(out, "host=https://cdn.example.com") { + t.Fatalf("日志里没有来源主机,定位不到是哪台平台主机:%q", out) + } + if strings.Contains(out, secret) || strings.Contains(out, "/c2c/download") { + t.Fatalf("日志泄露了完整 URL 或签名参数:%q", out) + } + }) + } +} + +// TestInlineImageEmptyBytesIsHonest 钉住「取到 0 字节」这条分支同样不静默。 +func TestInlineImageEmptyBytesIsHonest(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: nil} + msg := imageMessage(adapter.Media{ + Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", + }) + log, buf := logBuffer(t) + + Inline(context.Background(), log, msg, f) + + if !strings.Contains(buf.String(), "returned no bytes") { + t.Fatalf("0 字节路径静默了:%q", buf.String()) + } +} + +// TestImageWhitelistDoesNotPolluteOtherWhitelists 是本切片的硬红线(⑤):图片放行集合是 +// **独立**的一份 —— 既不许污染 `documents.ExtAllowed`(Web 文档上传口径),也不许与音频 +// 白名单互相渗透。 +func TestImageWhitelistDoesNotPolluteOtherWhitelists(t *testing.T) { + t.Parallel() + imageExts := []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"} + for _, ext := range imageExts { + if !imageExtAllowed(ext) { + t.Fatalf("imageExtAllowed(%q) = false, want true", ext) + } + if documents.ExtAllowed(ext) { + t.Fatalf("documents.ExtAllowed(%q) = true —— 图片格式污染了文档白名单", ext) + } + if audioExtAllowed(ext) { + t.Fatalf("audioExtAllowed(%q) = true —— 图片格式污染了音频白名单", ext) + } + } + // 反方向:文档/音频/可执行格式都不是图片。 + for _, ext := range []string{".txt", ".pdf", ".mp3", ".ogg", ".wav", ".exe", ".svg", ".html", ""} { + if imageExtAllowed(ext) { + t.Fatalf("imageExtAllowed(%q) = true, want false", ext) + } + } + // 三份集合的规模与成员都不互相包含(防止「一条路复用两份名单」)。 + if documents.ExtAllowed(".png") || documents.ExtAllowed(".mp3") || documents.ExtAllowed(".amr") { + t.Fatal("文档白名单里出现了图片或音频格式") + } + if audioExtAllowed(".png") || audioExtAllowed(".pdf") { + t.Fatal("音频白名单里出现了图片或文档格式") + } +} + +// TestImageMIMEOfIsTheByteArbiter 钉住字节裁决本身:白名单光栅图认得出来、SVG/HTML/文本/ +// 空字节一律判否,且 `image/jpg` 这种非标准写法被归一到 `image/jpeg`。 +func TestImageMIMEOfIsTheByteArbiter(t *testing.T) { + t.Parallel() + cases := []struct { + name string + data []byte + want string + ok bool + }{ + {"png", pngSample, "image/png", true}, + {"jpeg", jpegSample, "image/jpeg", true}, + {"gif", gifSample, "image/gif", true}, + {"webp", webpSample, "image/webp", true}, + {"bmp", bmpSample, "image/bmp", true}, + {"tiff 小端", tiffSample, "image/tiff", true}, + {"tiff 大端", tiffBE, "image/tiff", true}, + {"html 错误页", htmlSample, "", false}, + {"纯文本", textSample, "", false}, + {"svg", []byte(``), "", false}, + {"空字节", nil, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := ImageMIMEOf(tc.data) + if ok != tc.ok || got != tc.want { + t.Fatalf("ImageMIMEOf = %q/%v, want %q/%v", got, ok, tc.want, tc.ok) + } + }) + } + if got, ok := knownImageMIME("image/jpg"); !ok || got != "image/jpeg" { + t.Fatalf("knownImageMIME(image/jpg) = %q/%v, want image/jpeg/true", got, ok) + } + if _, ok := knownImageMIME("image/svg+xml"); ok { + t.Fatal("SVG 被图片白名单放行了(它是可执行内容)") + } +} + +// TestMaxBytesForPerKind 钉住「上限按类型分档」的唯一出处:图片与语音 5 MiB、其余文档类 +// 20 MiB —— 各渠道 Fetcher 只该引用它,不许自己写数。 +func TestMaxBytesForPerKind(t *testing.T) { + t.Parallel() + cases := []struct { + name string + m adapter.Media + want int64 + }{ + {"图片", adapter.Media{Kind: adapter.MediaImage}, MaxImageBytes}, + {"语音", adapter.Media{Kind: adapter.MediaVoice}, MaxVoiceBytes}, + {"文件", adapter.Media{Kind: adapter.MediaFile}, documents.DefaultMaxBytes}, + {"无类型", adapter.Media{}, documents.DefaultMaxBytes}, + } + for _, tc := range cases { + if got := MaxBytesFor(tc.m); got != tc.want { + t.Fatalf("%s: MaxBytesFor = %d, want %d", tc.name, got, tc.want) + } + } + if MaxImageBytes == documents.DefaultMaxBytes { + t.Fatal("图片上限与文档上限撞在同一个数上:本切片要求图片用 5 MiB、文档用 20 MiB") + } +} + +// TestInlineImageIgnoresOtherMediaKinds 钉住分支边界:图片分支只碰图片媒体,文件/语音媒体 +// 不会被它写入 Data(各自的分支负责),且非图片媒体的失败不会影响图片。 +func TestInlineImageIgnoresOtherMediaKinds(t *testing.T) { + t.Parallel() + f := &countingFetcher{data: pngSample} + msg := &adapter.UnifiedMessage{ + Platform: "test", + Media: []adapter.Media{ + {Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"}, + {Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.silk", Name: "v.silk"}, + }, + } + log, _ := logBuffer(t) + + inlineImages(context.Background(), log, msg, f) + + if f.calls() != 0 { + t.Fatalf("图片分支对非图片媒体发起了 %d 次取回,want 0", f.calls()) + } +} diff --git a/internal/channel/mediafetch/inline_test.go b/internal/channel/mediafetch/inline_test.go index 8e55fc2..a6ee6e7 100644 --- a/internal/channel/mediafetch/inline_test.go +++ b/internal/channel/mediafetch/inline_test.go @@ -197,10 +197,11 @@ func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) { } } -// TestInlineIgnoresImageAndNonAudioVoice 钉住边界:图片媒体不走本循环(图片走视觉 -// 链路),**格式不可转写**的语音也不取字节(切片 IM-ASR 起,白名单格式的语音**会** -// 被取回,见 inline_voice_test.go)。 -func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) { +// TestInlineIgnoresNonImageAndNonAudioVoice 钉住分支边界:**声明不是图片格式**的图片媒体 +// (这里是 `.txt`)与格式不可转写的语音一样**不取字节**(切片 IM-IMG 起,声明命中图片白名单 +// 的图片**会**被 `inlineImages` 取回,见 inline_image_test.go;音频白名单里的语音同理见 +// inline_voice_test.go)。 +func TestInlineIgnoresNonImageAndNonAudioVoice(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("x")} msg := &adapter.UnifiedMessage{ @@ -215,7 +216,7 @@ func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) { Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 0 { - t.Fatalf("非文件类媒体被取回了 %d 次,want 0", f.calls()) + t.Fatalf("声明不是图片格式的媒体被取回了 %d 次,want 0", f.calls()) } } diff --git a/plugins/channels/kook/media_test.go b/plugins/channels/kook/media_test.go index 27c10d5..f70cca7 100644 --- a/plugins/channels/kook/media_test.go +++ b/plugins/channels/kook/media_test.go @@ -230,20 +230,43 @@ func TestInboundBodyURLIsNeverFetched(t *testing.T) { } } -// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体不走文档内联。 -func TestInboundImageMediaKeepsExistingPath(t *testing.T) { +// kookPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名)。 +var kookPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body") + +// TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages 钉住切片 IM-IMG 之后的新边界: +// 图片类媒体**现在也进内联链路**(取回字节,供桥接器内联成 data: URI 进视觉链路),但 +// 「字节嗅探是唯一裁决权」——假服务端回文本时它**绝不**被写进 `Data`,回真图字节时才写。 +// ⚠️ KOOK 自己的图片来源白名单与上游接线属后续切片(本切片只做微信)。 +func TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages(t *testing.T) { t.Parallel() - s := newKookAssetServer(t) - a, c, _ := newInlineKookAdapter(t, s) + t.Run("字节不是图片: 取回但不内联", func(t *testing.T) { + t.Parallel() + s := newKookAssetServer(t) // 默认正文是文本(Content-Type: text/plain) + a, c, _ := newInlineKookAdapter(t, s) - if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil { - t.Fatalf("handleEvent: %v", err) - } + if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil { + t.Fatalf("handleEvent: %v", err) + } - if s.hits.Load() != 0 { - t.Fatalf("图片媒体被文档内联取回了 %d 次,want 0", s.hits.Load()) - } - if len(c.message(t).Media[0].Data) != 0 { - t.Fatal("图片媒体被写入了字节(应留给视觉链路)") - } + if s.hits.Load() != 1 { + t.Fatalf("图片声明的媒体取回次数 = %d, want 1(图片现在也进内联链路)", s.hits.Load()) + } + if len(c.message(t).Media[0].Data) != 0 { + t.Fatal("非图片字节被写入了字节(嗅探是唯一裁决权)") + } + }) + t.Run("字节是真图: 内联", func(t *testing.T) { + t.Parallel() + s := newKookAssetServer(t) + s.body = kookPNG + a, c, _ := newInlineKookAdapter(t, s) + + if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil { + t.Fatalf("handleEvent: %v", err) + } + + if !bytes.Equal(c.message(t).Media[0].Data, kookPNG) { + t.Fatalf("Media.Data = %q, want 取回的图片字节", c.message(t).Media[0].Data) + } + }) } diff --git a/plugins/channels/weixin_ilink/media.go b/plugins/channels/weixin_ilink/media.go index cd9056a..4520233 100644 --- a/plugins/channels/weixin_ilink/media.go +++ b/plugins/channels/weixin_ilink/media.go @@ -110,16 +110,20 @@ func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) return data, nil } -// inlineInboundMedia 把受支持格式的文件附件取回、解密并内联到消息上(切片 IM-1)。 -// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行 -// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。 +// inlineInboundMedia 把受支持格式的附件(**文件类、图片类、语音类**)取回、解密并内联到 +// 消息上(切片 IM-1 / IM-ASR / IM-IMG)。失败不抛出:该附件保持无字节,由 channelbridge 的 +// 入站摄取照既有口径给模型一行诚实提示(design/24 §4.4)——本切片绝不为了「让附件能读」 +// 而把失败吞掉。 +// +// 图片走的是**同一条路**:iLink 的 image_item 不带文件名也不带 MIME,因此它的可取性由 +// 平台类型 + 字节嗅探判定(`mediafetch.inlineImages`),来源仍受下面这份白名单约束。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData}) } // ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对 // URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 —— -// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、独立的体积上限,以及 +// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、按类型的体积上限,以及 // **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。 type ilinkFetcher struct { a *Adapter @@ -128,21 +132,23 @@ type ilinkFetcher struct { raw []byte } -// Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由 -// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量 +// Fetch 取回并解密一份微信 iLink 附件(**文件、图片、语音、视频同一条路**)。体积上限与 +// 超时都不在这里写数:上限按媒体类型由 `mediafetch.MaxBytesFor` 给出(图片 5 MiB、语音 +// 5 MiB、文档类附件 20 MiB —— 一份巨图绝不会按文档的 20 MiB 收下),超时是具名常量 // `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限, -// 与本切片的文档上限刻意解耦)。 +// 与本切片的内联上限刻意解耦)。 func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { key, err := f.inboundKey(m) if err != nil { return nil, err } ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{ - URL: f.a.cdnURL(m.URL), - Allow: f.a.assetAllowlist(), - Header: f.a.inboundHeaders(), - Client: f.a.httpClient, - Timeout: mediafetch.DefaultTimeout, + URL: f.a.cdnURL(m.URL), + Allow: f.a.assetAllowlist(), + Header: f.a.inboundHeaders(), + Client: f.a.httpClient, + MaxBytes: mediafetch.MaxBytesFor(m), + Timeout: mediafetch.DefaultTimeout, }) if err != nil { return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err) @@ -172,6 +178,10 @@ func (f ilinkFetcher) inboundKey(m adapter.Media) ([]byte, error) { // `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用 // 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute), // 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。 +// +// 它对**所有**媒体类型一视同仁:文件、图片、语音、视频都经 `ilinkFetcher.Fetch` 取回, +// 因此图片 URL(绝对 `full_url` 或 CDN 根下的相对引用)同样受这一份白名单约束 —— +// 图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限,不是更宽的网络可达面。 func (a *Adapter) assetAllowlist() mediafetch.Allowlist { origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL) if err != nil { diff --git a/plugins/channels/weixin_ilink/media_image_test.go b/plugins/channels/weixin_ilink/media_image_test.go new file mode 100644 index 0000000..b5e4f17 --- /dev/null +++ b/plugins/channels/weixin_ilink/media_image_test.go @@ -0,0 +1,179 @@ +package weixin_ilink + +// 本文件锁定微信 iLink 入站**图片**的采集口径(切片 IM-IMG):CDN 引用 → 来源白名单取回 +// → 剥 AES-ECB 层 → 写进 `Media.Data`(交回给桥接器内联成 data: URI 进视觉链路)。 +// +// 覆盖:正向(含真机形状——`image_item` 既无 file_name 也无 mime)、按条 `aeskey`、 +// 来源白名单反证(白名单外的绝对引用 **0 次命中**)、图片口径的体积上限(5 MiB,不是文档的 +// 20 MiB)、以及「声明说图片、字节说不是」时**不内联**。 + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" +) + +// pngBytes 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名, +// 因此样本必须是完整签名 + 载荷,短于签名就会被判成非图片)。 +var pngBytes = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body") + +// imageItemRaw 构造一条带图片 item 的原始 iLink 消息。imageItem 就是真机协议里的 +// `image_item` 对象(`{"media": {...}}`,可能另有 `aeskey`);**名字与 MIME 本就不存在**, +// 这正是本切片要覆盖的形状。 +func imageItemRaw(t *testing.T, imageItem map[string]any) []byte { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "client_id": "c-img", "from_user_id": "u-1", "message_type": msgTypeUser, + "item_list": []any{map[string]any{"type": itemImage, "image_item": imageItem}}, + }) + if err != nil { + t.Fatalf("marshal image item: %v", err) + } + return raw +} + +// TestInboundImageIsInlinedAndDecrypted 是正向用例:真机形状的图片 item(相对下载引用、 +// 无名字无 MIME)被取回、解密并内联,且字节确实是白名单光栅图。 +func TestInboundImageIsInlinedAndDecrypted(t *testing.T) { + t.Parallel() + key := keyOf(0x41) + s := newIlinkAssetServer(t, encrypted(t, key, pngBytes)) + a, c := newInlineIlinkAdapter(t, s, key) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "media": map[string]any{"encrypt_query_param": "ENC-PARAM"}, + })) + if m == nil { + t.Fatal("convertMessage 没有解析出消息") + } + a.deliver(context.Background(), *m, nil) + + msg := c.message(t) + if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage { + t.Fatalf("媒体不是图片:%+v", msg.Media) + } + if !bytes.Equal(msg.Media[0].Data, pngBytes) { + t.Fatalf("Media.Data = %q, want 解密后的图片字节", msg.Media[0].Data) + } + if s.hits.Load() != 1 { + t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load()) + } + // 桥接器构造 data: URI 时用的就是这个裁决入口,这里顺手钉住它对真机字节给得出类型。 + if mime, ok := mediafetch.ImageMIMEOf(msg.Media[0].Data); !ok || mime != "image/png" { + t.Fatalf("ImageMIMEOf = %q/%v, want image/png/true", mime, ok) + } +} + +// TestInboundImageAbsoluteRefOffAllowlistIsNotDownloaded 钉住来源白名单对**图片**同样生效: +// `full_url` 是绝对地址时 `cdnURL` 原样放行,唯一能挡住「对端伪造绝对引用」的就是白名单。 +func TestInboundImageAbsoluteRefOffAllowlistIsNotDownloaded(t *testing.T) { + t.Parallel() + key := keyOf(0x42) + inside := newIlinkAssetServer(t, encrypted(t, key, pngBytes)) + outside := newIlinkAssetServer(t, encrypted(t, key, pngBytes)) + a, c := newInlineIlinkAdapter(t, inside, key) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "media": map[string]any{"full_url": outside.srv.URL + "/c2c/img.png", "encrypt_query_param": "ENC"}, + })) + a.deliver(context.Background(), *m, nil) + + if outside.hits.Load() != 0 { + t.Fatalf("白名单外的图片主机被请求了 %d 次,want 0", outside.hits.Load()) + } + if inside.hits.Load() != 0 { + t.Fatalf("白名单内的主机不该收到这次请求(引用指向别处):%d 次", inside.hits.Load()) + } + if len(c.message(t).Media[0].Data) != 0 { + t.Fatal("白名单外的图片被内联了") + } +} + +// TestInboundImagePerItemAesKeyIsUsed 钉住图片的**按条密钥**(`image_item.aeskey`,十六进制) +// 优先于 bot_token 兜底:这里刻意让两条路的密钥不同,并断言解出的字节**逐字节等于**明文 —— +// 用错密钥时 AES-ECB 仍会「成功」返回乱码,只断言「非空」会假绿。 +func TestInboundImagePerItemAesKeyIsUsed(t *testing.T) { + t.Parallel() + perItem := keyOf(0x43) + fallback := keyOf(0x44) + plain := append([]byte(nil), pngBytes...) + s := newIlinkAssetServer(t, encrypted(t, perItem, plain)) + a, c := newInlineIlinkAdapter(t, s, fallback) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "aeskey": hex.EncodeToString(perItem), + "media": map[string]any{"encrypt_query_param": "ENC-PARAM"}, + })) + a.deliver(context.Background(), *m, nil) + + got := c.message(t).Media[0].Data + if !bytes.Equal(got, plain) { + t.Fatalf("Media.Data = %q, want %q(按条 aeskey 没有被用上)", got, plain) + } +} + +// TestInboundImageUsesImageByteCap 钉住图片走**图片口径**的上限(5 MiB),而不是文档的 +// 20 MiB:假 CDN 下发的是一份**合法 AES 密文**,解密后是「PNG 签名 + 填充」的 5 MiB+64 +// 字节 —— 它介于两个上限之间,且字节本身是**能通过嗅探的合法 PNG 前缀**,因此「没有被内联」 +// 只可能来自图片上限(用文档上限就会把它收下)。 +func TestInboundImageUsesImageByteCap(t *testing.T) { + t.Parallel() + key := keyOf(0x45) + big := append(append([]byte(nil), pngBytes...), bytes.Repeat([]byte("x"), int(mediafetch.MaxImageBytes)+64-len(pngBytes))...) + s := newIlinkAssetServer(t, encrypted(t, key, big)) + a, c := newInlineIlinkAdapter(t, s, key) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "media": map[string]any{"encrypt_query_param": "ENC-PARAM"}, + })) + a.deliver(context.Background(), *m, nil) + + if got := len(c.message(t).Media[0].Data); got != 0 { + t.Fatalf("超限的图片被内联了(%d 字节);图片上限应为 %d", got, mediafetch.MaxImageBytes) + } + if s.hits.Load() != 1 { + t.Fatalf("CDN 命中 = %d, want 1(取回发生在上限判定之前)", s.hits.Load()) + } + // 反证夹具本身是「合法图片」:去掉上限后会内联的字节,这里必须仍然只因为体积被拒。 + if _, ok := mediafetch.ImageMIMEOf(big); !ok { + t.Fatal("夹具要不是合法 PNG,本用例就变成了嗅探拒绝的假绿") + } +} + +// TestInboundImageBytesAreSniffedNotTrusted 钉住「字节嗅探是唯一裁决权」在微信这条路上 +// 也成立:平台把它标成图片、下发的却是 HTML(AES 密文合法、解密也成功)⇒ **不内联**。 +func TestInboundImageBytesAreSniffedNotTrusted(t *testing.T) { + t.Parallel() + key := keyOf(0x46) + s := newIlinkAssetServer(t, encrypted(t, key, []byte("PROXY"))) + a, c := newInlineIlinkAdapter(t, s, key) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "media": map[string]any{"encrypt_query_param": "ENC-PARAM"}, + })) + a.deliver(context.Background(), *m, nil) + + if len(c.message(t).Media[0].Data) != 0 { + t.Fatal("非图片字节被当成图片内联了") + } + if s.hits.Load() != 1 { + t.Fatalf("CDN 命中 = %d, want 1(必须看字节才知道它不是图片)", s.hits.Load()) + } +} + +// TestMaxBytesForImageIsNotDocumentCap 钉住渠道侧引用的那一份「按类型分档」映射:图片 5 MiB、 +// 文档类 20 MiB,两者不允许撞在一起(一个数只能有一个答案)。 +func TestMaxBytesForImageIsNotDocumentCap(t *testing.T) { + t.Parallel() + if got := mediafetch.MaxBytesFor(adapter.Media{Kind: adapter.MediaImage}); got != mediafetch.MaxImageBytes { + t.Fatalf("MaxBytesFor(image) = %d, want %d", got, mediafetch.MaxImageBytes) + } + if mediafetch.MaxImageBytes >= 20<<20 { + t.Fatalf("图片上限 %d 已经不小于文档上限,两者解耦失效", mediafetch.MaxImageBytes) + } +} diff --git a/plugins/channels/weixin_ilink/media_key_test.go b/plugins/channels/weixin_ilink/media_key_test.go index b08b2ea..9b6060f 100644 --- a/plugins/channels/weixin_ilink/media_key_test.go +++ b/plugins/channels/weixin_ilink/media_key_test.go @@ -173,7 +173,8 @@ func TestInboundRealFileItemAcceptsBase64HexAesKey(t *testing.T) { // TestInboundRealImageItemPrefersImageAeskey 钉住官方注明的优先级:`image_item.aeskey` // (十六进制)优先于 `media.aes_key`。夹具把 media.aes_key 填成**错的**密钥,因此只有 -// 走对分支才解得出正文。图片不进内联链路(mediafetch 只管文件类),所以直接驱动 Fetcher。 +// 走对分支才解得出正文。本用例钉的是**取回层**的密钥选择(图片现在也走内联链路,但这里的 +// 载荷不是可嗅探的真图字节,走内联会被字节裁决挡下),所以直接驱动 Fetcher。 func TestInboundRealImageItemPrefersImageAeskey(t *testing.T) { t.Parallel() right := keyOf(0x42)