feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)
This commit is contained in:
1 parent
0cbc9ff886
commit
b73a1276c6
18 files changed
+1224
-121
No files matched your search
@@ -1,8 +1,9 @@
|
||||
package kook
|
||||
|
||||
// 本文件承载 KOOK 的**入站附件字节内联**(切片 IM-1):KOOK 的 s=0 消息把附件地址
|
||||
// 直接放在 `content` 字段里(图片/视频/文件/音频同理),因此文件类消息拿到的是一个
|
||||
// 绝对 CDN 地址,可以按来源白名单直接取回。
|
||||
// 本文件承载 KOOK 的**入站附件字节内联**(切片 IM-1 / IM-IMG):KOOK 的 s=0 消息把附件
|
||||
// 地址直接放在 `content` 字段里(图片/视频/文件/音频同理),因此**文件与图片**拿到的是一个
|
||||
// 绝对 CDN 地址,可以按**同一份来源白名单**直接取回(图片多出来的只是「字节必须是白名单
|
||||
// 光栅图」与 5 MiB 的图片上限)。
|
||||
//
|
||||
// 两条边界:
|
||||
// - **只认平台资产主机**:白名单 = KOOK 附件 CDN(`img.kookapp.cn`)+ 已配置的
|
||||
@@ -18,11 +19,12 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// kookAssetOrigin 是 KOOK 附件的 CDN 来源(平台只在它上面提供附件地址)。
|
||||
// kookAssetOrigin 是 KOOK 附件的 CDN 来源(平台只在它上面提供附件地址)。**文件与图片
|
||||
// 共用这一条**:图片不另立名单(多一份名单只会多一处漂移),见 media_image_test.go。
|
||||
const kookAssetOrigin = "https://img.kookapp.cn"
|
||||
|
||||
// inlineInboundMedia 把受支持格式的文件附件取回并内联到消息上。失败不抛出:该附件
|
||||
// 保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。
|
||||
// inlineInboundMedia 把受支持格式的附件(**文件类与图片类**)取回并内联到消息上。失败不抛出:
|
||||
// 该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, kookFetcher{a: a})
|
||||
}
|
||||
@@ -40,7 +42,8 @@ func (f kookFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error)
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出 KOOK 附件的来源白名单:附件 CDN + 已配置的 API 根。第二条是
|
||||
// assetAllowlist 给出 KOOK 附件的来源白名单:附件 CDN + 已配置的 API 根(**文件与图片
|
||||
// 共用同一份** —— 两者落的是同一批主机,另立一份只会多一处漂移)。第二条是
|
||||
// 为了让**自建/私有化部署**(把 `base_url` 指到自己的网关)也能走通,且它的可信度与
|
||||
// 其余 API 调用一致(同一条由部署声明的地址)。两条都读不出来时给空名单 ⇒ 谁都不许。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package kook
|
||||
|
||||
// 本文件锁定 KOOK 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧):图片与文件
|
||||
// **共用同一份**媒体来源白名单(`assetAllowlist()`:`img.kookapp.cn` + 已配置的 API 根),
|
||||
// 因此图片不需要、也**不许**另立第三份名单。
|
||||
//
|
||||
// 覆盖:白名单命中(平台图片 CDN、已配置的 API 根)、**反证**(非白名单来源一个字节都不
|
||||
// 发出去 —— 用一台真在监听、真会计数的服务端做对照),以及白名单外的「0 次命中」。
|
||||
//
|
||||
// KOOK 的图片地址来自消息的 `content` 字段(发送方可影响的内容),因此这份白名单是硬门:
|
||||
// 伪造的图片消息完全可以填任意地址,能挡住它的只有来源名单本身。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// TestKookImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:平台图片 CDN
|
||||
// 与已配置的 API 根命中,其余一律不命中(deny-by-default)。
|
||||
func TestKookImageOriginsAreAllowlisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
a, _, _ := newInlineKookAdapter(t, s)
|
||||
allow := a.assetAllowlist()
|
||||
|
||||
for _, origin := range []string{
|
||||
"https://img.kookapp.cn/assets/2023-03/abcdef.png",
|
||||
s.srv.URL + "/asset/photo.png",
|
||||
} {
|
||||
if !allow.Allowed(origin) {
|
||||
t.Fatalf("图片来源 %q 没有被既有白名单覆盖(图片与文件必须共用一份来源名单)", origin)
|
||||
}
|
||||
}
|
||||
for _, origin := range []string{
|
||||
"https://evil.example.com/photo.png",
|
||||
"http://img.kookapp.cn/assets/2023-03/abcdef.png", // 该 CDN 只声明了 https 来源
|
||||
"file:///etc/passwd",
|
||||
} {
|
||||
if allow.Allowed(origin) {
|
||||
t.Fatalf("非平台来源 %q 被白名单放行了", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKookImageOffAllowlistIsNotDownloaded 是**反证**:图片的 `content` 指向白名单之外的主机时
|
||||
// **一个字节都不发出去** —— 对照服务端真的在监听、真的会计数(因此这条不是「连不上」的
|
||||
// 假象),命中次数必须是 0,字节必须为空,且失败不被静默。
|
||||
func TestKookImageOffAllowlistIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
var hits atomic.Int32
|
||||
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(kookPNG)
|
||||
}))
|
||||
t.Cleanup(foreign.Close)
|
||||
|
||||
s := newKookAssetServer(t) // 它才是被配置进白名单的那个 API 根
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, foreign.URL+"/asset/photo.png"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if got := hits.Load(); got != 0 {
|
||||
t.Fatalf("非白名单来源的图片被取回 %d 次,want 0", got)
|
||||
}
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage || len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("非白名单来源的图片仍被写入了字节:%+v", msg.Media)
|
||||
}
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("图片请求打到了已配置根 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestKookImageIsInlinedFromPlatformCDN 正向(平台 CDN 形状):`img.kookapp.cn` 的图片地址
|
||||
// 在名单内 ⇒ 取回并内联。(取回用的是假服务端:真 CDN 不在用例里碰,白名单本身由上面的
|
||||
// 断言钉住。)
|
||||
func TestKookImageIsInlinedFromPlatformCDN(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
s.body = kookPNG
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
// handleEvent 的图片地址取自 `content`,因此这里用假服务端地址(它同时是配置的 API 根,
|
||||
// 于是「名单内」这条前提成立);平台 CDN 主机名的命中由上面的白名单用例单独钉住。
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
msg := c.message(t)
|
||||
if msg.ContentType != adapter.ContentImage || len(msg.Media) != 1 {
|
||||
t.Fatalf("图片消息被归一成了别的形状:%+v", msg)
|
||||
}
|
||||
if string(msg.Media[0].Data) != string(kookPNG) {
|
||||
t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("图片资产命中 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
// 图片地址是发送方可影响的 `content`,因此它**绝不**能进正文(正文只承载聊天文本)。
|
||||
if msg.Content != "" {
|
||||
t.Fatalf("图片地址被写进了正文:%q(资产地址只配进 Media[].URL)", msg.Content)
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,8 @@ var kookPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
// TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages 钉住切片 IM-IMG 之后的新边界:
|
||||
// 图片类媒体**现在也进内联链路**(取回字节,供桥接器内联成 data: URI 进视觉链路),但
|
||||
// 「字节嗅探是唯一裁决权」——假服务端回文本时它**绝不**被写进 `Data`,回真图字节时才写。
|
||||
// ⚠️ KOOK 自己的图片来源白名单与上游接线属后续切片(本切片只做微信)。
|
||||
// ⚠️ KOOK 的**图片来源白名单**就是既有那份媒体来源名单(`img.kookapp.cn` + `base_url`),
|
||||
// 不另立第三份;名单命中与「非名单来源 0 次请求」由 media_image_test.go 单独钉住。
|
||||
func TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("字节不是图片: 取回但不内联", func(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user