Files
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

236 lines
8.5 KiB
Go

package mediafetch
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// hitServer 是一个「记录命中次数」的假资产服务端:所有断言「0 次命中」的反证用例
// 都靠它的计数器说话(不是靠日志或猜测)。
type hitServer struct {
srv *httptest.Server
hits atomic.Int32
body []byte
delay time.Duration
code int
next string // 非空时回 302 到该地址
}
func newHitServer(t *testing.T, body []byte) *hitServer {
t.Helper()
hs := &hitServer{body: body}
hs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hs.hits.Add(1)
if hs.delay > 0 {
time.Sleep(hs.delay)
}
if hs.next != "" {
http.Redirect(w, r, hs.next, http.StatusFound)
return
}
if hs.code != 0 {
w.WriteHeader(hs.code)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(hs.body)
}))
t.Cleanup(hs.srv.Close)
return hs
}
func (h *hitServer) count() int { return int(h.hits.Load()) }
// TestAllowlistMatchSemantics 钉住白名单的判定口径:deny-by-default、精确来源匹配、
// `*.suffix` 覆盖整棵子树但**不**覆盖裸域名。
func TestAllowlistMatchSemantics(t *testing.T) {
t.Parallel()
cases := []struct {
name string
entries []string
url string
want bool
}{
{"空名单一律拒", nil, "https://cdn.example.com/a.txt", false},
{"精确命中", []string{"https://cdn.example.com"}, "https://cdn.example.com/a.txt", true},
{"主机大小写归一", []string{"https://cdn.example.com"}, "https://CDN.Example.COM/a.txt", true},
{"协议不同即拒", []string{"https://cdn.example.com"}, "http://cdn.example.com/a.txt", false},
{"端口不同即拒", []string{"https://cdn.example.com"}, "https://cdn.example.com:8443/a.txt", false},
{"默认端口等价", []string{"https://cdn.example.com"}, "https://cdn.example.com:443/a.txt", true},
{"通配覆盖子树", []string{"https://*.qq.com"}, "https://multimedia.nt.qq.com.cn/x", false},
{"通配覆盖子域", []string{"https://*.qq.com"}, "https://a.qq.com/x", true},
{"通配覆盖多级子域", []string{"https://*.qq.com"}, "https://a.b.qq.com/x", true},
{"通配不覆盖裸域", []string{"https://*.qq.com"}, "https://qq.com/x", false},
{"畸形条目不匹配任何东西", []string{"cdn.example.com"}, "https://cdn.example.com/a.txt", false},
{"非 http 引用一律拒", []string{"https://cdn.example.com"}, "file:///etc/passwd", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := NewAllowlist(tc.entries...).Allowed(tc.url); got != tc.want {
t.Fatalf("Allowed(%q) = %v, want %v", tc.url, got, tc.want)
}
})
}
}
// TestFetchRejectsOffAllowlistWithoutRequest 是本包最要紧的反证:来源不在白名单里时
// **一个请求都不发出去**(断言服务端 0 次命中)。
func TestFetchRejectsOffAllowlistWithoutRequest(t *testing.T) {
t.Parallel()
hs := newHitServer(t, []byte("secret"))
// 白名单里放的是别的主机 —— 与真实资产主机同网段、同形态,只差来源不匹配。
allow := NewAllowlist("https://assets.example.com")
_, err := Fetch(context.Background(), Request{URL: hs.srv.URL + "/a.txt", Allow: allow, Client: hs.srv.Client()})
if !errors.Is(err, ErrOriginNotAllowed) {
t.Fatalf("err = %v, want ErrOriginNotAllowed", err)
}
if hs.count() != 0 {
t.Fatalf("白名单外的来源被请求了 %d 次,want 0", hs.count())
}
}
// TestFetchReturnsBytes 是正向用例:来源在白名单里 ⇒ 原样取回字节。
func TestFetchReturnsBytes(t *testing.T) {
t.Parallel()
hs := newHitServer(t, []byte("正文内容\n"))
allow := NewAllowlist(originOf(t, hs.srv.URL))
got, err := Fetch(context.Background(), Request{URL: hs.srv.URL + "/a.txt", Allow: allow, Client: hs.srv.Client()})
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if string(got) != "正文内容\n" {
t.Fatalf("bytes = %q", got)
}
if hs.count() != 1 {
t.Fatalf("命中次数 = %d, want 1", hs.count())
}
}
// TestFetchRejectsOversizeWhileReading 钉住「边读边限」:服务端超过上限时放弃,
// 返回 ErrTooLarge(绝不把整份超大响应读满内存)。
func TestFetchRejectsOversizeWhileReading(t *testing.T) {
t.Parallel()
hs := newHitServer(t, []byte(strings.Repeat("x", 4096)))
allow := NewAllowlist(originOf(t, hs.srv.URL))
_, err := Fetch(context.Background(), Request{
URL: hs.srv.URL + "/big.txt", Allow: allow, Client: hs.srv.Client(), MaxBytes: 64,
})
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
}
// TestFetchTimeoutHonorsContext 钉住超时:单份下载有具名超时,超时后放弃并保留
// ctx 哨兵(调用方据此分类,不必解析错误文本)。
func TestFetchTimeoutHonorsContext(t *testing.T) {
t.Parallel()
hs := newHitServer(t, []byte("slow"))
hs.delay = 500 * time.Millisecond
allow := NewAllowlist(originOf(t, hs.srv.URL))
start := time.Now()
_, err := Fetch(context.Background(), Request{
URL: hs.srv.URL + "/slow.txt", Allow: allow, Client: hs.srv.Client(), Timeout: 50 * time.Millisecond,
})
if err == nil {
t.Fatal("err = nil, want timeout")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("取回耗时 %v,超时没有生效", elapsed)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want context.DeadlineExceeded(哨兵链必须保留)", err)
}
}
// TestFetchBadStatus 钉住非 2xx 一律失败(绝不把错误页正文当附件收下)。
func TestFetchBadStatus(t *testing.T) {
t.Parallel()
hs := newHitServer(t, nil)
hs.code = http.StatusNotFound
allow := NewAllowlist(originOf(t, hs.srv.URL))
_, err := Fetch(context.Background(), Request{URL: hs.srv.URL + "/missing.txt", Allow: allow, Client: hs.srv.Client()})
if !errors.Is(err, ErrBadStatus) {
t.Fatalf("err = %v, want ErrBadStatus", err)
}
}
// TestFetchBlocksRedirectOffAllowlist 钉住重定向逐跳校验:302 到白名单外的主机时
// 拒绝,且**目标主机一次都没被请求**。
func TestFetchBlocksRedirectOffAllowlist(t *testing.T) {
t.Parallel()
outside := newHitServer(t, []byte("evil"))
inside := newHitServer(t, []byte("ok"))
inside.next = outside.srv.URL + "/evil.txt"
allow := NewAllowlist(originOf(t, inside.srv.URL))
_, err := Fetch(context.Background(), Request{
URL: inside.srv.URL + "/a.txt", Allow: allow, Client: inside.srv.Client(),
})
if !errors.Is(err, ErrOriginNotAllowed) {
t.Fatalf("err = %v, want ErrOriginNotAllowed(重定向跨到白名单外)", err)
}
if outside.count() != 0 {
t.Fatalf("白名单外的重定向目标被请求了 %d 次,want 0", outside.count())
}
}
// TestFetchErrorNeverCarriesFullURL 钉住「错误可安全进日志」:平台资产地址可能带签名
// 凭据(如 CDN 的 rkey),错误串里**绝不能**出现它。
func TestFetchErrorNeverCarriesFullURL(t *testing.T) {
t.Parallel()
// 127.0.0.1:1 上不会有人监听;白名单放行它,于是请求真的会发出去但连不上。
const secret = "rkey=SUPER_SECRET_VALUE"
allow := NewAllowlist("http://127.0.0.1:1")
_, err := Fetch(context.Background(), Request{
URL: "http://127.0.0.1:1/download?" + secret, Allow: allow, Timeout: 200 * time.Millisecond,
})
if err == nil {
t.Fatal("err = nil, want dial failure")
}
if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "/download") {
t.Fatalf("错误串里出现了 URL 或凭据:%v", err)
}
if !strings.Contains(err.Error(), "http://127.0.0.1:1") {
t.Fatalf("错误串里应保留来源(便于定位是哪个平台主机):%v", err)
}
}
// TestFetchRejectsEmptyAndNonHTTP 钉住输入侧的基本拒绝面。
func TestFetchRejectsEmptyAndNonHTTP(t *testing.T) {
t.Parallel()
allow := NewAllowlist("https://cdn.example.com")
if _, err := Fetch(context.Background(), Request{URL: " ", Allow: allow}); !errors.Is(err, ErrEmptyURL) {
t.Fatalf("空引用 err = %v, want ErrEmptyURL", err)
}
if _, err := Fetch(context.Background(), Request{URL: "ftp://cdn.example.com/a", Allow: allow}); !errors.Is(err, ErrBadScheme) {
t.Fatalf("非 http err = %v, want ErrBadScheme", err)
}
// HostOf 只回来源,绝不回完整 URL。
if got := HostOf("https://cdn.example.com/a?token=SECRET"); strings.Contains(got, "SECRET") {
t.Fatalf("HostOf 泄露了查询串:%q", got)
}
}
// originOf 返回测试服务端的来源串。
func originOf(t *testing.T, rawURL string) string {
t.Helper()
origin, err := OriginOf(rawURL)
if err != nil {
t.Fatalf("OriginOf(%q): %v", rawURL, err)
}
return origin
}