From ac5ff854b213aeeb4a670552951f42e0a624a22e Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 27 May 2026 14:28:17 +0800 Subject: [PATCH] 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. --- backend/internal/config/config.go | 41 ++- backend/internal/config/config_test.go | 15 + .../internal/handler/admin/setting_handler.go | 62 +++- .../admin/setting_handler_custom_menu_test.go | 331 ++++++++++++++++++ backend/internal/handler/dto/settings.go | 15 +- .../handler/dto/settings_custom_menu_test.go | 53 +++ backend/internal/handler/setting_handler.go | 2 +- .../setting_handler_custom_menu_test.go | 53 +++ .../service/custom_menu_auth_context.go | 180 ++++++++++ backend/internal/service/setting_service.go | 16 +- .../service/setting_service_public_test.go | 44 +++ backend/internal/service/settings_view.go | 1 + ...026-05-27-custom-menu-safe-embed-执行版.md | 117 +++++++ .../2026-05-27-自定义菜单安全嵌入部署.md | 70 ++++ frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/stores/__tests__/app.spec.ts | 42 +++ frontend/src/stores/app.ts | 15 +- frontend/src/types/index.ts | 1 + .../src/utils/__tests__/embedded-url.spec.ts | 60 ++++ frontend/src/utils/embedded-url.ts | 13 +- frontend/src/views/admin/SettingsView.vue | 22 ++ .../admin/__tests__/SettingsView.spec.ts | 61 ++++ frontend/src/views/user/CustomPageView.vue | 49 ++- .../user/__tests__/CustomPageView.spec.ts | 254 ++++++++++++++ ops/remote/us-racknerd-0526-sub2api/README.md | 32 ++ 26 files changed, 1506 insertions(+), 47 deletions(-) create mode 100644 backend/internal/handler/admin/setting_handler_custom_menu_test.go create mode 100644 backend/internal/handler/dto/settings_custom_menu_test.go create mode 100644 backend/internal/handler/setting_handler_custom_menu_test.go create mode 100644 backend/internal/service/custom_menu_auth_context.go create mode 100644 docs/agent-specs/2026-05-27-custom-menu-safe-embed-执行版.md create mode 100644 docs/memorys/2026-05-27-自定义菜单安全嵌入部署.md create mode 100644 frontend/src/views/user/__tests__/CustomPageView.spec.ts diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f689c2a9..7b275c83 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 == "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 1eae5ed9..a969e3d7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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") diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 3c7fe581..5e7a9cf5 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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 diff --git a/backend/internal/handler/admin/setting_handler_custom_menu_test.go b/backend/internal/handler/admin/setting_handler_custom_menu_test.go new file mode 100644 index 00000000..0bc29d9a --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_custom_menu_test.go @@ -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 +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index eecf98ac..d2b7fb2b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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. diff --git a/backend/internal/handler/dto/settings_custom_menu_test.go b/backend/internal/handler/dto/settings_custom_menu_test.go new file mode 100644 index 00000000..d4cf75b8 --- /dev/null +++ b/backend/internal/handler/dto/settings_custom_menu_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 7413b840..9f7350f2 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -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, diff --git a/backend/internal/handler/setting_handler_custom_menu_test.go b/backend/internal/handler/setting_handler_custom_menu_test.go new file mode 100644 index 00000000..4b7d6945 --- /dev/null +++ b/backend/internal/handler/setting_handler_custom_menu_test.go @@ -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) +} diff --git a/backend/internal/service/custom_menu_auth_context.go b/backend/internal/service/custom_menu_auth_context.go new file mode 100644 index 00000000..245c2d27 --- /dev/null +++ b/backend/internal/service/custom_menu_auth_context.go @@ -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 +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index e6f0f2bc..f69499b6 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 2faa4d82..cafe3051 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -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{ diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 3f961ab2..fb4bf485 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -254,6 +254,7 @@ type PublicSettings struct { HomeContent string HideCcsImportButton bool + FrontendURL string PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string TableDefaultPageSize int diff --git a/docs/agent-specs/2026-05-27-custom-menu-safe-embed-执行版.md b/docs/agent-specs/2026-05-27-custom-menu-safe-embed-执行版.md new file mode 100644 index 00000000..86480e73 --- /dev/null +++ b/docs/agent-specs/2026-05-27-custom-menu-safe-embed-执行版.md @@ -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 解析。 +- 后端设置保存测试覆盖新建默认安全、旧项缺省保真、非受信域名拒绝显式传上下文。 +- 前端构建和后端相关测试通过。 diff --git a/docs/memorys/2026-05-27-自定义菜单安全嵌入部署.md b/docs/memorys/2026-05-27-自定义菜单安全嵌入部署.md new file mode 100644 index 00000000..0bcfd812 --- /dev/null +++ b/docs/memorys/2026-05-27-自定义菜单安全嵌入部署.md @@ -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`。 diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1d94fa29..386fe73c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8fa15e72..7f4ed46e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5853,6 +5853,8 @@ export default { visibility: '可见角色', visibilityUser: '普通用户', visibilityAdmin: '管理员', + safeEmbed: '安全嵌入第三方页面', + safeEmbedHint: '开启后不会向嵌入页面传递用户 ID、登录 token 或来源 URL;仍会保留主题、语言和嵌入模式参数。', add: '添加菜单项', remove: '删除', moveUp: '上移', diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index 7dbe6bc3..6498427a 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -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) + }) }) }) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 4d701b2e..2d2237fe 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -32,6 +32,7 @@ export const useAppStore = defineStore('app', () => { const apiBaseUrl = ref('') const docUrl = ref('') const cachedPublicSettings = ref(null) + let publicSettingsRequest: Promise | null = null // Version cache state const versionLoaded = ref(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 } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 632e5108..6bd016ac 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -171,6 +171,7 @@ export interface CustomMenuItem { page_slug?: string visibility: 'user' | 'admin' sort_order: number + omit_auth_context?: boolean } export interface CustomEndpoint { diff --git a/frontend/src/utils/__tests__/embedded-url.spec.ts b/frontend/src/utils/__tests__/embedded-url.spec.ts index 0026b7dd..118fe47d 100644 --- a/frontend/src/utils/__tests__/embedded-url.spec.ts +++ b/frontend/src/utils/__tests__/embedded-url.spec.ts @@ -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') diff --git a/frontend/src/utils/embedded-url.ts b/frontend/src/utils/embedded-url.ts index e70d30b4..ae49ef85 100644 --- a/frontend/src/utils/embedded-url.ts +++ b/frontend/src/utils/embedded-url.ts @@ -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) } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 68eb4849..f3d79368 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4908,6 +4908,26 @@ @update:model-value="(v: string) => (item.icon_svg = v)" /> + + +
+
+ +

+ {{ t("admin.settings.customMenu.safeEmbedHint") }} +

+
+ +
@@ -7032,6 +7052,7 @@ const form = reactive({ 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, }); } diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 69b4f5f2..43b02801 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -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) { await flushPromises(); } +async function openGeneralTab(wrapper: ReturnType) { + 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) { 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", () => { diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index 59764c63..e8a1193d 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -107,6 +107,7 @@ @@ -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(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(() => { diff --git a/frontend/src/views/user/__tests__/CustomPageView.spec.ts b/frontend/src/views/user/__tests__/CustomPageView.spec.ts new file mode 100644 index 00000000..e6ef819e --- /dev/null +++ b/frontend/src/views/user/__tests__/CustomPageView.spec.ts @@ -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('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: '
', + }), +})) + +vi.mock('@/components/icons/Icon.vue', () => ({ + default: defineComponent({ + template: '', + }), +})) + +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) + }) +}) diff --git a/ops/remote/us-racknerd-0526-sub2api/README.md b/ops/remote/us-racknerd-0526-sub2api/README.md index 36a0a5a3..5f47412c 100644 --- a/ops/remote/us-racknerd-0526-sub2api/README.md +++ b/ops/remote/us-racknerd-0526-sub2api/README.md @@ -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`。