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 <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
PathGao
2026-07-30 03:01:59 +08:00
committed by GitHub
parent ad5f2a28cb
commit ad288a7ecc
10 changed files with 325 additions and 12 deletions

View File

@@ -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

View File

@@ -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 با نام مشخص. |
پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت

View File

@@ -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 панели становится

View File

@@ -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 将变为

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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

View File

@@ -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()
}()

View File

@@ -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",