feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)

This commit is contained in:
AskaEth committed 2026-10-05 11:22:26 +08:00
1 parent 0cbc9ff886
commit b73a1276c6
18 files changed
+1224 -121

No files matched your search

+37 -20
View File
@@ -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())
}
}