feat(custom-menu): secure embedded shop pages

Add omit_auth_context as a safe default for custom menu iframe pages, converge untrusted public output, and document the deployed shop menu configuration.
This commit is contained in:
zizi 2026-05-27 14:28:17 +08:00
parent 09497bc950
commit ac5ff854b2
26 changed files with 1506 additions and 47 deletions

View File

@ -31,6 +31,8 @@ const (
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
const customMenuTrustedOriginsEnv = "CUSTOM_MENU_AUTH_CONTEXT_TRUSTED_ORIGINS"
// UMQ用户消息队列模式常量
const (
// UMQModeSerialize: 账号级串行锁 + RPM 自适应延迟
@ -574,13 +576,14 @@ type CORSConfig struct {
}
type SecurityConfig struct {
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
CSP CSPConfig `mapstructure:"csp"`
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"`
trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"`
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
CSP CSPConfig `mapstructure:"csp"`
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
CustomMenuAuthContextTrustedOrigins []string `mapstructure:"custom_menu_auth_context_trusted_origins"`
TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"`
trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"`
}
func (c *Config) TrustForwardedIPForAPIKeyACL() bool {
@ -1414,6 +1417,12 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
cfg.Security.CustomMenuAuthContextTrustedOrigins = normalizeStringSlice(
firstNonEmptyStringSlice(
splitCommaSeparatedEnv(customMenuTrustedOriginsEnv),
cfg.Security.CustomMenuAuthContextTrustedOrigins,
),
)
cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy)
cfg.SetTrustForwardedIPForAPIKeyACL(cfg.Security.TrustForwardedIPForAPIKeyACL)
cfg.Log.Level = strings.ToLower(strings.TrimSpace(cfg.Log.Level))
@ -1559,6 +1568,7 @@ func setDefaults() {
viper.SetDefault("security.response_headers.force_remove", []string{})
viper.SetDefault("security.csp.enabled", true)
viper.SetDefault("security.csp.policy", DefaultCSPPolicy)
viper.SetDefault("security.custom_menu_auth_context_trusted_origins", []string{})
viper.SetDefault("security.proxy_probe.insecure_skip_verify", false)
viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", false)
@ -2765,6 +2775,23 @@ func normalizeStringSlice(values []string) []string {
return normalized
}
func firstNonEmptyStringSlice(values ...[]string) []string {
for _, value := range values {
if len(value) > 0 {
return value
}
}
return nil
}
func splitCommaSeparatedEnv(key string) []string {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return nil
}
return strings.Split(raw, ",")
}
func isWeakJWTSecret(secret string) bool {
lower := strings.ToLower(strings.TrimSpace(secret))
if lower == "" {

View File

@ -794,6 +794,21 @@ func TestNormalizeStringSlice(t *testing.T) {
}
}
func TestLoadCustomMenuTrustedOriginsFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("CUSTOM_MENU_AUTH_CONTEXT_TRUSTED_ORIGINS", "https://trusted.example.com, https://shop.example.com")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
got := cfg.Security.CustomMenuAuthContextTrustedOrigins
if len(got) != 2 || got[0] != "https://trusted.example.com" || got[1] != "https://shop.example.com" {
t.Fatalf("CustomMenuAuthContextTrustedOrigins = %#v, want two env origins", got)
}
}
func TestGetServerAddressFromEnv(t *testing.T) {
t.Setenv("SERVER_HOST", "127.0.0.1")
t.Setenv("SERVER_PORT", "9090")

View File

@ -378,6 +378,22 @@ func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []serv
return result
}
func customMenuItemsToService(items []dto.CustomMenuItem) []service.CustomMenuAuthContextItem {
result := make([]service.CustomMenuAuthContextItem, 0, len(items))
for _, item := range items {
result = append(result, service.CustomMenuAuthContextItem(item))
}
return result
}
func customMenuItemsFromService(items []service.CustomMenuAuthContextItem) []dto.CustomMenuItem {
result := make([]dto.CustomMenuItem, 0, len(items))
for _, item := range items {
result = append(result, dto.CustomMenuItem(item))
}
return result
}
// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
@ -1314,14 +1330,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
return
}
// Auto-generate ID if missing
if strings.TrimSpace(item.ID) == "" {
id, err := generateMenuItemID()
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID")
return
}
items[i].ID = id
items[i].ID = ""
} else if len(item.ID) > maxMenuItemIDLen {
response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)")
return
@ -1333,13 +1343,49 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
// ID uniqueness check
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if strings.TrimSpace(item.ID) == "" {
continue
}
if _, exists := seen[item.ID]; exists {
response.BadRequest(c, "Duplicate custom menu item ID: "+item.ID)
return
}
seen[item.ID] = struct{}{}
}
menuBytes, err := json.Marshal(items)
frontendURLForCustomMenuTrust := req.FrontendURL
if frontendURLForCustomMenuTrust == "" {
frontendURLForCustomMenuTrust = previousSettings.FrontendURL
}
normalizedItems, err := service.NormalizeCustomMenuItemsForWrite(
customMenuItemsToService(items),
previousSettings.CustomMenuItems,
frontendURLForCustomMenuTrust,
h.settingService.Config(),
)
if err != nil {
response.ErrorFrom(c, err)
return
}
for i := range normalizedItems {
if strings.TrimSpace(normalizedItems[i].ID) != "" {
continue
}
id, err := generateMenuItemID()
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID")
return
}
normalizedItems[i].ID = id
}
seen = make(map[string]struct{}, len(normalizedItems))
for _, item := range normalizedItems {
if _, exists := seen[item.ID]; exists {
response.BadRequest(c, "Duplicate custom menu item ID: "+item.ID)
return
}
seen[item.ID] = struct{}{}
}
menuBytes, err := json.Marshal(customMenuItemsFromService(normalizedItems))
if err != nil {
response.BadRequest(c, "Failed to serialize custom menu items")
return

View File

@ -0,0 +1,331 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSettingHandler_UpdateSettings_CustomMenuNewItemDefaultsSafe(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{}, "")
rec := updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"id": "shop",
"label": "Shop",
"url": "https://shop.example.com/pay",
"visibility": "user",
}})
require.Equal(t, http.StatusOK, rec.Code)
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.NotNil(t, items[0].OmitAuthContext)
require.True(t, *items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyMissingFieldPreservedForSameTrustedURL(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"id":"legacy","label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"id": "legacy",
"label": "Legacy Renamed",
"url": "https://app.example.com/internal",
"visibility": "user",
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.Nil(t, items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyWithoutIDPreservedByFingerprint(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"label": "Legacy",
"url": "https://app.example.com/internal",
"visibility": "user",
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.NotEmpty(t, items[0].ID)
require.Nil(t, items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyUntrustedSameOriginPreserved(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"id":"legacy","label":"Legacy","url":"https://shop.example.com/old","visibility":"user"}]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"id": "legacy",
"label": "Legacy Renamed",
"url": "https://shop.example.com/new",
"visibility": "user",
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.Nil(t, items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyMovedToUntrustedExternalBecomesSafe(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"id":"legacy","label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"id": "legacy",
"label": "Legacy",
"url": "https://shop.example.com/pay",
"visibility": "user",
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.NotNil(t, items[0].OmitAuthContext)
require.True(t, *items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuRejectsExplicitFalseForUntrustedURL(t *testing.T) {
_, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, "")
updateCustomMenuSettings(t, handler, http.StatusBadRequest, []map[string]any{{
"id": "leak",
"label": "Leak",
"url": "https://shop.example.com/pay",
"visibility": "user",
"omit_auth_context": false,
}})
}
func TestSettingHandler_UpdateSettings_CustomMenuStoredFrontendURLDoesNotSelfAuthorize(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{}, `[]`)
repo.values[service.SettingKeyFrontendURL] = "https://app.example.com"
updateCustomMenuSettings(t, handler, http.StatusBadRequest, []map[string]any{{
"id": "internal",
"label": "Internal",
"url": "https://app.example.com/internal",
"visibility": "user",
"omit_auth_context": false,
}})
}
func TestSettingHandler_UpdateSettings_CustomMenuTrustUsesEnvAllowlist(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Security: config.SecurityConfig{
CustomMenuAuthContextTrustedOrigins: []string{"https://app.example.com"},
},
}, `[]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"id": "internal",
"label": "Internal",
"url": "https://app.example.com/internal",
"visibility": "user",
"omit_auth_context": false,
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.NotNil(t, items[0].OmitAuthContext)
require.False(t, *items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuNewItemsWithoutIDGetUniqueIDs(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{}, "")
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{
{
"label": "Shop One",
"url": "https://shop.example.com/one",
"visibility": "user",
},
{
"label": "Shop Two",
"url": "https://shop.example.com/two",
"visibility": "user",
},
})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 2)
require.NotEmpty(t, items[0].ID)
require.NotEmpty(t, items[1].ID)
require.NotEqual(t, items[0].ID, items[1].ID)
require.NotNil(t, items[0].OmitAuthContext)
require.True(t, *items[0].OmitAuthContext)
require.NotNil(t, items[1].OmitAuthContext)
require.True(t, *items[1].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyItemsWithoutIDRoundTrip(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[
{"label":"Legacy One","url":"https://app.example.com/one","visibility":"user"},
{"label":"Legacy Two","url":"https://app.example.com/two","visibility":"user"}
]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{
{
"label": "Legacy One Renamed",
"url": "https://app.example.com/one",
"visibility": "user",
},
{
"label": "Legacy Two Renamed",
"url": "https://app.example.com/two",
"visibility": "user",
},
})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 2)
require.NotEmpty(t, items[0].ID)
require.NotEmpty(t, items[1].ID)
require.NotEqual(t, items[0].ID, items[1].ID)
require.Nil(t, items[0].OmitAuthContext)
require.Nil(t, items[1].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyWithoutIDRenamePreservesMissingField(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"label": "Legacy Renamed",
"url": "https://app.example.com/internal",
"visibility": "user",
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.NotEmpty(t, items[0].ID)
require.Nil(t, items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyExplicitFalseRoundTripPreserved(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"id":"legacy","label":"Legacy","url":"https://shop.example.com/old","visibility":"user","omit_auth_context":false}]`)
updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{
"id": "legacy",
"label": "Legacy Renamed",
"url": "https://shop.example.com/new",
"visibility": "user",
"omit_auth_context": false,
}})
items := storedCustomMenuItems(t, repo)
require.Len(t, items, 1)
require.NotNil(t, items[0].OmitAuthContext)
require.False(t, *items[0].OmitAuthContext)
}
func TestSettingHandler_UpdateSettings_CustomMenuLegacyExplicitFalseMovedToNewUntrustedRejected(t *testing.T) {
_, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[{"id":"legacy","label":"Legacy","url":"https://shop.example.com/old","visibility":"user","omit_auth_context":false}]`)
updateCustomMenuSettings(t, handler, http.StatusBadRequest, []map[string]any{{
"id": "legacy",
"label": "Legacy",
"url": "https://other.example.com/pay",
"visibility": "user",
"omit_auth_context": false,
}})
}
func TestSettingHandler_GetSettings_CustomMenuReturnsStoredValue(t *testing.T) {
repo, handler := newCustomMenuSettingHandler(t, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}, `[
{"id":"legacy","label":"Legacy","url":"https://shop.example.com/pay","visibility":"user"},
{"id":"unsafe","label":"Unsafe","url":"https://shop.example.com/pay","visibility":"admin","omit_auth_context":false},
{"id":"trusted","label":"Trusted","url":"https://app.example.com/internal","visibility":"user"}
]`)
_ = repo
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
handler.GetSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data := resp.Data.(map[string]any)
rawItems, err := json.Marshal(data["custom_menu_items"])
require.NoError(t, err)
var items []dto.CustomMenuItem
require.NoError(t, json.Unmarshal(rawItems, &items))
require.Len(t, items, 3)
require.Nil(t, items[0].OmitAuthContext)
require.NotNil(t, items[1].OmitAuthContext)
require.False(t, *items[1].OmitAuthContext)
require.Nil(t, items[2].OmitAuthContext)
}
func newCustomMenuSettingHandler(t *testing.T, cfg *config.Config, existingCustomMenuJSON string) (*settingHandlerRepoStub, *SettingHandler) {
t.Helper()
gin.SetMode(gin.TestMode)
values := map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
}
if existingCustomMenuJSON != "" {
values[service.SettingKeyCustomMenuItems] = existingCustomMenuJSON
}
repo := &settingHandlerRepoStub{values: values}
svc := service.NewSettingService(repo, cfg)
return repo, NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
}
func updateCustomMenuSettings(t *testing.T, handler *SettingHandler, wantStatus int, items []map[string]any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{
"promo_code_enabled": true,
"custom_menu_items": items,
}
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, wantStatus, rec.Code, rec.Body.String())
return rec
}
func storedCustomMenuItems(t *testing.T, repo *settingHandlerRepoStub) []dto.CustomMenuItem {
t.Helper()
raw := repo.values[service.SettingKeyCustomMenuItems]
require.NotEmpty(t, raw)
var items []dto.CustomMenuItem
require.NoError(t, json.Unmarshal([]byte(raw), &items))
return items
}

View File

@ -9,13 +9,14 @@ import (
// CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct {
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
OmitAuthContext *bool `json:"omit_auth_context,omitempty"`
}
// CustomEndpoint represents an admin-configured API endpoint for quick copy.

View File

@ -0,0 +1,53 @@
package dto
import (
"encoding/json"
"testing"
)
func TestCustomMenuItemOmitAuthContextTriState(t *testing.T) {
tests := []struct {
name string
rawJSON string
wantNil bool
want bool
}{
{
name: "missing keeps nil",
rawJSON: `{"id":"legacy","label":"Legacy","url":"https://app.example.com/legacy","visibility":"user"}`,
wantNil: true,
},
{
name: "explicit false keeps pointer false",
rawJSON: `{"id":"trusted","label":"Trusted","url":"https://app.example.com/internal","visibility":"user","omit_auth_context":false}`,
want: false,
},
{
name: "explicit true keeps pointer true",
rawJSON: `{"id":"external","label":"External","url":"https://shop.example.com","visibility":"user","omit_auth_context":true}`,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var item CustomMenuItem
if err := json.Unmarshal([]byte(tt.rawJSON), &item); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if tt.wantNil {
if item.OmitAuthContext != nil {
t.Fatalf("OmitAuthContext = %v, want nil", *item.OmitAuthContext)
}
return
}
if item.OmitAuthContext == nil {
t.Fatalf("OmitAuthContext = nil, want %v", tt.want)
}
if *item.OmitAuthContext != tt.want {
t.Fatalf("OmitAuthContext = %v, want %v", *item.OmitAuthContext, tt.want)
}
})
}
}

View File

@ -70,7 +70,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomMenuItems: dto.ParseUserVisibleMenuItems(service.ConvergeCustomMenuItemsJSONForRead(settings.CustomMenuItems, settings.FrontendURL, h.settingService.Config())),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,

View File

@ -0,0 +1,53 @@
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSettingHandler_GetPublicSettings_CustomMenuConvergesUntrustedOutput(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{
values: map[string]string{
service.SettingKeyCustomMenuItems: `[
{"id":"legacy","label":"Legacy","url":"https://shop.example.com/pay","visibility":"user"},
{"id":"unsafe","label":"Unsafe","url":"https://shop.example.com/pay","visibility":"user","omit_auth_context":false},
{"id":"trusted","label":"Trusted","url":"https://app.example.com/internal","visibility":"user"}
]`,
},
}, &config.Config{
Server: config.ServerConfig{FrontendURL: "https://app.example.com"},
}), "test-version")
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil)
h.GetPublicSettings(c)
require.Equal(t, http.StatusOK, recorder.Code)
var resp struct {
Code int `json:"code"`
Data struct {
CustomMenuItems []dto.CustomMenuItem `json:"custom_menu_items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Len(t, resp.Data.CustomMenuItems, 3)
require.NotNil(t, resp.Data.CustomMenuItems[0].OmitAuthContext)
require.True(t, *resp.Data.CustomMenuItems[0].OmitAuthContext)
require.NotNil(t, resp.Data.CustomMenuItems[1].OmitAuthContext)
require.True(t, *resp.Data.CustomMenuItems[1].OmitAuthContext)
require.Nil(t, resp.Data.CustomMenuItems[2].OmitAuthContext)
}

View File

@ -0,0 +1,180 @@
package service
import (
"encoding/json"
"net/url"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// CustomMenuAuthContextItem 是菜单认证上下文策略使用的最小数据契约。
type CustomMenuAuthContextItem struct {
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"`
SortOrder int `json:"sort_order"`
OmitAuthContext *bool `json:"omit_auth_context,omitempty"`
}
// NormalizeCustomMenuItemsForWrite 在保存 custom_menu_items 前补齐安全默认值并校验显式传认证上下文的 URL。
func NormalizeCustomMenuItemsForWrite(items []CustomMenuAuthContextItem, previousRaw, frontendURL string, cfg *config.Config) ([]CustomMenuAuthContextItem, error) {
previousItems, previousByID, previousByFingerprint := indexPreviousCustomMenuItems(previousRaw)
normalized := make([]CustomMenuAuthContextItem, len(items))
copy(normalized, items)
for i := range normalized {
item := &normalized[i]
oldItem, existed := matchPreviousCustomMenuItem(i, *item, previousItems, previousByID, previousByFingerprint)
newTrusted := IsCustomMenuAuthContextTrustedURL(item.URL, frontendURL, cfg)
if item.OmitAuthContext == nil {
switch {
case !existed:
item.OmitAuthContext = customMenuBoolPtr(true)
case oldItem.OmitAuthContext == nil && movedToNewUntrustedOrigin(oldItem.URL, item.URL, newTrusted):
item.OmitAuthContext = customMenuBoolPtr(true)
case oldItem.OmitAuthContext == nil:
// 旧缺省菜单只在跨到新的非受信 origin 时固化为 true普通保存保持存储三态。
case oldItem.OmitAuthContext != nil:
item.OmitAuthContext = customMenuBoolPtr(*oldItem.OmitAuthContext)
}
}
if item.OmitAuthContext != nil && !*item.OmitAuthContext && !IsMarkdownCustomMenuURL(item.URL) && !newTrusted && !isPreservedExistingUntrustedAuthContextFalse(oldItem, existed, item.URL) {
return nil, ErrCustomMenuAuthContextUntrusted
}
}
return normalized, nil
}
// ConvergeCustomMenuItemsForRead 对输出值做安全收敛,避免受信配置撤销后继续向非受信 iframe 传 token。
func ConvergeCustomMenuItemsForRead(items []CustomMenuAuthContextItem, frontendURL string, cfg *config.Config) []CustomMenuAuthContextItem {
converged := make([]CustomMenuAuthContextItem, len(items))
copy(converged, items)
for i := range converged {
item := &converged[i]
if IsMarkdownCustomMenuURL(item.URL) || IsCustomMenuAuthContextTrustedURL(item.URL, frontendURL, cfg) {
continue
}
if item.OmitAuthContext == nil || !*item.OmitAuthContext {
item.OmitAuthContext = customMenuBoolPtr(true)
}
}
return converged
}
// ConvergeCustomMenuItemsJSONForRead 对 raw JSON 菜单做输出收敛,非法 JSON 保持空数组语义。
func ConvergeCustomMenuItemsJSONForRead(raw, frontendURL string, cfg *config.Config) string {
items := ParseCustomMenuAuthContextItems(raw)
if len(items) == 0 {
return "[]"
}
data, err := json.Marshal(ConvergeCustomMenuItemsForRead(items, frontendURL, cfg))
if err != nil {
return "[]"
}
return string(data)
}
// ParseCustomMenuAuthContextItems 解析菜单 JSON保留 omit_auth_context 的 nil/false/true 三态。
func ParseCustomMenuAuthContextItems(raw string) []CustomMenuAuthContextItem {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []CustomMenuAuthContextItem{}
}
var items []CustomMenuAuthContextItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []CustomMenuAuthContextItem{}
}
return items
}
// IsCustomMenuAuthContextTrustedURL 判断菜单 URL 是否允许携带认证上下文;只看配置,不信请求 Origin。
func IsCustomMenuAuthContextTrustedURL(rawURL, frontendURL string, cfg *config.Config) bool {
origin := extractCustomMenuOrigin(rawURL)
if origin == "" {
return false
}
if cfg != nil {
if cfgFrontendOrigin := extractCustomMenuOrigin(cfg.Server.FrontendURL); cfgFrontendOrigin != "" && origin == cfgFrontendOrigin {
return true
}
for _, trusted := range cfg.Security.CustomMenuAuthContextTrustedOrigins {
if trustedOrigin := extractCustomMenuOrigin(trusted); trustedOrigin != "" && origin == trustedOrigin {
return true
}
}
}
return false
}
func IsMarkdownCustomMenuURL(rawURL string) bool {
return strings.HasPrefix(strings.TrimSpace(rawURL), "md:")
}
func indexPreviousCustomMenuItems(raw string) ([]CustomMenuAuthContextItem, map[string]CustomMenuAuthContextItem, map[string]CustomMenuAuthContextItem) {
previousItems := ParseCustomMenuAuthContextItems(raw)
byID := map[string]CustomMenuAuthContextItem{}
byFingerprint := map[string]CustomMenuAuthContextItem{}
for _, item := range previousItems {
if id := strings.TrimSpace(item.ID); id != "" {
byID[id] = item
}
byFingerprint[customMenuLegacyFingerprint(item)] = item
}
return previousItems, byID, byFingerprint
}
func matchPreviousCustomMenuItem(index int, item CustomMenuAuthContextItem, previousItems []CustomMenuAuthContextItem, byID, byFingerprint map[string]CustomMenuAuthContextItem) (CustomMenuAuthContextItem, bool) {
if id := strings.TrimSpace(item.ID); id != "" {
if oldItem, ok := byID[id]; ok {
return oldItem, true
}
}
if oldItem, ok := byFingerprint[customMenuLegacyFingerprint(item)]; ok {
return oldItem, true
}
if strings.TrimSpace(item.ID) == "" && index >= 0 && index < len(previousItems) && strings.TrimSpace(previousItems[index].ID) == "" {
return previousItems[index], true
}
return CustomMenuAuthContextItem{}, false
}
func customMenuLegacyFingerprint(item CustomMenuAuthContextItem) string {
return strings.TrimSpace(item.URL) + "\x00" + strings.TrimSpace(item.Label) + "\x00" + strings.TrimSpace(item.Visibility)
}
func movedToNewUntrustedOrigin(oldURL, newURL string, newTrusted bool) bool {
if newTrusted || IsMarkdownCustomMenuURL(newURL) {
return false
}
return extractCustomMenuOrigin(oldURL) != extractCustomMenuOrigin(newURL)
}
func isPreservedExistingUntrustedAuthContextFalse(oldItem CustomMenuAuthContextItem, existed bool, newURL string) bool {
if !existed || oldItem.OmitAuthContext == nil || *oldItem.OmitAuthContext {
return false
}
return extractCustomMenuOrigin(oldItem.URL) == extractCustomMenuOrigin(newURL)
}
func extractCustomMenuOrigin(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return ""
}
return scheme + "://" + strings.ToLower(u.Host)
}
func customMenuBoolPtr(v bool) *bool {
return &v
}

View File

@ -54,6 +54,10 @@ var (
"DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE",
"default subscription group cannot be duplicated",
)
ErrCustomMenuAuthContextUntrusted = infraerrors.BadRequest(
"CUSTOM_MENU_AUTH_CONTEXT_UNTRUSTED",
"custom menu item URL must be trusted when omit_auth_context=false",
)
)
type SettingRepository interface {
@ -613,6 +617,14 @@ func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *Setti
}
}
// Config 返回当前服务使用的配置,只读调用方用它复用同一安全策略。
func (s *SettingService) Config() *config.Config {
if s == nil {
return nil
}
return s.cfg
}
// SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
s.defaultSubGroupReader = reader
@ -685,6 +697,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyDocURL,
SettingKeyHomeContent,
SettingKeyHideCcsImportButton,
SettingKeyFrontendURL,
SettingKeyPurchaseSubscriptionEnabled,
SettingKeyPurchaseSubscriptionURL,
SettingKeyTableDefaultPageSize,
@ -809,6 +822,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
DocURL: settings[SettingKeyDocURL],
HomeContent: settings[SettingKeyHomeContent],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
FrontendURL: settings[SettingKeyFrontendURL],
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
TableDefaultPageSize: tableDefaultPageSize,
@ -1131,7 +1145,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
CustomMenuItems: filterUserVisibleMenuItems(ConvergeCustomMenuItemsJSONForRead(settings.CustomMenuItems, settings.FrontendURL, s.cfg)),
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,

View File

@ -4,6 +4,7 @@ package service
import (
"context"
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
@ -91,6 +92,49 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
require.True(t, settings.ForceEmailOnThirdPartySignup)
}
func TestSettingService_GetPublicSettingsForInjection_ConvergesCustomMenuAuthContext(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{
values: map[string]string{
SettingKeyCustomMenuItems: `[
{"id":"legacy","label":"Legacy","url":"https://shop.example.com/pay","visibility":"user"},
{"id":"trusted","label":"Trusted","url":"https://app.example.com/internal","visibility":"user"}
]`,
SettingKeyFrontendURL: "https://app.example.com",
},
}, &config.Config{})
payload, err := svc.GetPublicSettingsForInjection(context.Background())
require.NoError(t, err)
injection := payload.(*PublicSettingsInjectionPayload)
var items []CustomMenuAuthContextItem
require.NoError(t, json.Unmarshal(injection.CustomMenuItems, &items))
require.Len(t, items, 2)
require.NotNil(t, items[0].OmitAuthContext)
require.True(t, *items[0].OmitAuthContext)
require.NotNil(t, items[1].OmitAuthContext)
require.True(t, *items[1].OmitAuthContext)
}
func TestSettingService_GetPublicSettingsForInjection_PreservesTrustedDeploymentFrontendURL(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{
values: map[string]string{
SettingKeyCustomMenuItems: `[
{"id":"legacy","label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}
]`,
},
}, &config.Config{Server: config.ServerConfig{FrontendURL: "https://app.example.com"}})
payload, err := svc.GetPublicSettingsForInjection(context.Background())
require.NoError(t, err)
injection := payload.(*PublicSettingsInjectionPayload)
var items []CustomMenuAuthContextItem
require.NoError(t, json.Unmarshal(injection.CustomMenuItems, &items))
require.Len(t, items, 1)
require.Nil(t, items[0].OmitAuthContext)
}
func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{
values: map[string]string{

View File

@ -254,6 +254,7 @@ type PublicSettings struct {
HomeContent string
HideCcsImportButton bool
FrontendURL string
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string
TableDefaultPageSize int

View File

@ -0,0 +1,117 @@
# 自定义菜单安全嵌入第三方商铺页执行方案
## 目标
在 Sub2API 用户侧补充一级菜单入口,让用户点击后能在站内看到 `https://pay.ldxp.cn/shop/5HAP5JVN` 商铺页,同时避免把 Sub2API 登录凭据泄漏给第三方页面。
## 现状事实
- 前端已有自定义菜单能力:后台配置 `custom_menu_items` 后,用户侧菜单会生成 `/custom/:id` 一级入口。
- `CustomPageView` 当前会用 `buildEmbeddedUrl` 给 iframe URL 追加 `user_id``token``theme``lang``ui_mode``src_host``src_url`
- 后端 `GetFrameSrcOrigins` 会从 `custom_menu_items` 提取 http(s) origin并动态加入 CSP `frame-src`,因此新增商铺 URL 不需要手工改 CSP。
- `pay.ldxp.cn` 首个 HTML 响应未返回 `X-Frame-Options``Content-Security-Policy: frame-ancestors` 禁止嵌入,但后续支付跳转仍可能受第三方页面策略影响。
## 非目标
- 不接入 ldxp 支付回调、验签、订单幂等、余额或订阅自动发放。
- 不重做菜单系统或新增孤立支付页。
- 不改变已有自定义菜单的默认运行行为。
## 修订后的方案
```mermaid
flowchart LR
A[管理员配置自定义菜单] --> B{omit_auth_context}
B -->|旧记录缺省| C[兼容旧行为: 追加用户上下文]
B -->|新记录缺省或 true| D[安全嵌入: 不追加敏感上下文]
B -->|显式 false 且目标受信| C
B -->|显式 false 且目标不受信| G[后端拒绝保存]
C --> H[后端读取时按当前 trust policy 二次收敛]
D --> H
H --> E[/custom/:id iframe]
E --> F[外部商铺页]
```
### 数据契约
`CustomMenuItem` 增加三态字段:
```json
{
"id": "shop",
"label": "购买套餐",
"url": "https://pay.ldxp.cn/shop/5HAP5JVN",
"visibility": "user",
"sort_order": 0,
"omit_auth_context": true
}
```
语义:
- `omit_auth_context=true`:安全嵌入,不追加 `user_id``token``src_host``src_url`
- `omit_auth_context=false`:显式允许传递认证上下文,只允许用于部署配置或环境变量声明的受信域名,继续追加 `user_id``token``src_host``src_url`
- `omit_auth_context` 缺省:
- 已存在的旧菜单项保持 legacy 行为,避免破坏依赖 token 的既有内部嵌入页。
- 新增菜单项由后端保存时补成 `true`,保证非 UI API 写入方也走安全默认。
- 已存在旧菜单项如果 URL 变更到新的非受信 origin后端保存时自动补成 `true`,不允许继续以 legacy 缺省传 token。
- 安全嵌入仍保留 `theme``lang``ui_mode=embedded`,用于页面外观和嵌入态提示。
受信域名规则:
- 与部署配置 `SERVER_FRONTEND_URL` / `cfg.Server.FrontendURL` 同源的 URL 自动视为受信;数据库/后台设置里的 `frontend_url` 不作为信任根,避免管理员或被写入的配置自授权第三方页面接收 token。
- 非同源 URL 只有在 `CUSTOM_MENU_AUTH_CONTEXT_TRUSTED_ORIGINS` 环境变量中显式列出 origin 时,才允许保存 `omit_auth_context=false`
- 首版不在后台 UI 暴露受信域名配置,避免把安全边界扩成站点配置面;需要传 token 的内部嵌入页由运维显式配置环境变量。
后端输出规则:
- 存储值为 `true`:输出 `true`
- 存储值为 `false`:如果当前 URL 仍受信,输出 `false`;如果当前 URL 不受信,输出 `true`,安全降级。
- 存储值缺省:如果当前 URL 受部署配置或环境变量信任,保持缺省输出,让旧内部页继续 legacy如果当前 URL 不受信,输出 `true`,安全降级。
- 因此前端只消费后端返回的 `omit_auth_context`,不自行判断受信域名。
### 前端实施点
- `frontend/src/types/index.ts`:给 `CustomMenuItem` 增加 `omit_auth_context?: boolean`
- `frontend/src/utils/embedded-url.ts`:给 `buildEmbeddedUrl` 增加 options 参数,用于控制是否追加敏感上下文。
- `frontend/src/views/user/CustomPageView.vue`:读取 `menuItem.omit_auth_context`,决定是否传递用户凭据和来源信息。
- `frontend/src/views/user/CustomPageView.vue`:进入 `/custom/:id` 时强制刷新 public settings避免受信策略撤销后继续使用旧缓存拼出带 token 的 iframe URL。
- `frontend/src/views/user/CustomPageView.vue`:安全模式 iframe 增加 `referrerpolicy="no-referrer"`sandbox 首版不启用,因为支付页常依赖脚本、表单、弹窗和跳转,错误 sandbox 更容易造成支付流程不可用。安全兜底依赖“不传 token + 新窗口打开”。
- `frontend/src/views/admin/SettingsView.vue`:自定义菜单配置区增加“安全嵌入第三方页面”开关,文案明确开启后不传用户 token`addMenuItem()` 新建项默认 `omit_auth_context: true`
- `frontend/src/views/admin/SettingsView.vue`:加载已有设置时不得把缺省字段补成 `true`,必须保持 legacy 缺省值原样 round-trip。
- `frontend/src/i18n/locales/zh.ts``frontend/src/i18n/locales/en.ts`:补中文/英文文案。
### 后端实施点
- `backend/internal/handler/dto/settings.go`:给 `CustomMenuItem` 增加 `OmitAuthContext *bool 'json:"omit_auth_context,omitempty"'`,保留缺省、显式 false、显式 true 三种状态。
- `backend/internal/config/config.go`:增加 `custom_menu_auth_context_trusted_origins` 配置,环境变量为 `CUSTOM_MENU_AUTH_CONTEXT_TRUSTED_ORIGINS`,逗号分隔 origin。
- `backend/internal/handler/admin/setting_handler.go`:保存 `custom_menu_items` 时解析旧配置,优先按 ID 判断新旧项;如果旧数据或旧客户端没有 ID则用 `url + label + visibility` 作为 legacy 指纹兜底识别旧项。
- `backend/internal/handler/admin/setting_handler.go`:新项缺省补 `true`;旧项缺省且 URL 未跨到非受信 origin 时保持缺省。
- `backend/internal/handler/admin/setting_handler.go`:旧项缺省但 URL 变更到新的非受信 origin 时自动补 `true`,阻断旧 ID 缺省绕过。
- `backend/internal/handler/admin/setting_handler.go`:当菜单项最终为 `omit_auth_context=false` 且 URL 非 `md:` 时,校验目标 origin 必须是同源或配置在受信 origin 列表内,否则拒绝保存。
- `backend/internal/service/setting_service.go` 或 DTO 输出层:对 admin/public settings 返回的 `custom_menu_items` 做 effective 安全收敛;受信配置撤销后,即使存储里仍是缺省或 false输出也必须降级成 `omit_auth_context=true`
- `backend/internal/service/setting_service.go`CSP origin 提取保持不变;该字段不影响 iframe 可加载域名。
## 风险与回滚
- iframe 兼容风险:第三方支付流程可能在后续 JS 或支付跳转阶段拒绝 iframe保留“新窗口打开”按钮作为兜底。
- 兼容风险:旧菜单字段缺省时保持旧行为,避免已有内部嵌入页依赖 token 时失效;新建菜单默认安全。
- 安全风险:管理员关闭该开关并配置第三方域名时可能泄漏 token后端通过同源/受信 origin 校验阻断默认外泄路径。
- 回滚方式:关闭菜单项、删除菜单项,或将部署回退到上一个 Docker 镜像。
## 验收标准
- 旧 `custom_menu_items` 不包含 `omit_auth_context` 且 URL 属于部署配置或环境变量声明的受信 origin 时iframe URL 仍包含 `token``user_id`
- 旧 `custom_menu_items` 不包含 `omit_auth_context` 且 URL 不受信时public/injection 输出会补 `omit_auth_context=true`iframe URL 不再包含 `token``user_id`
- 旧 `custom_menu_items` 没有 `id` 且没有 `omit_auth_context` 时,普通保存不会静默改成安全模式;只有 URL 跨到非受信外部 origin 才安全化。
- 旧 `custom_menu_items` 不包含 `omit_auth_context` 且 URL 被改到非受信外部 origin 时,保存后自动变为 `omit_auth_context=true`
- 新建 `custom_menu_items` 不包含 `omit_auth_context` 时,后端保存后补成 `omit_auth_context=true`
- 读取 admin/public settings 时,非受信 URL 的缺省/显式 false 项都会以 `omit_auth_context=true` 输出,前端不会继续传 token。
- `omit_auth_context=true`iframe URL 不包含 `token``user_id``src_host``src_url`,并设置 `referrerpolicy="no-referrer"`
- 进入 `/custom/:id` 会强制刷新 public settings降低已打开会话继续使用撤销前信任策略的风险。
- 非受信外部 URL 不允许保存 `omit_auth_context=false`
- 后台新增自定义菜单默认开启安全嵌入开关,适合第三方商铺页;已有缺省菜单不会因打开设置页并保存而被静默改写。
- 前端单元测试覆盖 URL 拼接两种模式。
- 后端 DTO 测试覆盖 `omit_auth_context` 的缺省、显式 false、显式 true 三态 JSON 解析。
- 后端设置保存测试覆盖新建默认安全、旧项缺省保真、非受信域名拒绝显式传上下文。
- 前端构建和后端相关测试通过。

View File

@ -0,0 +1,70 @@
# 自定义菜单安全嵌入部署
## 背景
用户需要在 Sub2API 用户侧增加一级菜单,让用户点击后在站内看到第三方商铺页:
- 菜单名称:`购买套餐`
- 页面地址:`https://pay.ldxp.cn/shop/5HAP5JVN`
- 入口路径:`/custom/shop`
核心安全边界是第三方 iframe 不应收到 Sub2API 的用户 ID、登录 token 或来源 URL。
## 本次实现
- `custom_menu_items` 增加三态字段 `omit_auth_context`
- `true`:安全嵌入,不追加 `user_id``token``src_host``src_url`
- `false`:允许追加认证上下文,但只允许部署配置或环境变量声明的受信 origin。
- 缺省兼容旧菜单public/injection 输出时如果 URL 不受信,会收敛为 `true`
- 新增菜单默认安全嵌入,后台 UI 增加“安全嵌入第三方页面”开关。
- 受信根只来自 `cfg.Server.FrontendURL``CUSTOM_MENU_AUTH_CONTEXT_TRUSTED_ORIGINS`,数据库/后台设置里的 `frontend_url` 不作为自授权信任根。
- 用户进入 `/custom/:id` 时强制刷新 public settings降低信任策略撤销后的旧缓存风险。
- admin-only 自定义页面 fail closed即使来自 admin settings也不向 iframe 传 token。
- 侧边栏左上角不再渲染版本号。
## 线上部署
- 服务器:`个人-US-racknerd-0526-3c4g`
- IP`216.45.59.243`
- 部署目录:`/opt/sub2api`
- 镜像:`sub2api:custom-menu-safe-embed-20260527`
- 镜像 ID`sha256:356653e0e5a7b688faacb728a0f94d450e102393fd73bb16c705000f359133de`
- 部署方式:本地构建 linux/amd64 Go 二进制并嵌入前端,上传 runtime patch tar 到远程,在远程基于 `weishaw/sub2api:latest` 替换二进制和资源后 `docker commit` 生成本地镜像。
- runtime patch`/tmp/sub2api-custom-menu-runtime-patch-20260527.tar.gz`
- runtime patch SHA256`321b69bcf40470be9f2de10f8f1114ebd6bd7e6e014783b123c7160781ac127d`
- 部署前备份:`/opt/sub2api/backups/deploy-custom-menu-20260526-222849`
- 菜单配置备份:`/opt/sub2api/backups/custom-menu-shop-20260526-224153`
## 线上菜单配置
远程 PostgreSQL `settings.custom_menu_items` 当前配置:
```json
[
{
"id": "shop",
"label": "购买套餐",
"icon_svg": "",
"url": "https://pay.ldxp.cn/shop/5HAP5JVN",
"visibility": "user",
"sort_order": 0,
"omit_auth_context": true
}
]
```
直接改数据库后需要重启 `sub2api` 容器或通过应用设置接口触发缓存刷新,否则 CSP 动态 `frame-src` 可能暂时不包含新 iframe origin。
## 验证记录
- `https://catproxy.lilifamily.com/api/v1/settings/public` 返回 `shop` 菜单项。
- `https://proxy.api.lilifamily.com/api/v1/settings/public` 返回 `shop` 菜单项。
- `https://catproxy.lilifamily.com/custom/shop` 返回 `200`
- `https://proxy.api.lilifamily.com/custom/shop` 返回 `200`,响应头含 `X-Proxy-Ng-Node: jppro-akile-0504-1c1g`
- `/custom/shop` 的 CSP `frame-src` 已包含 `https://pay.ldxp.cn`
- `omit_auth_context=true` 时 iframe URL 不应包含 `token``user_id``src_host``src_url`
## 回滚
- 删除或清空 `settings.custom_menu_items` 中的 `shop` 项,并重启 `sub2api` 容器刷新 CSP 缓存。
- 或把 `/opt/sub2api/.env``SUB2API_IMAGE` 回退到部署前镜像,再执行 `docker compose up -d sub2api`

View File

@ -5694,6 +5694,8 @@ export default {
visibility: 'Visible To',
visibilityUser: 'Regular Users',
visibilityAdmin: 'Administrators',
safeEmbed: 'Safe third-party embed',
safeEmbedHint: 'When enabled, user ID, login token, and source URL are not sent to the embedded page. Theme, language, and embedded mode are still kept.',
add: 'Add Menu Item',
remove: 'Remove',
moveUp: 'Move Up',

View File

@ -5853,6 +5853,8 @@ export default {
visibility: '可见角色',
visibilityUser: '普通用户',
visibilityAdmin: '管理员',
safeEmbed: '安全嵌入第三方页面',
safeEmbedHint: '开启后不会向嵌入页面传递用户 ID、登录 token 或来源 URL仍会保留主题、语言和嵌入模式参数。',
add: '添加菜单项',
remove: '删除',
moveUp: '上移',

View File

@ -14,6 +14,7 @@ vi.mock('@/api/auth', () => ({
describe('useAppStore', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
vi.useFakeTimers()
localStorage.clear()
@ -332,5 +333,46 @@ describe('useAppStore', () => {
expect(localStorage.getItem('table-page-size')).toBeNull()
expect(localStorage.getItem('table-page-size-source')).toBeNull()
})
it('并发 fetchPublicSettings(force) 复用同一个请求结果', async () => {
vi.mocked(getPublicSettings).mockResolvedValue({
registration_enabled: false,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: 'Concurrent Site',
site_logo: '',
site_subtitle: '',
api_base_url: '',
contact_info: '',
doc_url: '',
home_content: '',
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
backend_mode_enabled: false,
version: '1.0.0'
})
const store = useAppStore()
const [first, second] = await Promise.all([
store.fetchPublicSettings(true),
store.fetchPublicSettings(true)
])
expect(getPublicSettings).toHaveBeenCalledTimes(1)
expect(first?.site_name).toBe('Concurrent Site')
expect(second?.site_name).toBe('Concurrent Site')
expect(store.publicSettingsLoaded).toBe(true)
})
})
})

View File

@ -32,6 +32,7 @@ export const useAppStore = defineStore('app', () => {
const apiBaseUrl = ref<string>('')
const docUrl = ref<string>('')
const cachedPublicSettings = ref<PublicSettings | null>(null)
let publicSettingsRequest: Promise<PublicSettings | null> | null = null
// Version cache state
const versionLoaded = ref<boolean>(false)
@ -362,21 +363,25 @@ export const useAppStore = defineStore('app', () => {
}
}
// Prevent duplicate requests
if (publicSettingsLoading.value) {
return null
if (publicSettingsRequest) {
return publicSettingsRequest
}
publicSettingsLoading.value = true
try {
publicSettingsRequest = (async () => {
publicSettingsLoading.value = true
const data = await fetchPublicSettingsAPI()
applySettings(data)
return data
})()
try {
return await publicSettingsRequest
} catch (error) {
console.error('Failed to fetch public settings:', error)
return null
} finally {
publicSettingsLoading.value = false
publicSettingsRequest = null
}
}

View File

@ -171,6 +171,7 @@ export interface CustomMenuItem {
page_slug?: string
visibility: 'user' | 'admin'
sort_order: number
omit_auth_context?: boolean
}
export interface CustomEndpoint {

View File

@ -45,6 +45,66 @@ describe('embedded-url', () => {
expect(url.searchParams.get('src_url')).toBe('https://app.example.com/user/purchase')
})
it('keeps legacy sensitive context when omitAuthContext is false', () => {
const result = buildEmbeddedUrl(
'https://pay.example.com/checkout',
42,
'token-123',
'dark',
'zh-CN',
{ omitAuthContext: false },
)
const url = new URL(result)
expect(url.searchParams.get('user_id')).toBe('42')
expect(url.searchParams.get('token')).toBe('token-123')
expect(url.searchParams.get('src_host')).toBe('https://app.example.com')
expect(url.searchParams.get('src_url')).toBe('https://app.example.com/user/purchase')
expect(url.searchParams.get('theme')).toBe('dark')
expect(url.searchParams.get('lang')).toBe('zh-CN')
expect(url.searchParams.get('ui_mode')).toBe('embedded')
})
it('keeps legacy sensitive context when omitAuthContext is omitted', () => {
const result = buildEmbeddedUrl(
'https://pay.example.com/checkout',
42,
'token-123',
'dark',
'zh-CN',
)
const url = new URL(result)
expect(url.searchParams.get('user_id')).toBe('42')
expect(url.searchParams.get('token')).toBe('token-123')
expect(url.searchParams.get('src_host')).toBe('https://app.example.com')
expect(url.searchParams.get('src_url')).toBe('https://app.example.com/user/purchase')
expect(url.searchParams.get('theme')).toBe('dark')
expect(url.searchParams.get('lang')).toBe('zh-CN')
expect(url.searchParams.get('ui_mode')).toBe('embedded')
})
it('omits sensitive auth context but keeps display context in safe mode', () => {
const result = buildEmbeddedUrl(
'https://pay.example.com/checkout?plan=pro',
42,
'token-123',
'dark',
'zh-CN',
{ omitAuthContext: true },
)
const url = new URL(result)
expect(url.searchParams.get('plan')).toBe('pro')
expect(url.searchParams.get('theme')).toBe('dark')
expect(url.searchParams.get('lang')).toBe('zh-CN')
expect(url.searchParams.get('ui_mode')).toBe('embedded')
expect(url.searchParams.has('user_id')).toBe(false)
expect(url.searchParams.has('token')).toBe(false)
expect(url.searchParams.has('src_host')).toBe(false)
expect(url.searchParams.has('src_url')).toBe(false)
})
it('omits optional params when they are empty', () => {
const result = buildEmbeddedUrl('https://pay.example.com/checkout', undefined, '', 'light')

View File

@ -13,20 +13,25 @@ const EMBEDDED_UI_MODE_VALUE = 'embedded'
const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host'
const EMBEDDED_SRC_QUERY_KEY = 'src_url'
export interface BuildEmbeddedUrlOptions {
omitAuthContext?: boolean
}
export function buildEmbeddedUrl(
baseUrl: string,
userId?: number,
authToken?: string | null,
theme: 'light' | 'dark' = 'light',
lang?: string,
options: BuildEmbeddedUrlOptions = {},
): string {
if (!baseUrl) return baseUrl
try {
const url = new URL(baseUrl)
if (userId) {
if (!options.omitAuthContext && userId) {
url.searchParams.set(EMBEDDED_USER_ID_QUERY_KEY, String(userId))
}
if (authToken) {
if (!options.omitAuthContext && authToken) {
url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken)
}
url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme)
@ -34,8 +39,8 @@ export function buildEmbeddedUrl(
url.searchParams.set(EMBEDDED_LANG_QUERY_KEY, lang)
}
url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE)
// Source tracking: let the embedded page know where it's being loaded from
if (typeof window !== 'undefined') {
// 来源信息会暴露当前登录态页面路径,安全嵌入模式下必须一并省略。
if (!options.omitAuthContext && typeof window !== 'undefined') {
url.searchParams.set(EMBEDDED_SRC_HOST_QUERY_KEY, window.location.origin)
url.searchParams.set(EMBEDDED_SRC_QUERY_KEY, window.location.href)
}

View File

@ -4908,6 +4908,26 @@
@update:model-value="(v: string) => (item.icon_svg = v)"
/>
</div>
<!-- Safe embed mode (full width) -->
<div
class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-800/60 sm:col-span-2"
>
<div>
<label
class="text-xs font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.customMenu.safeEmbed") }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.customMenu.safeEmbedHint") }}
</p>
</div>
<Toggle
:model-value="item.omit_auth_context === true"
@update:model-value="(value: boolean) => (item.omit_auth_context = value)"
/>
</div>
</div>
</div>
@ -7032,6 +7052,7 @@ const form = reactive<SettingsForm>({
url: string;
visibility: "user" | "admin";
sort_order: number;
omit_auth_context?: boolean;
}>,
custom_endpoints: [] as Array<{
name: string;
@ -7664,6 +7685,7 @@ function addMenuItem() {
url: "",
visibility: "user",
sort_order: form.custom_menu_items.length,
omit_auth_context: true,
});
}

View File

@ -163,6 +163,9 @@ vi.mock("vue-i18n", async () => {
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
"admin.settings.customMenu.add": "添加菜单项",
"admin.settings.customMenu.safeEmbed": "安全嵌入第三方页面",
"admin.settings.customMenu.safeEmbedHint": "开启后不会向嵌入页面传递用户 ID、登录 token 或来源 URL。",
"admin.settings.platformQuota.platform": "平台",
"admin.settings.platformQuota.daily": "日限额 (USD)",
"admin.settings.platformQuota.weekly": "周限额 (USD)",
@ -453,6 +456,16 @@ async function openPaymentTab(wrapper: ReturnType<typeof mountView>) {
await flushPromises();
}
async function openGeneralTab(wrapper: ReturnType<typeof mountView>) {
const generalTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.general"));
expect(generalTabButton).toBeDefined();
await generalTabButton?.trigger("click");
await flushPromises();
}
async function openSecurityTab(wrapper: ReturnType<typeof mountView>) {
const securityTabButton = wrapper
.findAll("button")
@ -754,6 +767,54 @@ describe("admin SettingsView payment visible method controls", () => {
expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片");
expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除");
});
it("creates new custom menu items in safe embed mode and submits the flag", async () => {
const wrapper = mountView();
await flushPromises();
await openGeneralTab(wrapper);
const addButton = wrapper
.findAll("button")
.find((node) => node.text().includes("添加菜单项"));
expect(addButton).toBeDefined();
await addButton?.trigger("click");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
const payload = updateSettings.mock.calls[0]?.[0];
expect(payload.custom_menu_items).toHaveLength(1);
expect(payload.custom_menu_items[0]).toEqual(
expect.objectContaining({
omit_auth_context: true,
}),
);
});
it("round-trips existing custom menu items without silently filling omit_auth_context", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
custom_menu_items: [
{
id: "legacy",
label: "Legacy",
icon_svg: "",
url: "https://internal.example.com/page",
visibility: "user",
sort_order: 0,
},
],
});
const wrapper = mountView();
await flushPromises();
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
const payload = updateSettings.mock.calls[0]?.[0];
expect(payload.custom_menu_items).toHaveLength(1);
expect(payload.custom_menu_items[0]).not.toHaveProperty("omit_auth_context");
});
});
describe("admin SettingsView wechat connect controls", () => {

View File

@ -107,6 +107,7 @@
<iframe
:src="embeddedUrl"
class="custom-embed-frame"
:referrerpolicy="safeEmbedMode ? 'no-referrer' : undefined"
allowfullscreen
></iframe>
</div>
@ -141,6 +142,7 @@ const authStore = useAuthStore()
const adminSettingsStore = useAdminSettingsStore()
const loading = ref(false)
const settingsReady = ref(false)
const pageTheme = ref<'light' | 'dark'>('light')
const renderedHtml = ref('')
const markdownContainer = ref<HTMLElement | null>(null)
@ -151,15 +153,34 @@ let themeObserver: MutationObserver | null = null
const menuItemId = computed(() => route.params.id as string)
const menuItem = computed(() => {
async function refreshPublicSettingsForCustomPage() {
settingsReady.value = false
loading.value = true
try {
const [settings] = await Promise.all([
appStore.fetchPublicSettings(true),
authStore.isAdmin ? adminSettingsStore.fetch(true) : Promise.resolve(),
])
settingsReady.value = Boolean(settings)
} finally {
loading.value = false
}
}
const publicMenuItem = computed(() => {
if (!settingsReady.value) return null
const id = menuItemId.value
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
const found = publicItems.find((item) => item.id === id) ?? null
if (found) return found
if (authStore.isAdmin) {
return adminSettingsStore.customMenuItems.find((item) => item.id === id) ?? null
}
return null
return publicItems.find((item) => item.id === id) ?? null
})
const adminMenuItem = computed(() => {
if (!settingsReady.value || !authStore.isAdmin || publicMenuItem.value) return null
return adminSettingsStore.customMenuItems.find((item) => item.id === menuItemId.value) ?? null
})
const menuItem = computed(() => {
return publicMenuItem.value ?? adminMenuItem.value
})
const markdownSlug = computed(() => {
@ -171,6 +192,7 @@ const markdownSlug = computed(() => {
})
const isMarkdownMode = computed(() => !!markdownSlug.value)
const safeEmbedMode = computed(() => Boolean(adminMenuItem.value) || menuItem.value?.omit_auth_context === true)
const embeddedUrl = computed(() => {
if (!menuItem.value || isMarkdownMode.value) return ''
@ -180,6 +202,7 @@ const embeddedUrl = computed(() => {
authStore.token,
pageTheme.value,
locale.value,
{ omitAuthContext: safeEmbedMode.value },
)
})
@ -342,6 +365,10 @@ watch(markdownSlug, (slug) => {
}
}, { immediate: true })
watch(menuItemId, () => {
refreshPublicSettingsForCustomPage()
})
onMounted(async () => {
pageTheme.value = detectTheme()
@ -355,13 +382,7 @@ onMounted(async () => {
})
}
if (appStore.publicSettingsLoaded) return
loading.value = true
try {
await appStore.fetchPublicSettings()
} finally {
loading.value = false
}
await refreshPublicSettingsForCustomPage()
})
onUnmounted(() => {

View File

@ -0,0 +1,254 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import CustomPageView from '../CustomPageView.vue'
enableAutoUnmount(afterEach)
const { appStore, authStore, adminSettingsStore, routeRef } = vi.hoisted(() => ({
appStore: {
publicSettingsLoaded: true,
cachedPublicSettings: {
custom_menu_items: [
{
id: 'shop',
label: 'Shop',
icon_svg: '',
url: 'https://pay.example.com/shop',
visibility: 'user',
sort_order: 0,
omit_auth_context: true,
},
],
},
fetchPublicSettings: vi.fn(),
},
authStore: {
isAdmin: false,
token: 'token-123',
user: { id: 42 },
},
adminSettingsStore: {
customMenuItems: [],
fetch: vi.fn(),
},
routeRef: {
current: {
params: { id: 'shop' },
},
},
}))
vi.mock('vue-router', async () => {
const { reactive } = await vi.importActual<typeof import('vue')>('vue')
routeRef.current = reactive(routeRef.current)
return {
useRoute: () => routeRef.current,
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
locale: { value: 'zh-CN' },
}),
}))
vi.mock('@/stores', () => ({
useAppStore: () => appStore,
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => authStore,
}))
vi.mock('@/stores/adminSettings', () => ({
useAdminSettingsStore: () => adminSettingsStore,
}))
vi.mock('@/components/layout/AppLayout.vue', () => ({
default: defineComponent({
template: '<div><slot /></div>',
}),
}))
vi.mock('@/components/icons/Icon.vue', () => ({
default: defineComponent({
template: '<span />',
}),
}))
describe('CustomPageView', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
appStore.publicSettingsLoaded = true
appStore.cachedPublicSettings = {
custom_menu_items: [
{
id: 'shop',
label: 'Shop',
icon_svg: '',
url: 'https://pay.example.com/shop',
visibility: 'user',
sort_order: 0,
omit_auth_context: true,
},
],
}
routeRef.current.params.id = 'shop'
adminSettingsStore.customMenuItems = []
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('forces public settings refresh when entering a custom page', async () => {
appStore.fetchPublicSettings.mockResolvedValueOnce(appStore.cachedPublicSettings)
mount(CustomPageView)
await flushPromises()
expect(appStore.fetchPublicSettings).toHaveBeenCalledWith(true)
expect(adminSettingsStore.fetch).not.toHaveBeenCalled()
})
it('refreshes admin settings for admin-only custom pages', async () => {
authStore.isAdmin = true
routeRef.current.params.id = 'admin-shop'
appStore.cachedPublicSettings = { custom_menu_items: [] }
adminSettingsStore.customMenuItems = [
{
id: 'admin-shop',
label: 'Admin Shop',
icon_svg: '',
url: 'https://pay.example.com/admin',
visibility: 'admin',
sort_order: 0,
omit_auth_context: false,
},
]
appStore.fetchPublicSettings.mockResolvedValueOnce(appStore.cachedPublicSettings)
adminSettingsStore.fetch.mockResolvedValueOnce(undefined)
mount(CustomPageView)
await flushPromises()
expect(adminSettingsStore.fetch).toHaveBeenCalledWith(true)
})
it('forces public settings refresh when custom page id changes', async () => {
appStore.fetchPublicSettings.mockResolvedValue(appStore.cachedPublicSettings)
const wrapper = mount(CustomPageView)
await flushPromises()
routeRef.current.params.id = 'other'
await wrapper.vm.$nextTick()
await flushPromises()
expect(appStore.fetchPublicSettings).toHaveBeenCalledTimes(2)
expect(appStore.fetchPublicSettings).toHaveBeenLastCalledWith(true)
})
it('does not render iframe from stale cached settings before forced refresh completes', async () => {
appStore.cachedPublicSettings = {
custom_menu_items: [
{
id: 'shop',
label: 'Stale Shop',
icon_svg: '',
url: 'https://pay.example.com/stale',
visibility: 'user',
sort_order: 0,
omit_auth_context: false,
},
],
}
appStore.fetchPublicSettings.mockReturnValueOnce(new Promise(() => {}))
const wrapper = mount(CustomPageView)
await flushPromises()
expect(wrapper.find('iframe').exists()).toBe(false)
})
it('does not fetch markdown from stale cached settings before forced refresh completes', async () => {
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
appStore.cachedPublicSettings = {
custom_menu_items: [
{
id: 'shop',
label: 'Stale Markdown',
icon_svg: '',
url: 'md:stale-page',
visibility: 'user',
sort_order: 0,
},
],
}
appStore.fetchPublicSettings.mockReturnValueOnce(new Promise(() => {}))
mount(CustomPageView)
await flushPromises()
expect(fetchSpy).not.toHaveBeenCalled()
})
it('uses no-referrer iframe and omits sensitive context for safe menu items', async () => {
appStore.fetchPublicSettings.mockResolvedValueOnce(appStore.cachedPublicSettings)
const wrapper = mount(CustomPageView)
await flushPromises()
const iframe = wrapper.get('iframe')
const src = iframe.attributes('src') ?? ''
const url = new URL(src)
expect(iframe.attributes('referrerpolicy')).toBe('no-referrer')
expect(url.searchParams.get('theme')).toBe('light')
expect(url.searchParams.get('lang')).toBe('zh-CN')
expect(url.searchParams.get('ui_mode')).toBe('embedded')
expect(url.searchParams.has('user_id')).toBe(false)
expect(url.searchParams.has('token')).toBe(false)
expect(url.searchParams.has('src_host')).toBe(false)
expect(url.searchParams.has('src_url')).toBe(false)
})
it('omits sensitive context for admin-only menu items even if admin settings contain legacy unsafe state', async () => {
authStore.isAdmin = true
routeRef.current.params.id = 'admin-shop'
appStore.cachedPublicSettings = { custom_menu_items: [] }
adminSettingsStore.customMenuItems = [
{
id: 'admin-shop',
label: 'Admin Shop',
icon_svg: '',
url: 'https://pay.example.com/admin',
visibility: 'admin',
sort_order: 0,
omit_auth_context: false,
},
]
appStore.fetchPublicSettings.mockResolvedValueOnce(appStore.cachedPublicSettings)
adminSettingsStore.fetch.mockResolvedValueOnce(undefined)
const wrapper = mount(CustomPageView)
await flushPromises()
const iframe = wrapper.get('iframe')
const src = iframe.attributes('src') ?? ''
const url = new URL(src)
expect(iframe.attributes('referrerpolicy')).toBe('no-referrer')
expect(url.searchParams.get('theme')).toBe('light')
expect(url.searchParams.get('lang')).toBe('zh-CN')
expect(url.searchParams.get('ui_mode')).toBe('embedded')
expect(url.searchParams.has('user_id')).toBe(false)
expect(url.searchParams.has('token')).toBe(false)
expect(url.searchParams.has('src_host')).toBe(false)
expect(url.searchParams.has('src_url')).toBe(false)
})
})

View File

@ -80,6 +80,7 @@ Sub2API
- token 配置:`/etc/nginx/proxy-ng/token.conf`,权限 `600 root root`
- token map 配置:`/etc/nginx/conf.d/proxy-ng-token-map.conf`,本地副本已脱敏
- 远程 `.env``PUBLIC_DOMAIN=proxy.api.lilifamily.com`
- 远程 `.env``SUB2API_IMAGE=sub2api:custom-menu-safe-embed-20260527`
- URL allowlist 当前关闭:`SECURITY_URL_ALLOWLIST_ENABLED=false`
## DNS state after proxy-ng hardening
@ -166,3 +167,34 @@ curl --noproxy '*' -i https://catproxy.lilifamily.com/health
- Sub2API 业务请求限速维度是用户、用户+分组、API Key 窗口额度;不是按 IP 限速。
- 生产库非敏感快照:当前用户全局 `rpm_limit=0`;部分分组配置 `20/30/40/100 RPM`API Key `briqt` 配置 `5h=300``1d=500`API Key `free` 配置 `1d=50`
- 本机测速与限速维度详见 `docs/memorys/2026-05-26-主站测速与限速核对.md`
### 2026-05-27 自定义菜单安全嵌入部署
- 目标:用户侧新增一级菜单 `购买套餐`,站内嵌入 `https://pay.ldxp.cn/shop/5HAP5JVN`
- 安全边界:菜单项配置 `omit_auth_context=true`iframe URL 不传 `token``user_id``src_host``src_url`
- 本地构建:前端 build 后交叉编译 linux/amd64 Go 二进制,使用 `-tags embed` 嵌入前端资源。
- 远程部署方式:上传 runtime patch tar在远程基于 `weishaw/sub2api:latest` 替换 `/app/sub2api``/app/resources``docker commit` 生成本地镜像。
- runtime patch`/tmp/sub2api-custom-menu-runtime-patch-20260527.tar.gz`
- runtime patch SHA256`321b69bcf40470be9f2de10f8f1114ebd6bd7e6e014783b123c7160781ac127d`
- 当前镜像:`sub2api:custom-menu-safe-embed-20260527`
- 镜像 ID`sha256:356653e0e5a7b688faacb728a0f94d450e102393fd73bb16c705000f359133de`
- 部署前备份:`/opt/sub2api/backups/deploy-custom-menu-20260526-222849`
- 菜单配置备份:`/opt/sub2api/backups/custom-menu-shop-20260526-224153`
- 线上数据库 `settings.custom_menu_items` 已写入:
```json
[
{
"id": "shop",
"label": "购买套餐",
"icon_svg": "",
"url": "https://pay.ldxp.cn/shop/5HAP5JVN",
"visibility": "user",
"sort_order": 0,
"omit_auth_context": true
}
]
```
- 写入数据库后已重启 `sub2api` 容器刷新 CSP frame-src 缓存。
- 验证结果:`sub2api` healthy`catproxy.lilifamily.com/health``proxy.api.lilifamily.com/health` 返回 `200``catproxy.lilifamily.com/custom/shop``proxy.api.lilifamily.com/custom/shop` 返回 `200`CSP `frame-src` 已包含 `https://pay.ldxp.cn`