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:
parent
09497bc950
commit
ac5ff854b2
@ -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 == "" {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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.
|
||||
|
||||
53
backend/internal/handler/dto/settings_custom_menu_test.go
Normal file
53
backend/internal/handler/dto/settings_custom_menu_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
53
backend/internal/handler/setting_handler_custom_menu_test.go
Normal file
53
backend/internal/handler/setting_handler_custom_menu_test.go
Normal 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)
|
||||
}
|
||||
180
backend/internal/service/custom_menu_auth_context.go
Normal file
180
backend/internal/service/custom_menu_auth_context.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -254,6 +254,7 @@ type PublicSettings struct {
|
||||
HomeContent string
|
||||
HideCcsImportButton bool
|
||||
|
||||
FrontendURL string
|
||||
PurchaseSubscriptionEnabled bool
|
||||
PurchaseSubscriptionURL string
|
||||
TableDefaultPageSize int
|
||||
|
||||
117
docs/agent-specs/2026-05-27-custom-menu-safe-embed-执行版.md
Normal file
117
docs/agent-specs/2026-05-27-custom-menu-safe-embed-执行版.md
Normal 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 解析。
|
||||
- 后端设置保存测试覆盖新建默认安全、旧项缺省保真、非受信域名拒绝显式传上下文。
|
||||
- 前端构建和后端相关测试通过。
|
||||
70
docs/memorys/2026-05-27-自定义菜单安全嵌入部署.md
Normal file
70
docs/memorys/2026-05-27-自定义菜单安全嵌入部署.md
Normal 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`。
|
||||
@ -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',
|
||||
|
||||
@ -5853,6 +5853,8 @@ export default {
|
||||
visibility: '可见角色',
|
||||
visibilityUser: '普通用户',
|
||||
visibilityAdmin: '管理员',
|
||||
safeEmbed: '安全嵌入第三方页面',
|
||||
safeEmbedHint: '开启后不会向嵌入页面传递用户 ID、登录 token 或来源 URL;仍会保留主题、语言和嵌入模式参数。',
|
||||
add: '添加菜单项',
|
||||
remove: '删除',
|
||||
moveUp: '上移',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -171,6 +171,7 @@ export interface CustomMenuItem {
|
||||
page_slug?: string
|
||||
visibility: 'user' | 'admin'
|
||||
sort_order: number
|
||||
omit_auth_context?: boolean
|
||||
}
|
||||
|
||||
export interface CustomEndpoint {
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
254
frontend/src/views/user/__tests__/CustomPageView.spec.ts
Normal file
254
frontend/src/views/user/__tests__/CustomPageView.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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`。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user