mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
250 lines
8.0 KiB
Go
250 lines
8.0 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"testing"
|
||
|
||
"github.com/glebarez/sqlite"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
)
|
||
|
||
// 现场(2026-10-16):13 条媒体记录的 poster_url 指向
|
||
// http://mebox-metatube-1:8081/v1/images/primary/... ,而图片代理只放行
|
||
// 远程 Emby 挂载主机。MetaTube 是用户显式配置的刮削后端(settings 里的
|
||
// adult.scraper.metatube_server),它跑在 docker 私网里,于是每一次封面
|
||
// 回源都被连接层 SSRF 校验拒掉,日志刷 warn,前端长期只拿到占位图。
|
||
//
|
||
// 结论:显式配置的服务地址必须和远程 Emby 一样被加入放行名单。
|
||
func TestConfiguredServiceImageHostsExtractsHostnames(t *testing.T) {
|
||
got := configuredServiceImageHosts(
|
||
"http://mebox-metatube-1:8081",
|
||
"https://api.themoviedb.org/3",
|
||
"http://192.168.1.50:8096/emby",
|
||
)
|
||
want := []string{"192.168.1.50", "api.themoviedb.org", "mebox-metatube-1"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("hosts = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Fatalf("hosts = %v, want %v", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestConfiguredServiceImageHostsNormalizesAndDedupes(t *testing.T) {
|
||
got := configuredServiceImageHosts(
|
||
" HTTP://MeBox-MetaTube-1:8081 ",
|
||
"http://mebox-metatube-1:8081/other/path",
|
||
"mebox-metatube-1:8081",
|
||
)
|
||
if len(got) != 1 || got[0] != "mebox-metatube-1" {
|
||
t.Fatalf("hosts = %v, want exactly [mebox-metatube-1]", got)
|
||
}
|
||
}
|
||
|
||
func TestConfiguredServiceImageHostsSkipsUnusableValues(t *testing.T) {
|
||
got := configuredServiceImageHosts(
|
||
"",
|
||
" ",
|
||
"not a url at all",
|
||
"http://",
|
||
"://missing-scheme",
|
||
"/relative/path",
|
||
"ftp://files.example.com",
|
||
)
|
||
if len(got) != 0 {
|
||
t.Fatalf("hosts = %v, want none", got)
|
||
}
|
||
}
|
||
|
||
func TestConfiguredServiceImageHostsPreservesIPv6(t *testing.T) {
|
||
got := configuredServiceImageHosts("http://[fd00::5]:8081")
|
||
if len(got) != 1 || got[0] != "fd00::5" {
|
||
t.Fatalf("hosts = %v, want [fd00::5]", got)
|
||
}
|
||
}
|
||
|
||
func TestImageProxyAllowlistCombinesMountsAndConfiguredServices(t *testing.T) {
|
||
// 回归:合并后的名单必须同时覆盖远程 Emby 挂载与显式配置的服务,
|
||
// 否则两组图片里必有一组被 SSRF 校验挡住。
|
||
proxy := newUpstreamImageProxy(t)
|
||
proxy.SetAllowedRemoteHostsProvider(func() []string {
|
||
return mergeImageProxyAllowedHosts(
|
||
[]string{"192.168.1.50"},
|
||
configuredServiceImageHosts("http://mebox-metatube-1:8081"),
|
||
)
|
||
})
|
||
|
||
for _, host := range []string{"192.168.1.50", "mebox-metatube-1"} {
|
||
if !proxy.isAllowedRemoteHost(host) {
|
||
t.Fatalf("host %q must bypass the private-IP guard", host)
|
||
}
|
||
}
|
||
if proxy.isAllowedRemoteHost("evil.example.com") {
|
||
t.Fatal("unrelated hosts must stay blocked")
|
||
}
|
||
}
|
||
|
||
func TestMergeImageProxyAllowedHostsDedupesCaseInsensitively(t *testing.T) {
|
||
got := mergeImageProxyAllowedHosts(
|
||
[]string{"192.168.1.50:8096", "MeBox-MetaTube-1"},
|
||
[]string{"mebox-metatube-1", "", " ", "192.168.1.50"},
|
||
)
|
||
sort.Strings(got)
|
||
want := []string{"192.168.1.50", "mebox-metatube-1"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("merged = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Fatalf("merged = %v, want %v", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 公网上游必须留在名单之外:放行后图片代理会优先走直连,而 GFW 环境恰恰要靠
|
||
// HTTP(S)_PROXY 才能取到 image.tmdb.org 这类海报。只有内网地址才需要豁免。
|
||
func TestInternalServiceImageHostsKeepsOnlyInternalAddresses(t *testing.T) {
|
||
got := internalServiceImageHosts(
|
||
"http://mebox-metatube-1:8081",
|
||
"http://127.0.0.1:7700",
|
||
"http://192.168.1.50:8096",
|
||
"https://api.themoviedb.org/3",
|
||
"https://image.tmdb.org",
|
||
"https://api.bgm.tv",
|
||
"https://javdb.com",
|
||
"http://nas.local:7700",
|
||
"http://media.internal",
|
||
)
|
||
want := []string{
|
||
"127.0.0.1",
|
||
"192.168.1.50",
|
||
"mebox-metatube-1",
|
||
"media.internal",
|
||
"nas.local",
|
||
}
|
||
sort.Strings(want)
|
||
if len(got) != len(want) {
|
||
t.Fatalf("hosts = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Fatalf("hosts = %v, want %v", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestIsInternalServiceHost(t *testing.T) {
|
||
internal := []string{
|
||
"mebox-metatube-1", "127.0.0.1", "10.1.2.3", "172.18.0.5",
|
||
"192.168.1.50", "fd00::5", "nas.local", "media.internal",
|
||
"box.lan", "db.home.arpa",
|
||
}
|
||
for _, host := range internal {
|
||
if !isInternalServiceHost(host) {
|
||
t.Errorf("isInternalServiceHost(%q) = false, want true", host)
|
||
}
|
||
}
|
||
external := []string{
|
||
"", "api.themoviedb.org", "image.tmdb.org", "www.javbus.com",
|
||
"pics.dmm.co.jp", "8.8.8.8", "example.com",
|
||
}
|
||
for _, host := range external {
|
||
if isInternalServiceHost(host) {
|
||
t.Errorf("isInternalServiceHost(%q) = true, want false", host)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 端到端装配:从真实的 settings / api_configs 里读出内网服务地址。
|
||
// 现场数据是 settings.adult.scraper.metatube_server = http://mebox-metatube-1:8081,
|
||
// 而 api_configs.metatube.base_url 还是出厂默认的 http://127.0.0.1:7700。
|
||
func TestConfiguredServiceImageHostsFromRepoReadsSettingsAndAPIConfigs(t *testing.T) {
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("failed to open sqlite: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
ctx := context.Background()
|
||
|
||
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", "http://mebox-metatube-1:8081"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Setting.Set(ctx, "adult.scraper.builtin_javdb_url", "https://javdb.com"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, row := range []model.APIConfig{
|
||
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Enabled: true},
|
||
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
|
||
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
|
||
} {
|
||
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
got := configuredServiceImageHostsFromRepo(ctx, repos)
|
||
// javdb.com / api.themoviedb.org 是公网地址,必须留在名单之外(否则图片
|
||
// 代理会改走直连,GFW 环境下反而取不到图);openai 的 base_url 虽然指向
|
||
// 内网,但它从不产出图片 URL,同样不应成为 SSRF 例外。
|
||
want := []string{"127.0.0.1", "mebox-metatube-1"}
|
||
sort.Strings(want)
|
||
if len(got) != len(want) {
|
||
t.Fatalf("hosts = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Fatalf("hosts = %v, want %v", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 放行名单是 SSRF 例外,必须收窄:只有「会产出图片 URL」的配置才入选。
|
||
func TestConfiguredServiceImageHostsFromRepoIgnoresUnrelatedInternalServices(t *testing.T) {
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("failed to open sqlite: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
ctx := context.Background()
|
||
|
||
// 只配置了内网的 OpenAI 兼容网关与内网下载器,没有任何图片来源。
|
||
if err := repos.Setting.Set(ctx, "app.server_url", "http://10.0.0.2:1314"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Setting.Set(ctx, "strm.base_url", "http://10.0.0.2:1314"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, row := range []model.APIConfig{
|
||
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
|
||
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
|
||
} {
|
||
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
if got := configuredServiceImageHostsFromRepo(ctx, repos); len(got) != 0 {
|
||
t.Fatalf("hosts = %v, want none: unrelated internal services must not become SSRF exceptions", got)
|
||
}
|
||
}
|
||
|
||
func TestConfiguredServiceImageHostsFromRepoToleratesMissingRepos(t *testing.T) {
|
||
if got := configuredServiceImageHostsFromRepo(context.Background(), nil); len(got) != 0 {
|
||
t.Fatalf("hosts = %v, want none", got)
|
||
}
|
||
}
|
||
|
||
|