- 新增 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 超量用例)
236 lines
8.5 KiB
Go
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
|
|
}
|