Files

152 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}