Files
MeBox/internal/service/image_proxy_dial_allowlist_test.go
T

113 lines
3.9 KiB
Go
Raw 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 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)
}
}