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) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件承载 OneBot v11 的**入站附件字节内联**(切片 IM-1):文件类消息段(数组
|
||||
// 形态的 `{type:"file",data:{...}}` 与 CQ 码形态的 `[CQ:file,...]`)里,实现方通常
|
||||
// 会给出一个 `url`(离线文件/群文件的下载地址),字节就照它取回。
|
||||
// 本文件承载 OneBot v11 的**入站附件字节内联**(切片 IM-1 / IM-IMG):文件类消息段
|
||||
// (数组形态的 `{type:"file",data:{...}}` 与 CQ 码形态的 `[CQ:file,...]`)与**图片段**
|
||||
// (`{type:"image",data:{url:...}}`)里,实现方通常会给出一个 `url`(下载地址),
|
||||
// 字节就照它取回 —— 文件与图片走**同一条路、同一份来源白名单**(图片多出来的只是
|
||||
// 「字节必须是白名单光栅图」与 5 MiB 的图片上限)。
|
||||
//
|
||||
// 三条边界:
|
||||
// - **只认平台系资产主机**:白名单 = 腾讯系 CDN(`*.qq.com`、`*.qq.com.cn`、
|
||||
// `*.qpic.cn`,覆盖 `multimedia.nt.qq.com.cn` 与 `gchat.qpic.cn`)+ 部署自己
|
||||
// 声明的 OneBot API 端点(本地实现常自己下发文件 URL)。消息段里的 `url` 是
|
||||
// 对端上报的内容,白名单是硬门:不在名单里一个字节都不发出去;
|
||||
// `*.qpic.cn` 的 http/https 两种协议,覆盖 `multimedia.nt.qq.com.cn` 与
|
||||
// `gchat.qpic.cn`)+ 部署自己声明的 OneBot API 端点(本地实现常自己下发资产 URL)。
|
||||
// 消息段里的 `url` 是对端上报的内容,白名单是硬门:不在名单里一个字节都不发出去;
|
||||
// - **没有真 url 就不下载**:段里只有文件 id/文件名时(`url` 缺失,`file` 被当作
|
||||
// 引用写进了 URL 字段),它连绝对 http(s) 地址都不是 ⇒ 白名单直接否决,绝不
|
||||
// 拼凑地址去猜;
|
||||
@@ -21,17 +23,27 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// 腾讯系资产主机白名单:OneBot 实现上报的文件地址落在这些子树里(`.qq.com` 家族
|
||||
// 覆盖 `multimedia.nt.qq.com.cn` 之外的多媒体域名,`.qpic.cn` 覆盖图片/文件 CDN)。
|
||||
// 腾讯系资产主机白名单:OneBot 实现上报的文件与**图片**地址都落在这些子树里(`.qq.com`
|
||||
// 家族覆盖 `multimedia.nt.qq.com.cn` 之外的多媒体域名,`.qpic.cn` 覆盖图片/文件 CDN)。
|
||||
// 通配条目**不含**裸的 `qq.com` 本身,因此不会顺带放行 `qq.com` 主页这类无关主机。
|
||||
//
|
||||
// ⚠️ **两种协议都在名单里,这不是第三份名单,而是同一份名单补上 OneBot 的真实形状**:
|
||||
// `http://` 变体来自经典实现下发图片地址的实际形状(`http://gchat.qpic.cn/gchatpic_new/…`),
|
||||
// 只放行 `https://` 会让那些图片**永远取不到**(白名单拒绝 = 一个字节都不发,模型只能收到
|
||||
// 「读不到」)。放行面仍是**平台自己的 CDN 主机**(子域由腾讯控制,发送方无法把请求引到
|
||||
// 别的主机),且取回的字节仍要过 `mediafetch` 的图片白名单(5 MiB + 字节嗅探),因此
|
||||
// 明文协议只影响「与腾讯 CDN 之间是否 TLS」,不扩大可达主机面。
|
||||
const (
|
||||
qqAssetOriginQQ = "https://*.qq.com"
|
||||
qqAssetOriginQQCN = "https://*.qq.com.cn"
|
||||
qqAssetOriginQpic = "https://*.qpic.cn"
|
||||
qqAssetOriginQQ = "https://*.qq.com"
|
||||
qqAssetOriginQQCN = "https://*.qq.com.cn"
|
||||
qqAssetOriginQpic = "https://*.qpic.cn"
|
||||
qqAssetOriginQQHTTP = "http://*.qq.com"
|
||||
qqAssetOriginQQCNHTTP = "http://*.qq.com.cn"
|
||||
qqAssetOriginQpicHTTP = "http://*.qpic.cn"
|
||||
)
|
||||
|
||||
// inlineInboundMedia 把受支持格式的文件附件取回并内联到消息上。失败不抛出:该附件
|
||||
// 保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。
|
||||
// inlineInboundMedia 把受支持格式的附件(**文件类与图片类**)取回并内联到消息上。失败不抛出:
|
||||
// 该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, qqFetcher{a: a})
|
||||
}
|
||||
@@ -39,9 +51,9 @@ func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMe
|
||||
// qqFetcher 实现 mediafetch.Fetcher:OneBot 上报的 `url` 就是资产地址。
|
||||
type qqFetcher struct{ a *Adapter }
|
||||
|
||||
// Fetch 按来源白名单取回一份文件资产。下载**不复用**出站 HTTP API 的客户端:那个
|
||||
// 客户端带着 OneBot access_token 头,绝不能把它发到 CDN 主机上,因此这里交给
|
||||
// mediafetch 自建一个干净客户端。
|
||||
// Fetch 按来源白名单取回一份平台资产(**文件与图片同一条路**)。下载**不复用**出站
|
||||
// HTTP API 的客户端:那个客户端带着 OneBot access_token 头,绝不能把它发到 CDN 主机上,
|
||||
// 因此这里交给 mediafetch 自建一个干净客户端。
|
||||
func (f qqFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
return mediafetch.Fetch(ctx, mediafetch.Request{
|
||||
URL: m.URL,
|
||||
@@ -50,11 +62,16 @@ func (f qqFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出 OneBot 文件资产的来源白名单:腾讯系 CDN + 已配置的 OneBot
|
||||
// API 端点(`api_base_url`,本地部署常是 `http://127.0.0.1:5700` 这类地址 —— 它由
|
||||
// 部署显式声明,可信度与其余 API 调用一致)。`api_base_url` 为空时只用腾讯系条目。
|
||||
// assetAllowlist 给出 OneBot 平台资产的来源白名单:腾讯系 CDN(文件与**图片**共用同一份,
|
||||
// 见上面两种协议的说明)+ 已配置的 OneBot API 端点(`api_base_url`,本地部署常是
|
||||
// `http://127.0.0.1:5700` 这类地址 —— 它由部署显式声明,可信度与其余 API 调用一致)。
|
||||
// `api_base_url` 为空时只用腾讯系条目。图片不另立名单:它落的是同一批主机,
|
||||
// 多一份名单只会多一处漂移。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
origins := []string{qqAssetOriginQQ, qqAssetOriginQQCN, qqAssetOriginQpic}
|
||||
origins := []string{
|
||||
qqAssetOriginQQ, qqAssetOriginQQCN, qqAssetOriginQpic,
|
||||
qqAssetOriginQQHTTP, qqAssetOriginQQCNHTTP, qqAssetOriginQpicHTTP,
|
||||
}
|
||||
if origin, err := mediafetch.OriginOf(a.cfg.APIBaseURL); err == nil {
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件锁定 OneBot v11 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧):
|
||||
// 图片来源与文件来源**共用同一份**媒体来源白名单(`assetAllowlist()`:腾讯系 CDN +
|
||||
// 部署自己声明的 OneBot API 端点),因此图片不需要、也**不许**另立第三份名单。
|
||||
//
|
||||
// 覆盖:白名单命中(腾讯系图片 CDN、本地 OneBot 端点)、端点来源被取回并内联、
|
||||
// 以及**反证** —— 非白名单来源一个字节都不发出去(用一台真在监听、真会计数的服务端
|
||||
// 做对照,因此「0 次命中」不是「连不上」的假象)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// qqPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只看前 8 字节签名)。
|
||||
var qqPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
|
||||
// qqImageEvent 构造一条私聊**图片**消息(数组形态的消息段,`file` 带图片扩展名、
|
||||
// `url` 是 OneBot 实现上报的资产地址 —— 真机上就是这个形状)。
|
||||
func qqImageEvent(fileName, fileURL string) []byte {
|
||||
segs := []any{
|
||||
map[string]any{"type": "image", "data": map[string]any{"file": fileName, "url": fileURL}},
|
||||
}
|
||||
ev := map[string]any{
|
||||
"post_type": "message", "message_type": "private", "time": 1700000000,
|
||||
"self_id": 100, "message_id": 7, "user_id": 200,
|
||||
"message": segs,
|
||||
"sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""},
|
||||
}
|
||||
raw, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("marshal ob event: %v", err))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestQQImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:腾讯系图片 CDN
|
||||
// (`multimedia.nt.qq.com.cn` / `gchat.qpic.cn`)与已配置的 OneBot 端点命中,其余一律不命中
|
||||
// (deny-by-default)。
|
||||
func TestQQImageOriginsAreAllowlisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
a, _, _ := newInlineQQAdapter(t, s)
|
||||
allow := a.assetAllowlist()
|
||||
|
||||
for _, origin := range []string{
|
||||
"https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=x&rkey=SECRET",
|
||||
"https://gchat.qpic.cn/gchatpic_new/0/0-0-0123456789/0",
|
||||
"http://gchat.qpic.cn/gchatpic_new/0/0-0-0123456789/0",
|
||||
s.srv.URL + "/download/photo.png",
|
||||
} {
|
||||
if !allow.Allowed(origin) {
|
||||
t.Fatalf("图片来源 %q 没有被既有白名单覆盖(图片与文件必须共用一份来源名单)", origin)
|
||||
}
|
||||
}
|
||||
for _, origin := range []string{
|
||||
"https://evil.example.com/a.png",
|
||||
"https://qq.com/a.png", // 通配条目不覆盖裸域名
|
||||
"file:///etc/passwd",
|
||||
} {
|
||||
if allow.Allowed(origin) {
|
||||
t.Fatalf("非平台来源 %q 被白名单放行了", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQQImageIsInlinedFromOneBotEndpoint 正向:图片段的 `url` 指向已配置的 OneBot 端点
|
||||
// (本地实现自己下发图片地址的部署形态)⇒ 取回并内联,消息照常投递。
|
||||
func TestQQImageIsInlinedFromOneBotEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
s.body = qqPNG
|
||||
a, c, _ := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqImageEvent("photo.jpg", s.srv.URL+"/download/photo.png"))
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || !bytes.Equal(msg.Media[0].Data, qqPNG) {
|
||||
t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("图片资产命中 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestQQImageOffAllowlistIsNotDownloaded 是**反证**:图片的 `url` 指向白名单之外的主机时
|
||||
// **一个字节都不发出去** —— 对照服务端真的在监听、真的会计数(因此这条不是「连不上」的
|
||||
// 假象),命中次数必须是 0,字节必须为空。
|
||||
func TestQQImageOffAllowlistIsNotDownloaded(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(qqPNG)
|
||||
}))
|
||||
t.Cleanup(foreign.Close)
|
||||
|
||||
s := newQQAssetServer(t) // 它才是被配置进白名单的那个端点
|
||||
a, c, logs := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqImageEvent("photo.jpg", foreign.URL+"/download/photo.png"))
|
||||
|
||||
if got := hits.Load(); got != 0 {
|
||||
t.Fatalf("非白名单来源的图片被取回 %d 次,want 0", got)
|
||||
}
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("非白名单来源的图片仍被写入了字节:%q", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("图片请求打到了已配置端点 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "inbound image inline failed") ||
|
||||
!strings.Contains(out, "资产来源不在白名单内") {
|
||||
t.Fatalf("被白名单拒掉时没有留下日志(绝不静默):%q", out)
|
||||
}
|
||||
if strings.Contains(out, "SECRET") {
|
||||
t.Fatalf("日志泄露了签名参数:%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQQImageBytesMustSniffAsImage 钉住渠道侧的「字节嗅探是唯一裁决权」:声明是 `.jpg`
|
||||
// 且来源在白名单内,取回的却是 HTML ⇒ 取回发生过(必须看字节才知道),但**不内联**。
|
||||
func TestQQImageBytesMustSniffAsImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
s.body = []byte("<!DOCTYPE html><html>error page</html>")
|
||||
a, c, _ := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqImageEvent("photo.jpg", s.srv.URL+"/download/photo.png"))
|
||||
|
||||
msg := c.message(t)
|
||||
if msg.Media[0].Kind != adapter.MediaImage || len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("非图片字节被写进了字节:%q", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package telegram
|
||||
|
||||
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1):Bot API 上报的文件类
|
||||
// 媒体只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id 换成一个
|
||||
// **平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载。
|
||||
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1 / IM-IMG):Bot API 上报的
|
||||
// **文件类与图片类**媒体都只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id
|
||||
// 换成一个**平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载(两步同一条
|
||||
// 路、同一份来源白名单;图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限)。
|
||||
//
|
||||
// 三条边界:
|
||||
// - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机;
|
||||
@@ -25,8 +26,8 @@ import (
|
||||
// getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。
|
||||
const getFileMethod = "getFile"
|
||||
|
||||
// inlineInboundMedia 在消息投递给上层之前,把受支持格式的文件附件取回并内联。
|
||||
// 失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型
|
||||
// inlineInboundMedia 在消息投递给上层之前,把受支持格式的附件(**文件类与图片类**)取回并
|
||||
// 内联。失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型
|
||||
// 一行诚实提示(design/24 §4.4)。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, telegramFetcher{a: a})
|
||||
@@ -52,7 +53,8 @@ func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, er
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出文件下载的来源白名单:**只有已配置的 Bot API 根**。文件地址
|
||||
// assetAllowlist 给出文件与**图片**下载的来源白名单:**只有已配置的 Bot API 根**(图片不
|
||||
// 另立名单 —— 两者落的是同一台主机,另立一份只会多一处漂移)。文件地址
|
||||
// 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把
|
||||
// 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package telegram
|
||||
|
||||
// 本文件锁定 Telegram 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧):图片与文件
|
||||
// **共用同一份**媒体来源白名单(`assetAllowlist()`:已配置的 Bot API 根),因此图片不需要、
|
||||
// 也**不许**另立第三份名单。
|
||||
//
|
||||
// 覆盖:默认配置下 API 根(`api.telegram.org`)命中、覆盖配置下用配置的根、非名单来源不命中;
|
||||
// 图片经 `file_id → getFile → 下载` 两步取回并内联;以及**反证** —— 平台返回的路径若试图
|
||||
// 把我们引到别的主机(含 `://`),连一次下载请求都不发出去。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// tgPNG 是一份「字节嗅探认得出来」的 PNG 载荷(嗅探只看前 8 字节签名)。
|
||||
var tgPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
|
||||
// tgPhotoUpdate 构造一条 Telegram **纯图片**消息(无 text/caption)。
|
||||
func tgPhotoUpdate(fileID string, size int64) []byte {
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"update_id": 1,
|
||||
"message": map[string]any{
|
||||
"message_id": 42,
|
||||
"date": 1700000000,
|
||||
"chat": map[string]any{"id": 7, "type": "private"},
|
||||
"from": map[string]any{"id": 9, "first_name": "主人"},
|
||||
"photo": []map[string]any{
|
||||
{"file_id": fileID, "file_size": size, "width": 1280, "height": 720},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("marshal update: %v", err))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// tgImageServer 是假的 Bot API + 文件服务端:`file_path` 由调用方给(用来构造「平台想让
|
||||
// 我们下载什么」的各种形状),下载次数单独计数。
|
||||
type tgImageServer struct {
|
||||
srv *httptest.Server
|
||||
filePath string
|
||||
body []byte
|
||||
getFile atomic.Int32
|
||||
fileHits atomic.Int32
|
||||
}
|
||||
|
||||
func newTGImageServer(t *testing.T, filePath string) *tgImageServer {
|
||||
t.Helper()
|
||||
s := &tgImageServer{filePath: filePath, body: tgPNG}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getFile"):
|
||||
s.getFile.Add(1)
|
||||
okResponse(w, map[string]any{"file_id": "photo-1", "file_path": s.filePath})
|
||||
case strings.Contains(r.URL.Path, "/file/bot"):
|
||||
s.fileHits.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(s.body)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// newTGImageAdapter 装配一个接到假服务端的适配器 + 捕获器。
|
||||
func newTGImageAdapter(t *testing.T, s *tgImageServer) (*Adapter, *captureHandler) {
|
||||
t.Helper()
|
||||
a := New(
|
||||
Config{BotToken: "123456:TESTTOKEN"},
|
||||
WithBaseURL(s.srv.URL),
|
||||
WithHTTPClient(s.srv.Client()),
|
||||
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(&syncWriter{}, nil)), "plugins/channels/telegram")),
|
||||
)
|
||||
h := &captureHandler{}
|
||||
a.SetMessageHandler(h.fn)
|
||||
return a, h
|
||||
}
|
||||
|
||||
// syncWriter 是并发安全的丢弃型 io.Writer(适配器日志可能在别的 goroutine 里写)。
|
||||
type syncWriter struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (w *syncWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestTelegramImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:默认配置下
|
||||
// 是 `api.telegram.org`,覆盖 `base_url` 后就是那个根 —— 两者都**只有**配置的根,其余一律
|
||||
// 不命中(deny-by-default)。
|
||||
func TestTelegramImageOriginsAreAllowlisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("默认根", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "123456:TESTTOKEN"})
|
||||
if !a.assetAllowlist().Allowed("https://api.telegram.org/file/bot123:ABC/photos/photo_1.jpg") {
|
||||
t.Fatal("默认 API 根没有被白名单覆盖(图片与文件必须共用一份来源名单)")
|
||||
}
|
||||
if a.assetAllowlist().Allowed("https://evil.example.com/photo.jpg") {
|
||||
t.Fatal("非平台来源被白名单放行了")
|
||||
}
|
||||
})
|
||||
t.Run("覆盖根", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTGImageServer(t, "photos/photo_1.png")
|
||||
a, _ := newTGImageAdapter(t, s)
|
||||
if !a.assetAllowlist().Allowed(s.srv.URL + "/file/bot123:ABC/photos/photo_1.jpg") {
|
||||
t.Fatal("覆盖后的 API 根没有被白名单覆盖")
|
||||
}
|
||||
if a.assetAllowlist().Allowed("https://api.telegram.org/file/bot123:ABC/photos/photo_1.jpg") {
|
||||
t.Fatal("覆盖根之后官方根仍在白名单里(名单必须跟着配置走)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTelegramPhotoIsInlinedFromApiRoot 正向:图片的 `file_id` 先经 getFile 换成平台返回的
|
||||
// 路径,再从**配置的根**下载 ⇒ 字节内联、消息照常投递(纯图片消息的 ContentType 是图片)。
|
||||
func TestTelegramPhotoIsInlinedFromApiRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTGImageServer(t, "photos/photo_1.png")
|
||||
a, h := newTGImageAdapter(t, s)
|
||||
|
||||
if _, err := a.ProcessUpdate(context.Background(), tgPhotoUpdate("photo-1", 4096)); err != nil {
|
||||
t.Fatalf("ProcessUpdate: %v", err)
|
||||
}
|
||||
|
||||
msg := h.message(t)
|
||||
if msg.ContentType != adapter.ContentImage || len(msg.Media) != 1 {
|
||||
t.Fatalf("图片消息被归一成了别的形状:%+v", msg)
|
||||
}
|
||||
if !bytes.Equal(msg.Media[0].Data, tgPNG) {
|
||||
t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.fileHits.Load() != 1 || s.getFile.Load() != 1 {
|
||||
t.Fatalf("取回次数 = 下载 %d / getFile %d, want 1/1", s.fileHits.Load(), s.getFile.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelegramHostileFilePathIsNotDownloaded 是**反证**:平台返回的 `file_path` 试图把我们
|
||||
// 引到别处(含 `://`)时,`sanitizeFilePath` 直接否决 ⇒ **一次下载请求都不发出去**。地址
|
||||
// 既然只由「配置的根 + 平台返回的相对路径」拼成,白名单外的来源在本渠道里根本不可能出现。
|
||||
func TestTelegramHostileFilePathIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
label string
|
||||
filePath string
|
||||
}{
|
||||
{"绝对地址", "https://evil.example.com/photo.png"},
|
||||
{"路径穿越", "../../../etc/passwd"},
|
||||
{"查询串", "photos/photo.png?token=SECRET"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTGImageServer(t, tc.filePath)
|
||||
a, h := newTGImageAdapter(t, s)
|
||||
|
||||
if _, err := a.ProcessUpdate(context.Background(), tgPhotoUpdate("photo-1", 4096)); err != nil {
|
||||
t.Fatalf("ProcessUpdate: %v", err)
|
||||
}
|
||||
|
||||
if got := s.fileHits.Load(); got != 0 {
|
||||
t.Fatalf("可疑路径仍发起了 %d 次下载,want 0", got)
|
||||
}
|
||||
msg := h.message(t)
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("可疑路径仍写入了字节:%q", msg.Media[0].Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user