fix(sub): prevent default profile page URL disclosure (#6538)

* fix(sub): prevent default profile page URL disclosure

Add explicit none, builtin, and custom profile page modes.
Preserve existing custom URLs and warn before exposing the built-in page.
Cover mode selection, legacy settings, and subscription response headers.

* fix(subscription): add profile page link options and upgrade notes
This commit is contained in:
NgaiYeanCoi
2026-09-16 03:13:29 +08:00
committed by GitHub
parent 3fa44915c1
commit 1d85ef138e
36 changed files with 747 additions and 47 deletions
+47
View File
@@ -44,6 +44,13 @@ const (
maxRegexLength = 2048
)
// Built-in profile links expose the subscription URL and require an explicit opt-in.
const (
SubProfileModeNone = "none"
SubProfileModeBuiltin = "builtin"
SubProfileModeCustom = "custom"
)
var defaultValueMap = map[string]string{
"xrayTemplateConfig": xrayTemplateConfig,
"webListen": "",
@@ -101,6 +108,7 @@ var defaultValueMap = map[string]string{
"subClashUserAgentRegex": "",
"subTitle": "",
"subSupportUrl": "",
"subProfileMode": SubProfileModeNone,
"subProfileUrl": "",
"subAnnounce": "",
"subEnableRouting": "false",
@@ -298,6 +306,11 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
}
}
// A missing mode must still preserve URLs configured before modes existed.
if !keyMap["subProfileMode"] {
allSetting.SubProfileMode = ""
}
allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl)
return allSetting, nil
}
@@ -850,6 +863,34 @@ func (s *SettingService) GetSubProfileUrl() (string, error) {
return common.EnsureURLScheme(value), err
}
func (s *SettingService) GetSubProfileMode() (string, error) {
setting, err := s.getSetting("subProfileMode")
if err != nil && !database.IsNotFound(err) {
return SubProfileModeNone, err
}
if err == nil && setting.Value != "" {
return effectiveSubProfileMode(setting.Value, ""), nil
}
profileURL, err := s.getString("subProfileUrl")
if err != nil {
return SubProfileModeNone, err
}
return effectiveSubProfileMode("", profileURL), nil
}
func effectiveSubProfileMode(mode, profileURL string) string {
switch mode {
case SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom:
return mode
case "":
// Older settings have no mode; only an existing custom URL opts them in.
if strings.TrimSpace(profileURL) != "" {
return SubProfileModeCustom
}
}
return SubProfileModeNone
}
func (s *SettingService) GetSubAnnounce() (string, error) {
return s.getString("subAnnounce")
}
@@ -1448,6 +1489,12 @@ type SecretClears struct {
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
switch allSetting.SubProfileMode {
case "", SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom:
allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl)
default:
return errors.New("subscription profile mode must be none, builtin, or custom")
}
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
return err
}
@@ -0,0 +1,148 @@
package service
import (
"encoding/json"
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestSubProfileModeReadsLegacyAndExplicitSettings(t *testing.T) {
tests := []struct {
name string
storedMode string
storedURL string
modeExists bool
want string
}{
{name: "fresh installation", want: "none"},
{name: "legacy URL", storedURL: " https://profile.example/account ", want: "custom"},
{name: "legacy blank URL", storedURL: " \t ", want: "none"},
{name: "legacy empty mode with URL", modeExists: true, storedURL: "https://profile.example/account", want: "custom"},
{name: "legacy empty mode without URL", modeExists: true, want: "none"},
{name: "explicit none preserves saved URL", modeExists: true, storedMode: "none", storedURL: "https://profile.example/account", want: "none"},
{name: "explicit builtin", modeExists: true, storedMode: "builtin", storedURL: "https://profile.example/account", want: "builtin"},
{name: "explicit custom", modeExists: true, storedMode: "custom", storedURL: "https://profile.example/account", want: "custom"},
{name: "custom with empty URL", modeExists: true, storedMode: "custom", want: "custom"},
{name: "invalid stored mode fails closed", modeExists: true, storedMode: "automatic", storedURL: "https://profile.example/account", want: "none"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if tt.modeExists {
if err := s.saveSetting("subProfileMode", tt.storedMode); err != nil {
t.Fatal(err)
}
}
if err := s.saveSetting("subProfileUrl", tt.storedURL); err != nil {
t.Fatal(err)
}
assertSubProfileSettings(t, s, tt.want, tt.storedURL)
})
}
}
func TestSubProfileModeUpdatesPreserveURLAndLegacyPayloads(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if got := s.GetFactoryDefaults()["subProfileMode"]; got != "none" {
t.Errorf("factory profile mode = %q, want none", got)
}
tests := []struct {
name string
mode string
url string
want string
}{
{name: "custom", mode: "custom", url: "https://profile.example/account", want: "custom"},
{name: "none retains custom URL", mode: "none", url: "https://profile.example/account", want: "none"},
{name: "builtin retains custom URL", mode: "builtin", url: "https://profile.example/account", want: "builtin"},
{name: "custom restores saved URL", mode: "custom", url: "https://profile.example/account", want: "custom"},
{name: "legacy URL submission", url: "https://legacy.example/account", want: "custom"},
{name: "legacy empty URL submission", want: "none"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(map[string]string{"subProfileMode": tt.mode, "subProfileUrl": tt.url})
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(payload, settings); err != nil {
t.Fatal(err)
}
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
assertSubProfileSettings(t, s, tt.want, tt.url)
stored, err := s.getSetting("subProfileMode")
if err != nil {
t.Fatal(err)
}
if stored.Value != tt.want {
t.Fatalf("persisted mode = %q, want %q", stored.Value, tt.want)
}
})
}
}
func TestSubProfileModeRejectsInvalidUpdateBeforeWrites(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if err := s.saveSetting("subTitle", "Original title"); err != nil {
t.Fatal(err)
}
var before []model.Setting
if err := database.GetDB().Order("id").Find(&before).Error; err != nil {
t.Fatal(err)
}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(`{"subProfileMode":"automatic","subTitle":"Changed title","subProfileUrl":"https://profile.example/account"}`), settings); err != nil {
t.Fatal(err)
}
err = s.UpdateAllSetting(settings, SecretClears{})
if err == nil || err.Error() != "subscription profile mode must be none, builtin, or custom" {
t.Errorf("UpdateAllSetting error = %v, want invalid profile mode error", err)
}
var after []model.Setting
if err := database.GetDB().Order("id").Find(&after).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, after) {
t.Fatal("invalid profile mode update modified stored settings")
}
}
func assertSubProfileSettings(t *testing.T, s *SettingService, wantMode, wantURL string) {
t.Helper()
if mode, err := s.GetSubProfileMode(); err != nil || mode != wantMode {
t.Fatalf("GetSubProfileMode = %q, %v; want %q, nil", mode, err, wantMode)
}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
var profile struct {
Mode string `json:"subProfileMode"`
URL string `json:"subProfileUrl"`
}
if err := json.Unmarshal(payload, &profile); err != nil {
t.Fatal(err)
}
if profile.Mode != wantMode || profile.URL != wantURL {
t.Fatalf("profile settings = (%q, %q), want (%q, %q)", profile.Mode, profile.URL, wantMode, wantURL)
}
}