mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-31 04:12:13 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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 با نام مشخص. |
|
||||
|
||||
پس از تغییر پورت یا مسیر پایه، آدرس پنل بهصورت
|
||||
|
||||
@@ -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 панели становится
|
||||
|
||||
@@ -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 将变为
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
internal/sub/forwarded_trust.go
Normal file
92
internal/sub/forwarded_trust.go
Normal 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
|
||||
}
|
||||
205
internal/sub/forwarded_trust_test.go
Normal file
205
internal/sub/forwarded_trust_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user