From a6a4ffbeab2f8ad4fe265e511fa811f3c3a922a1 Mon Sep 17 00:00:00 2001 From: farhadh Date: Mon, 11 May 2026 21:10:05 +0200 Subject: [PATCH] feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs Introduces AllSettingView which strips tgBotToken, twoFactorToken, ldapPassword, apiToken and warp/nord secrets before sending them to the browser, replacing them with boolean hasFoo presence flags. A new /panel/setting/secret endpoint allows updating individual secrets by key. Secrets that arrive blank on a save are preserved from the DB rather than overwritten. Adds TrustedProxyCIDRs as a configurable setting (defaults to localhost CIDRs). URL fields are validated before save. --- frontend/src/models/setting.js | 9 +- frontend/src/pages/settings/GeneralTab.vue | 14 ++- frontend/src/pages/settings/TelegramTab.vue | 7 +- .../src/pages/settings/TwoFactorModal.vue | 10 +- web/entity/entity.go | 42 ++++++-- web/service/setting.go | 95 ++++++++++++++++++- 6 files changed, 163 insertions(+), 14 deletions(-) diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js index 7efc9e7d..1f465036 100644 --- a/frontend/src/models/setting.js +++ b/frontend/src/models/setting.js @@ -15,6 +15,7 @@ export class AllSetting { this.webKeyFile = ""; this.webBasePath = "/"; this.sessionMaxAge = 360; + this.trustedProxyCIDRs = "127.0.0.1/32,::1/128"; this.pageSize = 25; this.expireDiff = 0; this.trafficDiff = 0; @@ -87,6 +88,12 @@ export class AllSetting { this.ldapDefaultTotalGB = 0; this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + this.hasTgBotToken = false; + this.hasTwoFactorToken = false; + this.hasLdapPassword = false; + this.hasApiToken = false; + this.hasWarpSecret = false; + this.hasNordSecret = false; if (data == null) { return @@ -97,4 +104,4 @@ export class AllSetting { equals(other) { return ObjectUtil.equals(this, other); } -} \ No newline at end of file +} diff --git a/frontend/src/pages/settings/GeneralTab.vue b/frontend/src/pages/settings/GeneralTab.vue index 2d85312b..75957277 100644 --- a/frontend/src/pages/settings/GeneralTab.vue +++ b/frontend/src/pages/settings/GeneralTab.vue @@ -153,6 +153,14 @@ onMounted(loadInboundTags); + + + + + + @@ -298,8 +306,12 @@ onMounted(loadInboundTags); + diff --git a/frontend/src/pages/settings/TelegramTab.vue b/frontend/src/pages/settings/TelegramTab.vue index ce82350f..15111307 100644 --- a/frontend/src/pages/settings/TelegramTab.vue +++ b/frontend/src/pages/settings/TelegramTab.vue @@ -23,9 +23,12 @@ defineProps({ - + diff --git a/frontend/src/pages/settings/TwoFactorModal.vue b/frontend/src/pages/settings/TwoFactorModal.vue index 51a0afea..d0b5819a 100644 --- a/frontend/src/pages/settings/TwoFactorModal.vue +++ b/frontend/src/pages/settings/TwoFactorModal.vue @@ -38,18 +38,24 @@ function buildTotp() { watch(() => props.open, (next) => { if (!next) return; enteredCode.value = ''; + totp = null; + qrValue.value = ''; if (props.token) { buildTotp(); } }); -function close(success) { - emit('confirm', success); +function close(success, code = '') { + emit('confirm', success, code); emit('update:open', false); enteredCode.value = ''; } function onOk() { + if (props.type === 'confirm' && !props.token) { + close(true, enteredCode.value); + return; + } if (!totp) return; if (totp.generate() === enteredCode.value) { close(true); diff --git a/web/entity/entity.go b/web/entity/entity.go index 77e1d661..b1f02a37 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -21,13 +21,14 @@ type Msg struct { // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings. type AllSetting struct { // Web server settings - WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address - WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation - WebPort int `json:"webPort" form:"webPort"` // Web server port number - WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server - WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server - WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs - SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes + WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address + WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation + WebPort int `json:"webPort" form:"webPort"` // Web server port number + WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server + WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server + WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs + SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes + TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers // UI settings PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists @@ -110,6 +111,20 @@ type AllSetting struct { // JSON subscription routing rules } +// AllSettingView is the browser-safe settings read model. Secret values +// are redacted from the embedded write model and represented by presence +// flags so the UI can show configured/not configured state. +type AllSettingView struct { + AllSetting + + HasTgBotToken bool `json:"hasTgBotToken"` + HasTwoFactorToken bool `json:"hasTwoFactorToken"` + HasLdapPassword bool `json:"hasLdapPassword"` + HasApiToken bool `json:"hasApiToken"` + HasWarpSecret bool `json:"hasWarpSecret"` + HasNordSecret bool `json:"hasNordSecret"` +} + // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. func (s *AllSetting) CheckValid() error { if s.WebListen != "" { @@ -179,6 +194,19 @@ func (s *AllSetting) CheckValid() error { s.SubClashPath += "/" } + for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + if ip := net.ParseIP(cidr); ip != nil { + continue + } + if _, _, err := net.ParseCIDR(cidr); err != nil { + return common.NewError("trusted proxy CIDR is not valid:", cidr) + } + } + _, err := time.LoadLocation(s.TimeLocation) if err != nil { return common.NewError("time location not exist:", s.TimeLocation) diff --git a/web/service/setting.go b/web/service/setting.go index 0e0ca8f8..fe0da73d 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -36,6 +36,7 @@ var defaultValueMap = map[string]string{ "apiToken": "", "webBasePath": "/", "sessionMaxAge": "360", + "trustedProxyCIDRs": "127.0.0.1/32,::1/128", "pageSize": "25", "expireDiff": "0", "trafficDiff": "0", @@ -199,6 +200,32 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { return allSetting, nil } +func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { + allSetting, err := s.GetAllSetting() + if err != nil { + return nil, err + } + view := &entity.AllSettingView{AllSetting: *allSetting} + view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) + view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken) + view.HasLdapPassword = secretConfigured(allSetting.LdapPassword) + view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) + view.HasNordSecret = secretConfigured(mustString(s.GetNord())) + view.HasApiToken = secretConfigured(mustString(s.getString("apiToken"))) + view.TgBotToken = "" + view.TwoFactorToken = "" + view.LdapPassword = "" + return view, nil +} + +func secretConfigured(value string) bool { + return strings.TrimSpace(value) != "" +} + +func mustString(value string, _ error) string { + return value +} + func (s *SettingService) ResetSettings() error { db := database.GetDB() err := db.Where("1 = 1").Delete(model.Setting{}).Error @@ -286,7 +313,11 @@ func (s *SettingService) GetXrayOutboundTestUrl() (string, error) { } func (s *SettingService) SetXrayOutboundTestUrl(url string) error { - return s.setString("xrayOutboundTestUrl", url) + clean, err := SanitizeHTTPURL(url) + if err != nil { + return err + } + return s.setString("xrayOutboundTestUrl", clean) } func (s *SettingService) GetListen() (string, error) { @@ -417,6 +448,10 @@ func (s *SettingService) GetSessionMaxAge() (int, error) { return s.getInt("sessionMaxAge") } +func (s *SettingService) GetTrustedProxyCIDRs() (string, error) { + return s.getString("trustedProxyCIDRs") +} + func (s *SettingService) GetRemarkModel() (string, error) { return s.getString("remarkModel") } @@ -771,6 +806,12 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) { } func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { + if err := s.preserveRedactedSecrets(allSetting); err != nil { + return err + } + if err := validateSettingsURLs(allSetting); err != nil { + return err + } if err := allSetting.CheckValid(); err != nil { return err } @@ -791,6 +832,58 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { return common.Combine(errs...) } +func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error { + if strings.TrimSpace(allSetting.TgBotToken) == "" { + value, err := s.GetTgBotToken() + if err != nil { + return err + } + allSetting.TgBotToken = value + } + if strings.TrimSpace(allSetting.LdapPassword) == "" { + value, err := s.GetLdapPassword() + if err != nil { + return err + } + allSetting.LdapPassword = value + } + if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" { + value, err := s.GetTwoFactorToken() + if err != nil { + return err + } + allSetting.TwoFactorToken = value + } + return nil +} + +func validateSettingsURLs(allSetting *entity.AllSetting) error { + if allSetting.ExternalTrafficInformURI != "" { + u, err := SanitizeHTTPURL(allSetting.ExternalTrafficInformURI) + if err != nil { + return common.NewError("external traffic inform URI is invalid:", err) + } + allSetting.ExternalTrafficInformURI = u + } + if allSetting.TgBotAPIServer != "" { + u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer) + if err != nil { + return common.NewError("telegram API server URL is invalid:", err) + } + allSetting.TgBotAPIServer = u + } + return nil +} + +func (s *SettingService) UpdateSecret(key string, value string) error { + switch key { + case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken": + return s.saveSetting(key, strings.TrimSpace(value)) + default: + return common.NewError("secret key is not replaceable:", key) + } +} + func (s *SettingService) GetDefaultXrayConfig() (any, error) { var jsonData any err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)