diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 4f04cb1..5307b44 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -111,17 +111,20 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { // 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到 // 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次 // 校验(含重定向后的每条连接)堵住该旁路。 - // 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。 - dialer := &net.Dialer{ + // + // 用户明确配置的远程挂载源(如内网 Emby)与显式配置的服务(如 + // MetaTube)豁免该私网限制。豁免必须在 DialContext 这一层判断: + // Control 回调收到的 address 已经是**解析后的 IP**,而放行名单里 + // 存的是主机名(mebox-metatube-1、NAS 名),在 Control 里比较永远 + // 不相等——那段分支对主机名条目是死代码,现场表现就是「名单里有 + // 这个主机,回源依然被拒」。 + guardDialer := &net.Dialer{ Timeout: 15 * time.Second, Control: func(_, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return err } - if proxy.isAllowedRemoteHost(host) { - return nil - } ip := net.ParseIP(host) if ip == nil { return errors.New("image proxy: refusing non-IP dial target") @@ -132,7 +135,19 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { return nil }, } - transport.DialContext = dialer.DialContext + // 放行目标的专用拨号器:不再做私网校验,但仍然只连到请求里的那个 + // 主机(豁免的是私网限制,不是「任意目标」)。 + allowlistedDialer := &net.Dialer{Timeout: 15 * time.Second} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if proxy.isAllowedRemoteHost(host) { + return allowlistedDialer.DialContext(ctx, network, address) + } + return guardDialer.DialContext(ctx, network, address) + } } proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport} diff --git a/internal/service/image_proxy_allowed_hosts.go b/internal/service/image_proxy_allowed_hosts.go new file mode 100644 index 0000000..46e3b18 --- /dev/null +++ b/internal/service/image_proxy_allowed_hosts.go @@ -0,0 +1,193 @@ +package service + +import ( + "context" + "net" + "net/url" + "sort" + "strings" + + "github.com/truewhile/MeBox/internal/repository" +) + +// 显式配置的服务地址也要进入图片代理的私网放行名单。 +// +// 背景(现场 2026-10-16):MetaTube 刮削后端被配置成 docker 服务名 +// `http://mebox-metatube-1:8081`,刮削结果里的 poster_url 就是这个地址。 +// 图片代理的连接层 SSRF 校验只放行远程 Emby 挂载主机,于是每一次封面回源都 +// 被拒(日志 warn: requests to private/internal hosts are not allowed), +// 前端长期只拿到占位图。 +// +// 这里的取舍与 isPrivateHost 的注释一致:私网校验防的是「用户可控 URL 打到 +// 内网」,而管理员显式配置的服务地址属于受信输入,和远程 Emby 挂载同级。 +func configuredServiceImageHosts(values ...string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, raw := range values { + host := imageHostFromBaseURL(raw) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + out = append(out, host) + } + sort.Strings(out) + return out +} + +// internalServiceImageHosts 只保留指向内网/局域网的服务地址。 +// +// 公网域名(image.tmdb.org、api.themoviedb.org 等)必须留在名单之外:放行后 +// 图片代理会优先走直连客户端,而 GFW 环境恰恰需要先用 HTTP(S)_PROXY。把它们 +// 加进来会让每张海报都白付一次直连失败。 +func internalServiceImageHosts(values ...string) []string { + hosts := configuredServiceImageHosts(values...) + out := make([]string, 0, len(hosts)) + for _, host := range hosts { + if isInternalServiceHost(host) { + out = append(out, host) + } + } + return out +} + +// isInternalServiceHost 判断主机名是否只在内网可达:私网/回环/链路本地 IP、 +// 单标签服务名(docker compose 服务名如 mebox-metatube-1),或常见的内部域名后缀。 +func isInternalServiceHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return false + } + if ip := net.ParseIP(host); ip != nil { + return isPrivateIP(ip) + } + // 单标签名(不含点)不是公网可解析的 FQDN,只可能是容器名/主机名。 + if !strings.Contains(host, ".") { + return true + } + for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa", ".localhost"} { + if strings.HasSuffix(host, suffix) { + return true + } + } + return false +} + +// imageHostFromBaseURL 从配置里的 base URL 取出小写主机名(不含端口)。 +// 容忍缺少 scheme 的写法(`mebox-metatube-1:8081`),并拒绝非 http/https。 +func imageHostFromBaseURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + default: + return "" + } + return strings.ToLower(strings.TrimSpace(u.Hostname())) +} + +// mergeImageProxyAllowedHosts 合并多组放行主机(远程 Emby 挂载 + 显式配置的 +// 服务地址),去重时忽略大小写与端口。 +func mergeImageProxyAllowedHosts(groups ...[]string) []string { + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, group := range groups { + for _, raw := range group { + host := strings.ToLower(strings.TrimSpace(raw)) + if host == "" { + continue + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = strings.ToLower(strings.TrimSpace(h)) + } + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + out = append(out, host) + } + } + sort.Strings(out) + return out +} + +// imageMintingSettingKeys 是「其取值会被拼进图片 URL」的设置项。 +// +// 只收录这类设置:放行名单每多一条就是多一处 SSRF 例外,因此 app.server_url、 +// strm.base_url 这些只出现在播放地址里的设置不在此列(它们由 streamURL 使用, +// 与 /api/img 无关)。 +var imageMintingSettingKeys = []string{ + "adult.scraper.metatube_server", + "adult.scraper.builtin_javdb_url", + "adult.scraper.builtin_javbus_url", +} + +// imageMintingAPIProviders 是「其 base_url 会被拼进图片 URL」的 api_configs 行。 +// +// 同样刻意收窄:api_configs 里每个 provider 都有一行出厂默认值,其中 metatube +// 默认指向 http://127.0.0.1:7700。把整张表都放行会让每次安装都无条件放开回环 +// 地址,而只有真正用 MetaTube 刮削的部署才需要这个例外。 +var imageMintingAPIProviders = []string{"metatube"} + +// configuredServiceImageHostsFromRepo 从持久化配置里收集「会被写进图片 URL」的 +// 内网服务地址。只在构造阶段调用一次,所以直接读库、忽略错误——读不到只是少 +// 一条放行,不影响启动。 +func configuredServiceImageHostsFromRepo(ctx context.Context, repos *repository.Container) []string { + if repos == nil { + return nil + } + values := make([]string, 0, 4) + + if repos.Setting != nil { + for _, key := range imageMintingSettingKeys { + if val, err := repos.Setting.Get(ctx, key); err == nil && strings.TrimSpace(val) != "" { + values = append(values, val) + } + } + } + + if repos.ApiConfig != nil { + // 用 List 而不是 FindByProvider:后者走 First(),缺行时 gorm 会打一条 + // "record not found" 日志,而「没配置 MetaTube」是完全正常的状态。 + if rows, err := repos.ApiConfig.List(ctx); err == nil { + for _, row := range rows { + if !imageMintingAPIProvider(row.Provider) { + continue + } + if strings.TrimSpace(row.BaseURL) != "" { + values = append(values, row.BaseURL) + } + // Extra 是逗号/分号分隔的备用源列表(见 api_config.go 的 adult 默认值)。 + values = append(values, adultConfiguredBases(row.Extra)...) + } + } + } + + return internalServiceImageHosts(values...) +} + +func imageMintingAPIProvider(provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + for _, allowed := range imageMintingAPIProviders { + if provider == allowed { + return true + } + } + return false +} + diff --git a/internal/service/image_proxy_allowed_hosts_test.go b/internal/service/image_proxy_allowed_hosts_test.go new file mode 100644 index 0000000..018aa84 --- /dev/null +++ b/internal/service/image_proxy_allowed_hosts_test.go @@ -0,0 +1,249 @@ +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) + } +} + + diff --git a/internal/service/image_proxy_dial_allowlist_test.go b/internal/service/image_proxy_dial_allowlist_test.go new file mode 100644 index 0000000..10310ea --- /dev/null +++ b/internal/service/image_proxy_dial_allowlist_test.go @@ -0,0 +1,84 @@ +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) + resp, err := proxy.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") + } +} diff --git a/internal/service/image_proxy_live_verify_test.go b/internal/service/image_proxy_live_verify_test.go new file mode 100644 index 0000000..6f63cbf --- /dev/null +++ b/internal/service/image_proxy_live_verify_test.go @@ -0,0 +1,152 @@ +// 在真实 docker 网络里验证图片代理的私网放行名单修复。 +// +// 现场(修复前):MetaTube 刮削结果里的 poster_url 指向 +// http://mebox-metatube-1:8081/... ,而放行名单只含远程 Emby 挂载主机, +// 于是每一次封面回源都被连接层 SSRF 校验拒掉,前端长期只拿到占位图。 +// +// 本文件用与 service_builder.initImageProxy 相同的装配方式复现那条路径。 +// 真实回源那一步只在能解析到 MetaTube 服务名的环境里跑(生产容器网络); +// 开发机上会 Skip,因为那属于环境缺失,不是回归。 +// +// 在生产机上运行的姿势(无需 Go 工具链): +// +// GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/verify.test ./internal/service +// scp /tmp/verify.test root@HOST:/tmp/ && ssh root@HOST \ +// 'docker cp /tmp/verify.test mebox:/tmp/ && docker exec mebox /tmp/verify.test \ +// -test.run TestImageProxyFetchesFromMetaTubeService -test.v' +package service + +import ( + "context" + "errors" + "net" + "path/filepath" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// 与生产环境一致的两个取值(取自 10.0.0.2 上真实的 settings / api_configs)。 +const ( + metaTubeServer = "http://mebox-metatube-1:8081" + metaTubeLegacy = "http://127.0.0.1:7700" + sampleImageURL = "http://mebox-metatube-1:8081/v1/images/primary/JAV321/dsam-15" +) + +// newMetaTubeVerificationRepos 建一个带现场取值的临时库(不碰生产数据)。 +func newMetaTubeVerificationRepos(t *testing.T) (*repository.Container, *ImageProxy) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("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", metaTubeServer); err != nil { + t.Fatalf("seed setting: %v", err) + } + if err := repos.ApiConfig.Create(ctx, &model.APIConfig{ + Provider: "metatube", BaseURL: metaTubeLegacy, Enabled: true, + }); err != nil { + t.Fatalf("seed metatube api config: %v", err) + } + // 一个公网上游,用来确认它没有混进放行名单。 + if err := repos.ApiConfig.Create(ctx, &model.APIConfig{ + Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true, + }); err != nil { + t.Fatalf("seed tmdb api config: %v", err) + } + + cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}} + cfg.Secrets.JWTSecret = "verify-secret" + proxy := NewImageProxy(cfg, zap.NewNop()) + // 与 service_builder.initImageProxy 相同的装配方式(无远程 Emby 挂载: + // 本次只验证「显式配置的服务地址」这条路径)。 + proxy.SetAllowedRemoteHostsProvider(func() []string { + return mergeImageProxyAllowedHosts( + nil, + configuredServiceImageHostsFromRepo(ctx, repos), + ) + }) + return repos, proxy +} + +// TestImageProxyAllowlistIncludesConfiguredMetaTube 确认内网服务地址被放行、 +// 公网上游没有被放行。 +func TestImageProxyAllowlistIncludesConfiguredMetaTube(t *testing.T) { + repos, proxy := newMetaTubeVerificationRepos(t) + t.Logf("allowlist from config: %v", + configuredServiceImageHostsFromRepo(context.Background(), repos)) + + for _, host := range []string{"mebox-metatube-1", "127.0.0.1"} { + if !proxy.isAllowedRemoteHost(host) { + t.Fatalf("host %q must be allowlisted (it is an explicitly configured service)", host) + } + } + if proxy.isAllowedRemoteHost("api.themoviedb.org") { + t.Fatal("public upstreams must stay off the allowlist so image fetches keep using the proxy") + } +} + +// TestImageProxyReachesMetaTubeService 证明放行名单真的解除了私网拦截。 +// +// 现场(修复前)回源必然失败于 +// +// dial tcp 172.18.0.5:8081: image proxy: requests to private/internal hosts are not allowed +// +// 也就是连接根本没发生。修复后连接成功,失败只可能来自上游自己的 HTTP 状态 +// (MetaTube 冷缓存时对未刮削过的番号返回 500)。 +// +// 因此这个用例断言的不是「拿到 200」,而是**「不再是被私网校验拒绝」**: +// 后者才是本次修复的对象,前者取决于 MetaTube 缓存里有没有那条记录。 +func TestImageProxyReachesMetaTubeService(t *testing.T) { + _, proxy := newMetaTubeVerificationRepos(t) + + data, ctype, err := proxy.Fetch(context.Background(), sampleImageURL) + if err != nil { + if isMetaTubeUnreachable(err) { + t.Skipf("mebox-metatube-1 is not reachable from this environment: %v", err) + } + if isPrivateHostRejection(err) { + t.Fatalf("the allowlist did not take effect: %v", err) + } + // 连接已经建立,只是上游自己返回了错误状态(MetaTube 冷缓存)。 + t.Logf("reached metaTube; upstream returned its own error: %v", err) + return + } + t.Logf("fetched %d bytes (content-type=%s)", len(data), ctype) + if len(data) == 0 { + t.Fatal("fetched an empty image body") + } +} + +// isPrivateHostRejection 报告错误是否来自连接层的私网校验。 +// 这正是本次修复必须消除的那一类失败。 +func isPrivateHostRejection(err error) bool { + return err != nil && strings.Contains(err.Error(), "requests to private/internal hosts are not allowed") +} + +// isMetaTubeUnreachable 区分「环境里没有这个服务」与「放行名单没生效」。 +func isMetaTubeUnreachable(err error) bool { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + msg := strings.ToLower(err.Error()) + for _, needle := range []string{"no such host", "server misbehaving", "i/o timeout", "connection refused"} { + if strings.Contains(msg, needle) { + return true + } + } + return false +} diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 498bb3d..f2c9b43 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -253,10 +253,21 @@ func (b *serviceContainerBuilder) initIdentityServices() { func (b *serviceContainerBuilder) initImageProxy() { b.c.ImageProxy = NewImageProxy(b.cfg, b.log) b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots) + // 放行名单 = 远程 Emby 挂载主机 + 用户显式配置的内网服务地址。 + // + // 只放行挂载主机是不够的:MetaTube 这类刮削后端会把「容器服务名」写进 + // poster_url(现场:http://mebox-metatube-1:8081),连接层 SSRF 校验会把 + // 每一次封面回源都拒掉,前端长期只拿到占位图。名单本身有 30s 缓存, + // 所以管理员改完设置无需重启即可生效。 + b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string { + var mounts []string + if b.c.EmbyRemote != nil { + mounts = b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background()) + } + services := configuredServiceImageHostsFromRepo(context.Background(), b.repos) + return mergeImageProxyAllowedHosts(mounts, services) + }) if b.c.EmbyRemote != nil { - b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string { - return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background()) - }) // 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后 // URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时 // 重新登录一次再重试,封面图不再依赖人工改账号。 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4249d81..4a0912b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,7 +2,7 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { useAuthStore } from '../stores/auth' import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile' -import type { RefreshOutcome } from '../utils/authRefresh' +import { createTokenPreflight, type RefreshOutcome } from '../utils/authRefresh' // Single axios instance used by every API helper. Adds the JWT to outgoing // requests and refreshes it on 401. Only an explicit server-side rejection of @@ -48,8 +48,27 @@ function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean { return Boolean(config?.url?.includes('/auth/refresh')) } +// 请求发出前的令牌预检。本地持久化的 token 往往已经过期,而首屏有多个各自 +// 独立的 useEffect 会并发发请求;不预检的话它们会一起拿到 401,控制台刷出 +// 一整屏红字(现场:2026-10-16 12:49,7 个请求同时 401),用户还要等拦截器 +// 刷新完才看到内容。预检把刷新提到请求之前,且刷新协调器本身是单飞的。 +const preflightAccessToken = createTokenPreflight({ + readAccessToken: () => useAuthStore.getState().token, + refreshSession: () => useAuthStore.getState().refreshSession(), +}) + // Add auth token to requests -api.interceptors.request.use((config) => { +api.interceptors.request.use(async (config) => { + // 刷新请求自己绝不能走预检,否则会递归刷新。 + if (!isRefreshRequest(config)) { + try { + await preflightAccessToken() + } catch { + // 会话已被服务端判定失效:直接失败,不发无意义的 401 请求。 + // 清空会话与跳转登录页由刷新协调器和 401 拦截器负责,保持一致。 + throw new axios.Cancel('session expired') + } + } const token = useAuthStore.getState().token if (token) { config.headers = config.headers ?? {} diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index 27855ac..c9e202d 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -1,38 +1,13 @@ import { useEffect, useRef } from 'react' import { useAuthStore } from '../stores/auth' +import { isAccessTokenStale } from '../utils/authRefresh' // 前 5 次沿用原快速退避间隔;之后进入 60s 慢速重试并不再放弃, // 服务重启或网络恢复后仍能自动重连(清理函数可随时取消定时器)。 const FAST_RECONNECT_ATTEMPTS = 5 const SLOW_RECONNECT_INTERVAL = 60_000 -// token 过期前 30 秒即视为失效,给刷新留出余量。 -const TOKEN_EXPIRY_SKEW_MS = 30_000 - -// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。 -// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。 -function decodeJwtExpiryMs(token: string): number | null { - const segment = token.split('.')[1] - if (!segment) return null - try { - // JWT 使用 base64url 编码,需先还原字符集并补齐 padding。 - const normalized = segment.replace(/-/g, '+').replace(/_/g, '/') - const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') - const claims = JSON.parse(atob(padded)) as { exp?: unknown } - return typeof claims.exp === 'number' ? claims.exp * 1000 : null - } catch { - return null - } -} - -// isTokenExpired 判断 token 是否已过期或即将过期。 -function isTokenExpired(token: string): boolean { - const expiry = decodeJwtExpiryMs(token) - if (expiry === null) return false - return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS -} - // useWebSocket opens a single connection to /api/ws and dispatches every // message to the supplied handler. Auto-reconnects with back-off while the // auth token is present; after the fast retries are exhausted it keeps a @@ -68,7 +43,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) const open = () => { if (closed) return - if (isTokenExpired(token)) { + if (isAccessTokenStale(token)) { refreshExpiredToken() return } @@ -92,7 +67,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) ws.onclose = () => { if (closed) return // token 过期时不要继续退避重试,交给刷新流程处理。 - if (isTokenExpired(token)) { + if (isAccessTokenStale(token)) { refreshExpiredToken() return } diff --git a/web/src/pages/reader/bookshelfModel.test.ts b/web/src/pages/reader/bookshelfModel.test.ts index 1688e58..c584447 100644 --- a/web/src/pages/reader/bookshelfModel.test.ts +++ b/web/src/pages/reader/bookshelfModel.test.ts @@ -1,5 +1,5 @@ -import type { ReaderBook } from '../../api/reader' -import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel' +import type { ReaderBook } from '../../api/reader.ts' +import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel.ts' function check(name: string, condition: boolean) { if (!condition) throw new Error(`bookshelfModel: ${name}`) diff --git a/web/src/utils/authRefresh.test.ts b/web/src/utils/authRefresh.test.ts index 2b1e473..2812171 100644 --- a/web/src/utils/authRefresh.test.ts +++ b/web/src/utils/authRefresh.test.ts @@ -5,6 +5,9 @@ import { RefreshRequestError, createRefreshCoordinator, createSingleFlightRunner, + createTokenPreflight, + decodeJwtExpiryMs, + isAccessTokenStale, refreshFailureOutcome, type RefreshOutcome, type RefreshTokens, @@ -30,6 +33,149 @@ check('timeout is transient', refreshFailureOutcome(408) === 'transient') check('429 is transient', refreshFailureOutcome(429) === 'transient') check('unexpected status is transient', refreshFailureOutcome(418) === 'transient') +// ── JWT 过期判定:首屏放行前必须能识别出「已经过期的 access token」 ──────── +// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出 +// 7 个受保护请求,全部 401,控制台一次性刷出 7 行红字;约 2 秒后才由拦截器 +// 刷新成功并重放。修复要求首屏就认得过期 token,先刷新再放行。 +function jwtWithExp(exp: number | string | null): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url') + const claims: Record = { uid: 'u1' } + if (exp !== null) claims.exp = exp + return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature` +} + +const NOW = 1_700_000_000_000 + +check('jwt expiry is decoded to milliseconds', + decodeJwtExpiryMs(jwtWithExp(NOW / 1000 + 3600)) === NOW + 3600_000) +check('jwt without exp decodes to null', decodeJwtExpiryMs(jwtWithExp(null)) === null) +check('jwt with non-numeric exp decodes to null', decodeJwtExpiryMs(jwtWithExp('soon')) === null) +check('opaque token decodes to null', decodeJwtExpiryMs('not-a-jwt') === null) +check('empty token decodes to null', decodeJwtExpiryMs('') === null) +check('malformed payload decodes to null', decodeJwtExpiryMs('aaa.!!!.ccc') === null) + +// 已过期 / 即将过期的 token 必须在首屏就被拦下。 +check('expired token is stale', isAccessTokenStale(jwtWithExp(NOW / 1000 - 1), NOW)) +check('token expiring inside the skew is stale', + isAccessTokenStale(jwtWithExp(NOW / 1000 + 20), NOW)) +check('token outside the skew is fresh', + isAccessTokenStale(jwtWithExp(NOW / 1000 + 3600), NOW) === false) +// 边界:正好落在 skew 上算过期(留出余量,避免刷新途中再次过期)。 +check('token exactly at the skew boundary is stale', + isAccessTokenStale(jwtWithExp(NOW / 1000 + 30), NOW)) + +// 没有 token 或 token 无法解析时不能判定为「过期」: +// 前者由路由守卫处理,后者强行刷新会变成无休止的刷新循环。 +check('missing token is not stale', isAccessTokenStale(null, NOW) === false) +check('opaque token is not stale', isAccessTokenStale('opaque', NOW) === false) + +// ── 发送前预检:本地 token 已过期就先刷新,再让请求带新令牌出去 ──────────── +// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出 +// 7 个受保护请求,全部 401,控制台一次刷出 7 行红字;约 2 秒后才由 401 拦截器 +// 刷新成功并重放。修复要求请求在发出前就认得过期 token,先刷新再发送。 +async function expectPreflight( + name: string, + run: Promise, + want: 'resolved' | 'rejected', +) { + let outcome: 'resolved' | 'rejected' = 'resolved' + try { + await run + } catch { + outcome = 'rejected' + } + if (outcome !== want) throw new Error(`authRefresh: ${name} = ${outcome}, want ${want}`) +} + +function preflightHarness(overrides: { + accessToken?: string | null + refresh?: () => Promise +}) { + const state = { token: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken } + const calls = { refresh: 0 } + const ensure = createTokenPreflight({ + readAccessToken: () => state.token, + refreshSession: async () => { + calls.refresh += 1 + if (overrides.refresh) return overrides.refresh() + state.token = jwtWithExp(Date.now() / 1000 + 3600) + return 'refreshed' + }, + now: () => NOW, + }) + return { state, calls, ensure } +} + +{ + // 令牌新鲜:不得产生任何刷新请求。 + const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 + 3600) }) + await expectPreflight('fresh token passes straight through', ensure(), 'resolved') + check('fresh token skips the refresh', calls.refresh === 0) +} + +{ + // 核心回归:过期令牌必须先刷新,且刷新完成后才返回(调用方据此发请求)。 + const { state, calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) }) + await expectPreflight('stale token refreshes before sending', ensure(), 'resolved') + check('stale token refreshed exactly once', calls.refresh === 1) + check('preflight handed out a fresh token', + !isAccessTokenStale(state.token, NOW)) +} + +{ + // 并发首屏:7 个请求同时预检,只允许刷新一次(协调器本身是单飞, + // 这里确认预检不会绕过它)。 + const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) }) + await Promise.all([ensure(), ensure(), ensure(), ensure(), ensure(), ensure(), ensure()]) + check('concurrent preflight refreshes once', calls.refresh === 1) +} + +{ + // 瞬时故障(部署/重启窗口):保留会话,照常发出请求—— + // 页面自己会重试,不该把人挡在门外。 + const { ensure } = preflightHarness({ + accessToken: jwtWithExp(NOW / 1000 - 60), + refresh: async () => 'transient', + }) + await expectPreflight('transient failure still sends the request', ensure(), 'resolved') +} + +{ + // 服务端明确判定凭证失效:不发请求,直接失败,避免刷出无意义的 401。 + const { ensure } = preflightHarness({ + accessToken: jwtWithExp(NOW / 1000 - 60), + refresh: async () => 'invalid', + }) + await expectPreflight('revoked session refuses to send', ensure(), 'rejected') +} + +{ + // 未登录:交给路由守卫和 401 路径处理,预检不替它们做决定。 + const { calls, ensure } = preflightHarness({ accessToken: null }) + await expectPreflight('anonymous request is not preflighted', ensure(), 'resolved') + check('anonymous request does not refresh', calls.refresh === 0) +} + +{ + // 非 JWT 令牌(Emby 等外部客户端签发):读不出 exp,按新鲜处理, + // 绝不能因此陷入刷新循环。 + const { calls, ensure } = preflightHarness({ accessToken: 'opaque-token' }) + await expectPreflight('opaque token passes through', ensure(), 'resolved') + check('opaque token does not refresh', calls.refresh === 0) +} + +{ + // 刷新抛出异常(网络不可达):按瞬时故障处理,请求照发。 + const { ensure } = preflightHarness({ + accessToken: jwtWithExp(NOW / 1000 - 60), + refresh: async () => { + throw new Error('network down') + }, + }) + await expectPreflight('throwing refresh still sends the request', ensure(), 'resolved') +} + // ── 单飞:并发调用共享同一次执行,结束后可重新执行 ──────────────────────── { let calls = 0 diff --git a/web/src/utils/authRefresh.ts b/web/src/utils/authRefresh.ts index 732e0e6..6df676b 100644 --- a/web/src/utils/authRefresh.ts +++ b/web/src/utils/authRefresh.ts @@ -15,6 +15,46 @@ /** 一次刷新尝试的结果。 */ export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient' +/** + * access token 过期前多久就视为失效。刷新要留出余量:刚好卡在 exp 上 + * 刷新,重放的原请求可能在服务端又已经过期。 + */ +export const TOKEN_EXPIRY_SKEW_MS = 30_000 + +/** + * 解析 JWT 的 exp 声明并换算成毫秒时间戳。 + * + * 解析失败(非 JWT、base64url 损坏、缺少 exp、exp 不是数字)一律返回 null: + * 调用方据此放行,绝不能把「读不懂」当成「已过期」,否则不透明的令牌会导致 + * 无休止的刷新循环。 + */ +export function decodeJwtExpiryMs(token: string | null | undefined): number | null { + if (!token) return null + const segment = token.split('.')[1] + if (!segment) return null + try { + // JWT 使用 base64url 编码,需先还原字符集并补齐 padding。 + const normalized = segment.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + const claims = JSON.parse(atob(padded)) as { exp?: unknown } + return typeof claims.exp === 'number' ? claims.exp * 1000 : null + } catch { + return null + } +} + +/** + * token 是否已过期或即将过期。 + * + * 首屏放行前用它做一次判断:本地持久化的 token 往往已经过期,直接发请求只 + * 会一次性刷出一屏 401。读不出 exp 的令牌按「新鲜」处理(见 decodeJwtExpiryMs)。 + */ +export function isAccessTokenStale(token: string | null | undefined, nowMs = Date.now()): boolean { + const expiry = decodeJwtExpiryMs(token) + if (expiry === null) return false + return nowMs >= expiry - TOKEN_EXPIRY_SKEW_MS +} + /** 刷新接口返回的令牌对。 */ export interface RefreshTokens { token: string @@ -101,6 +141,44 @@ export function createBrowserExclusiveRunner( } } +export interface SessionGateDeps { + /** 读取当前 access token(可能来自持久化存储,往往已经过期)。 */ + readAccessToken: () => string | null + /** 刷新会话;语义与 useAuthStore.refreshSession 一致。 */ + refreshSession: () => Promise + /** 可注入的时间源,便于测试。 */ + now?: () => number +} + +/** + * 构造请求前预检:每个请求发出前调用,必要时先把过期的令牌换新。 + * + * 本地持久化的 access token 常常已经过期。此时若直接发请求,首屏那些各自 + * 独立的 useEffect 会在同一帧里并发打出受保护请求,全部拿到 401,控制台 + * 一次刷出一整屏红字,用户还要等 401 拦截器刷新完才能看到内容 + * (现场:2026-10-16 12:49,7 个请求同时 401)。 + * + * 预检把「先刷新、再发送」放在请求发出之前: + * - token 新鲜(或无法解析 exp)→ 立即返回; + * - token 已过期 → 等待刷新(刷新协调器自带单飞,并发请求只发一次); + * - 刷新遇到瞬时故障 → 照常发送(页面自己会重试); + * - 服务端明确判定凭证失效 → 抛出,请求根本不发出去。 + * + * 未登录(没有 token)直接返回:跳登录页是路由守卫的职责。 + */ +export function createTokenPreflight(deps: SessionGateDeps): () => Promise { + return async () => { + const token = deps.readAccessToken() + if (!token) return + const now = deps.now ?? Date.now + if (!isAccessTokenStale(token, now())) return + const outcome = await deps.refreshSession().catch((): RefreshOutcome => 'transient') + if (outcome === 'invalid') { + throw new Error('session expired') + } + } +} + export interface RefreshCoordinatorDeps { readAccessToken: () => string | null readRefreshToken: () => string | null diff --git a/web/src/utils/chapterGroups.test.ts b/web/src/utils/chapterGroups.test.ts index b7ffa94..80e5b5e 100644 --- a/web/src/utils/chapterGroups.test.ts +++ b/web/src/utils/chapterGroups.test.ts @@ -1,4 +1,4 @@ -import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups' +import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups.ts' // 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。 diff --git a/web/src/utils/readerBookGroups.test.ts b/web/src/utils/readerBookGroups.test.ts index 37e9f74..b1a28e7 100644 --- a/web/src/utils/readerBookGroups.test.ts +++ b/web/src/utils/readerBookGroups.test.ts @@ -10,7 +10,7 @@ import { normalizeBookGroups, reorderBookGroups, resolveSelectedGroupId, -} from './readerBookGroups' +} from './readerBookGroups.ts' // 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。