mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
113 lines
3.9 KiB
Go
113 lines
3.9 KiB
Go
package service
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
)
|
||
|
||
// hostAndPortOf 从测试服务器的 URL 里取出主机名与端口。
|
||
func hostAndPortOf(t *testing.T, raw string) (string, string) {
|
||
t.Helper()
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
t.Fatalf("parse upstream url: %v", err)
|
||
}
|
||
return u.Hostname(), u.Port()
|
||
}
|
||
|
||
// 现场(2026-10-16,生产容器内实测):放行名单里明明有 mebox-metatube-1,
|
||
// 回源仍然失败于
|
||
//
|
||
// image proxy: requests to private/internal hosts are not allowed
|
||
//
|
||
// 原因:连接层校验(dialer 的 Control 回调)拿到的 address 是**解析后的 IP**,
|
||
// 而名单里存的是主机名(ConfiguredRemoteHosts 与
|
||
// configuredServiceImageHostsFromRepo 都返回 u.Hostname()),两者永远不相等:
|
||
// 那段 isAllowedRemoteHost 分支对主机名条目是死代码。
|
||
//
|
||
// 远程 Emby 挂载用 IP 配置时看不出问题,用主机名(NAS 名、docker 服务名)配置
|
||
// 时同样中招。这个用例锁住「按主机名放行」这条路径。
|
||
func TestImageProxyDialsAllowlistedHostname(t *testing.T) {
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "image/jpeg")
|
||
_, _ = w.Write(testJPEG)
|
||
}))
|
||
defer upstream.Close()
|
||
|
||
proxy := NewImageProxy(
|
||
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
|
||
zap.NewNop(),
|
||
)
|
||
// 名单里放「主机名」而不是 IP —— 这正是生产里 mebox-metatube-1 的形态。
|
||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{"localhost"} })
|
||
|
||
// 直接用该代理自己的 transport 拨号,才能命中连接层校验。
|
||
_, port := hostAndPortOf(t, upstream.URL)
|
||
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
|
||
if err != nil {
|
||
t.Fatalf("allowlisted hostname must be dialable, got: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
// 反向约束:未放行的主机名解析到私网地址时仍必须被拒。
|
||
// 这是连接层校验存在的理由(DNS rebinding),不能为了修上面那条而拆掉。
|
||
func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T) {
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write(testJPEG)
|
||
}))
|
||
defer upstream.Close()
|
||
|
||
proxy := NewImageProxy(
|
||
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
|
||
zap.NewNop(),
|
||
)
|
||
// 名单为空:localhost 解析到回环地址,必须被拦。
|
||
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
|
||
|
||
_, port := hostAndPortOf(t, upstream.URL)
|
||
for _, client := range []*http.Client{proxy.client, proxy.directClient} {
|
||
resp, err := client.Get("http://localhost:" + port + "/poster.jpg")
|
||
if err == nil {
|
||
resp.Body.Close()
|
||
t.Fatal("a non-allowlisted hostname resolving to a private IP must be refused")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestImageProxyAllowsSelectedLocalProxy(t *testing.T) {
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "image/jpeg")
|
||
_, _ = w.Write(testJPEG)
|
||
}))
|
||
defer upstream.Close()
|
||
proxyURL, err := url.Parse(upstream.URL)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p := NewImageProxy(&config.Config{}, zap.NewNop())
|
||
transport := NewExternalTransport()
|
||
transport.Proxy = http.ProxyURL(proxyURL)
|
||
p.guardTransport(transport)
|
||
t.Cleanup(transport.CloseIdleConnections)
|
||
client := &http.Client{Transport: transport}
|
||
resp, err := client.Get("http://images.example.com/poster.jpg")
|
||
if err != nil {
|
||
t.Fatalf("configured local proxy must remain usable: %v", err)
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status=%d, want 200", resp.StatusCode)
|
||
}
|
||
}
|