152 lines
5.8 KiB
Go
152 lines
5.8 KiB
Go
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())
|
||
}
|
||
}
|