From ad288a7eccb00b38bbd43e3de46e75ffca530d05 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:01:59 +0800 Subject: [PATCH] fix(sub): honor trustedProxyCIDRs before forwarded URLs (#6135) * fix(sub): honor trustedProxyCIDRs before forwarded URLs * fix(sub): avoid unused trust-setting lookups Skip the trustedProxyCIDRs lookup when no forwarded header can affect a subscription URL. Keep the shipped proxy default in one exported setting constant and document the subscription-link behavior for custom proxy boundaries. * fix(frontend): meet config text contrast requirements Keep compact configuration text readable in the light theme and satisfy the Storybook accessibility check. --------- Co-authored-by: PathGao --- docs/content/docs/en/config/panel.mdx | 2 +- docs/content/docs/fa/config/panel.mdx | 2 +- docs/content/docs/ru/config/panel.mdx | 2 +- docs/content/docs/zh/config/panel.mdx | 2 +- .../src/components/clients/ConfigBlock.css | 4 + internal/sub/forwarded_trust.go | 92 ++++++++ internal/sub/forwarded_trust_test.go | 205 ++++++++++++++++++ internal/sub/service.go | 23 +- internal/web/controller/util.go | 2 +- internal/web/service/setting.go | 3 +- 10 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 internal/sub/forwarded_trust.go create mode 100644 internal/sub/forwarded_trust_test.go diff --git a/docs/content/docs/en/config/panel.mdx b/docs/content/docs/en/config/panel.mdx index 439ab17f0..542a4e604 100644 --- a/docs/content/docs/en/config/panel.mdx +++ b/docs/content/docs/en/config/panel.mdx @@ -19,7 +19,7 @@ browser in full. | `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). | | `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. | | `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). | -| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. | +| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. A custom value also controls forwarded host and scheme in subscription links; include the subscription proxy or set `subURI` to override those links. | | `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. | After changing the port or base path, the panel URL becomes diff --git a/docs/content/docs/fa/config/panel.mdx b/docs/content/docs/fa/config/panel.mdx index a1a781340..2f8bd5278 100644 --- a/docs/content/docs/fa/config/panel.mdx +++ b/docs/content/docs/fa/config/panel.mdx @@ -19,7 +19,7 @@ icon: SlidersHorizontal | `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود). | | `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود. | | `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیش‌فرض ۶ ساعت). | -| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. | +| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. مقدار سفارشی همچنین میزبان و طرحِ لینک‌های اشتراک را کنترل می‌کند؛ پراکسی اشتراک را اضافه کنید یا برای بازنویسی این لینک‌ها `subURI` را تنظیم کنید. | | `panelOutbound` | _(هیچ‌کدام)_ | مسیریابی خروجیِ خود پنل (بررسی به‌روزرسانی‌ها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. | پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت diff --git a/docs/content/docs/ru/config/panel.mdx b/docs/content/docs/ru/config/panel.mdx index de6ac9a61..b9bb6419f 100644 --- a/docs/content/docs/ru/config/panel.mdx +++ b/docs/content/docs/ru/config/panel.mdx @@ -19,7 +19,7 @@ icon: SlidersHorizontal | `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). | | `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. | | `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). | -| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. | +| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. Пользовательское значение также управляет пересылаемыми хостом и схемой в ссылках подписки; добавьте прокси подписки или задайте `subURI`, чтобы переопределить эти ссылки. | | `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. | После изменения порта или базового пути URL панели становится diff --git a/docs/content/docs/zh/config/panel.mdx b/docs/content/docs/zh/config/panel.mdx index 6a145188d..d9f52686f 100644 --- a/docs/content/docs/zh/config/panel.mdx +++ b/docs/content/docs/zh/config/panel.mdx @@ -15,7 +15,7 @@ icon: SlidersHorizontal | `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 | | `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 | | `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 | -| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。 | +| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。自定义值还会控制订阅链接中转发的主机和协议;请将订阅代理加入列表,或设置 `subURI` 覆盖这些链接。 | | `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 | 更改端口或基础路径后,面板 URL 将变为 diff --git a/frontend/src/components/clients/ConfigBlock.css b/frontend/src/components/clients/ConfigBlock.css index 474158099..4b4252510 100644 --- a/frontend/src/components/clients/ConfigBlock.css +++ b/frontend/src/components/clients/ConfigBlock.css @@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold { color: #874d00; } +body.light .config-block-text { + color: #595959; +} + .config-block .ant-collapse-extra { display: flex; align-items: center; diff --git a/internal/sub/forwarded_trust.go b/internal/sub/forwarded_trust.go new file mode 100644 index 000000000..4b44f8fde --- /dev/null +++ b/internal/sub/forwarded_trust.go @@ -0,0 +1,92 @@ +package sub + +import ( + "net" + "net/netip" + "strings" + "sync" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +var warnSuppressedForwardedOnce sync.Once + +var forwardedHeaderNames = [...]string{"X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"} + +func (s *SubService) forwardedHeadersTrusted(c *gin.Context) (trusted bool) { + if !hasForwardedHeaders(c) { + return true + } + + trusted = true + defer func() { + _ = recover() + }() + + configured, err := s.settingService.GetTrustedProxyCIDRs() + if err != nil { + return true + } + configured = strings.TrimSpace(configured) + if configured == "" || configured == service.DefaultTrustedProxyCIDRs { + return true + } + return remoteAddrInCIDRs(c.Request.RemoteAddr, configured) +} + +func hasForwardedHeaders(c *gin.Context) bool { + for _, name := range forwardedHeaderNames { + if c.GetHeader(name) != "" { + return true + } + } + return false +} + +func warnSuppressedForwardedHeaders(c *gin.Context) { + present := make([]string, 0, 3) + for _, name := range forwardedHeaderNames { + if c.GetHeader(name) != "" { + present = append(present, name) + } + } + if len(present) == 0 { + return + } + headers := strings.Join(present, ", ") + logger.Debugf("sub: ignoring %s from %s, which is outside trustedProxyCIDRs", headers, c.Request.RemoteAddr) + warnSuppressedForwardedOnce.Do(func() { + logger.Warningf("sub: ignoring %s from %s because it is outside trustedProxyCIDRs; subscription URLs will use the request host. Add the proxy to that setting, or set subURI, if the generated links look wrong.", headers, c.Request.RemoteAddr) + }) +} + +func remoteAddrInCIDRs(remoteAddr, cidrs string) bool { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + addr, err := netip.ParseAddr(strings.TrimSpace(host)) + if err != nil { + return false + } + addr = addr.Unmap() + for value := range strings.SplitSeq(cidrs, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if prefix, err := netip.ParsePrefix(value); err == nil { + if prefix.Contains(addr) { + return true + } + continue + } + if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() { + return true + } + } + return false +} diff --git a/internal/sub/forwarded_trust_test.go b/internal/sub/forwarded_trust_test.go new file mode 100644 index 000000000..83f1df87c --- /dev/null +++ b/internal/sub/forwarded_trust_test.go @@ -0,0 +1,205 @@ +package sub + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +func requestFrom(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/sub/abc", nil) + req.Host = "panel.example.com:2096" + req.RemoteAddr = remoteAddr + for k, v := range headers { + req.Header.Set(k, v) + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = req + return c +} + +func setTrustedProxyCIDRs(t *testing.T, value string) { + t.Helper() + if err := database.GetDB().Create(&model.Setting{Key: "trustedProxyCIDRs", Value: value}).Error; err != nil { + t.Fatalf("set trustedProxyCIDRs: %v", err) + } + settingService := service.SettingService{} + stored, err := settingService.GetTrustedProxyCIDRs() + if err != nil { + t.Fatalf("read trustedProxyCIDRs back through SettingService: %v", err) + } + if stored != value { + t.Fatalf("SettingService reads trustedProxyCIDRs as %q, want %q — the key this helper writes has drifted", stored, value) + } +} + +func storedAs(value string) *string { + return &value +} + +func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) { + tests := []struct { + name string + stored *string + remoteAddr string + wantScheme string + wantHost string + wantHostWithPort string + wantHostHeader string + }{ + { + name: "no stored row keeps trusting forwarded headers", + stored: nil, + remoteAddr: "203.0.113.9:51000", + wantScheme: "https", + wantHost: "sub.example.net", + wantHostWithPort: "sub.example.net", + wantHostHeader: "sub.example.net", + }, + { + name: "empty stored value keeps trusting forwarded headers", + stored: storedAs(""), + remoteAddr: "203.0.113.9:51000", + wantScheme: "https", + wantHost: "sub.example.net", + wantHostWithPort: "sub.example.net", + wantHostHeader: "sub.example.net", + }, + { + name: "stored shipped default keeps trusting forwarded headers", + stored: storedAs(service.DefaultTrustedProxyCIDRs), + remoteAddr: "203.0.113.9:51000", + wantScheme: "https", + wantHost: "sub.example.net", + wantHostWithPort: "sub.example.net", + wantHostHeader: "sub.example.net", + }, + { + name: "declared boundary ignores an origin outside it", + stored: storedAs("10.0.0.0/8"), + remoteAddr: "203.0.113.9:51000", + wantScheme: "http", + wantHost: "panel.example.com", + wantHostWithPort: "panel.example.com:2096", + wantHostHeader: "panel.example.com", + }, + { + name: "declared boundary trusts an origin inside it", + stored: storedAs("10.0.0.0/8"), + remoteAddr: "10.1.2.3:44000", + wantScheme: "https", + wantHost: "sub.example.net", + wantHostWithPort: "sub.example.net", + wantHostHeader: "sub.example.net", + }, + { + name: "declared boundary ignores an unparsable origin", + stored: storedAs("10.0.0.0/8"), + remoteAddr: "not-an-ip", + wantScheme: "http", + wantHost: "panel.example.com", + wantHostWithPort: "panel.example.com:2096", + wantHostHeader: "panel.example.com", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + initSubDB(t) + if tc.stored != nil { + setTrustedProxyCIDRs(t, *tc.stored) + } + s := &SubService{} + + c := requestFrom(t, tc.remoteAddr, map[string]string{ + "X-Forwarded-Host": "sub.example.net", + "X-Forwarded-Proto": "https", + }) + scheme, host, hostWithPort, hostHeader := s.ResolveRequest(c) + + if scheme != tc.wantScheme { + t.Errorf("scheme = %q, want %q", scheme, tc.wantScheme) + } + if host != tc.wantHost { + t.Errorf("host = %q, want %q", host, tc.wantHost) + } + if hostWithPort != tc.wantHostWithPort { + t.Errorf("hostWithPort = %q, want %q", hostWithPort, tc.wantHostWithPort) + } + if hostHeader != tc.wantHostHeader { + t.Errorf("hostHeader = %q, want %q", hostHeader, tc.wantHostHeader) + } + }) + } +} + +func TestResolveRequest_GatesRealIPFallback(t *testing.T) { + initSubDB(t) + setTrustedProxyCIDRs(t, "10.0.0.0/8") + s := &SubService{} + + c := requestFrom(t, "203.0.113.9:51000", map[string]string{ + "X-Real-IP": "198.51.100.7", + }) + _, host, _, hostHeader := s.ResolveRequest(c) + + if host != "panel.example.com" { + t.Errorf("host = %q, want the request host — X-Real-IP from an untrusted origin must be ignored", host) + } + if hostHeader != "panel.example.com" { + t.Errorf("hostHeader = %q, want the request host", hostHeader) + } +} + +func TestHasForwardedHeaders(t *testing.T) { + tests := []struct { + name string + headers map[string]string + want bool + }{ + {name: "no forwarded headers", want: false}, + {name: "forwarded host", headers: map[string]string{"X-Forwarded-Host": "sub.example.net"}, want: true}, + {name: "forwarded proto", headers: map[string]string{"X-Forwarded-Proto": "https"}, want: true}, + {name: "real ip", headers: map[string]string{"X-Real-IP": "10.1.2.3"}, want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := hasForwardedHeaders(requestFrom(t, "10.1.2.3:1234", tc.headers)); got != tc.want { + t.Errorf("hasForwardedHeaders() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestRemoteAddrInCIDRs(t *testing.T) { + tests := []struct { + name string + remoteAddr string + cidrs string + want bool + }{ + {name: "inside cidr", remoteAddr: "10.1.2.3:1234", cidrs: "10.0.0.0/8", want: true}, + {name: "ipv4 mapped address inside cidr", remoteAddr: "[::ffff:10.1.2.3]:1234", cidrs: "10.0.0.0/8", want: true}, + {name: "outside cidr", remoteAddr: "203.0.113.9:1234", cidrs: "10.0.0.0/8", want: false}, + {name: "bare address entry", remoteAddr: "192.168.1.5:80", cidrs: "192.168.1.5", want: true}, + {name: "ipv6 loopback", remoteAddr: "[::1]:8080", cidrs: "::1/128", want: true}, + {name: "no port", remoteAddr: "10.1.2.3", cidrs: "10.0.0.0/8", want: true}, + {name: "unparsable origin", remoteAddr: "not-an-ip", cidrs: "10.0.0.0/8", want: false}, + {name: "empty entries skipped", remoteAddr: "10.1.2.3:1", cidrs: " , 10.0.0.0/8 , ", want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := remoteAddrInCIDRs(tc.remoteAddr, tc.cidrs); got != tc.want { + t.Errorf("remoteAddrInCIDRs(%q, %q) = %v, want %v", tc.remoteAddr, tc.cidrs, got, tc.want) + } + }) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index c43974fff..0ebfa1c02 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2486,18 +2486,29 @@ type PageData struct { // ResolveRequest extracts scheme and host info from request/headers consistently. // ResolveRequest extracts scheme, host, and header information from an HTTP request. func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) { + trusted := s.forwardedHeadersTrusted(c) + if !trusted { + warnSuppressedForwardedHeaders(c) + } + forwarded := func(name string) string { + if !trusted { + return "" + } + return c.GetHeader(name) + } + // scheme scheme = "http" - if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { + if c.Request.TLS != nil || strings.EqualFold(forwarded("X-Forwarded-Proto"), "https") { scheme = "https" } // base host (no port) - if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" { + if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" { host = h } if host == "" { - host = c.GetHeader("X-Real-IP") + host = forwarded("X-Real-IP") } if host == "" { var err error @@ -2508,7 +2519,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, } // host:port for URLs - hostWithPort = c.GetHeader("X-Forwarded-Host") + hostWithPort = forwarded("X-Forwarded-Host") if hostWithPort == "" { hostWithPort = c.Request.Host } @@ -2517,9 +2528,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, } // header display host - hostHeader = c.GetHeader("X-Forwarded-Host") + hostHeader = forwarded("X-Forwarded-Host") if hostHeader == "" { - hostHeader = c.GetHeader("X-Real-IP") + hostHeader = forwarded("X-Real-IP") } if hostHeader == "" { hostHeader = host diff --git a/internal/web/controller/util.go b/internal/web/controller/util.go index b1a33e82a..aa14134f9 100644 --- a/internal/web/controller/util.go +++ b/internal/web/controller/util.go @@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool { } func trustedProxyCIDRs() (trusted string) { - trusted = "127.0.0.1/32,::1/128" + trusted = service.DefaultTrustedProxyCIDRs defer func() { _ = recover() }() diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 1ba6bdffb..61366522b 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -37,6 +37,7 @@ const ( DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)` DefaultSubJsonUserAgentRegex = `` DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D" + DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128" maxRegexLength = 2048 ) @@ -61,7 +62,7 @@ var defaultValueMap = map[string]string{ "nodeMtlsClientCAPem": "", "webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")), "sessionMaxAge": "360", - "trustedProxyCIDRs": "127.0.0.1/32,::1/128", + "trustedProxyCIDRs": DefaultTrustedProxyCIDRs, "pageSize": "25", "expireDiff": "0", "trafficDiff": "0",