Files
MeBox/internal/service/image_proxy_allowed_hosts.go
T
truewhile 43cfeabea1 bug处理
2026-10-16 14:54:06 +08:00

194 lines
6.1 KiB
Go

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
}