mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
bug处理
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 时
|
||||
// 重新登录一次再重试,封面图不再依赖人工改账号。
|
||||
|
||||
+21
-2
@@ -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 ?? {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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<string, unknown> = { 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<void>,
|
||||
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<RefreshOutcome>
|
||||
}) {
|
||||
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
|
||||
|
||||
@@ -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<RefreshOutcome>
|
||||
/** 可注入的时间源,便于测试。 */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造请求前预检:每个请求发出前调用,必要时先把过期的令牌换新。
|
||||
*
|
||||
* 本地持久化的 access token 常常已经过期。此时若直接发请求,首屏那些各自
|
||||
* 独立的 useEffect 会在同一帧里并发打出受保护请求,全部拿到 401,控制台
|
||||
* 一次刷出一整屏红字,用户还要等 401 拦截器刷新完才能看到内容
|
||||
* (现场:2026-10-16 12:49,7 个请求同时 401)。
|
||||
*
|
||||
* 预检把「先刷新、再发送」放在请求发出之前:
|
||||
* - token 新鲜(或无法解析 exp)→ 立即返回;
|
||||
* - token 已过期 → 等待刷新(刷新协调器自带单飞,并发请求只发一次);
|
||||
* - 刷新遇到瞬时故障 → 照常发送(页面自己会重试);
|
||||
* - 服务端明确判定凭证失效 → 抛出,请求根本不发出去。
|
||||
*
|
||||
* 未登录(没有 token)直接返回:跳登录页是路由守卫的职责。
|
||||
*/
|
||||
export function createTokenPreflight(deps: SessionGateDeps): () => Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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 的写法)。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
normalizeBookGroups,
|
||||
reorderBookGroups,
|
||||
resolveSelectedGroupId,
|
||||
} from './readerBookGroups'
|
||||
} from './readerBookGroups.ts'
|
||||
|
||||
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user