diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 26cd3128..e32c142f 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -23,11 +23,16 @@ import ( // semverPattern 预编译 semver 格式校验正则 var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) +// menuItemIDPattern validates custom menu item IDs: alphanumeric, hyphens, underscores only. +var menuItemIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + // generateMenuItemID generates a short random hex ID for a custom menu item. -func generateMenuItemID() string { +func generateMenuItemID() (string, error) { b := make([]byte, 8) - _, _ = rand.Read(b) - return hex.EncodeToString(b) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate menu item ID: %w", err) + } + return hex.EncodeToString(b), nil } // SettingHandler 系统设置处理器 @@ -358,10 +363,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } // Auto-generate ID if missing if strings.TrimSpace(item.ID) == "" { - items[i].ID = generateMenuItemID() + id, err := generateMenuItemID() + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID") + return + } + items[i].ID = id } else if len(item.ID) > maxMenuItemIDLen { response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)") return + } else if !menuItemIDPattern.MatchString(item.ID) { + response.BadRequest(c, "Custom menu item ID contains invalid characters (only a-z, A-Z, 0-9, - and _ are allowed)") + return } } // ID uniqueness check diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index f3c21be5..beb03e67 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -169,3 +169,15 @@ func ParseCustomMenuItems(raw string) []CustomMenuItem { } return items } + +// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries. +func ParseUserVisibleMenuItems(raw string) []CustomMenuItem { + items := ParseCustomMenuItems(raw) + filtered := make([]CustomMenuItem, 0, len(items)) + for _, item := range items { + if item.Visibility != "admin" { + filtered = append(filtered, item) + } + } + return filtered +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 40277a48..a48eaf31 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -50,7 +50,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { HideCcsImportButton: settings.HideCcsImportButton, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems), + CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, SoraClientEnabled: settings.SoraClientEnabled, Version: h.version, diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index c44a4608..430edcf8 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -2,10 +2,7 @@ package server import ( "context" - "encoding/json" "log" - "net/url" - "strings" "sync/atomic" "time" @@ -20,24 +17,7 @@ import ( "github.com/redis/go-redis/v9" ) -// extractOrigin returns the scheme+host origin from rawURL, or "" on error. -// Only http and https schemes are accepted; other values (e.g. "//host/path") return "". -func extractOrigin(rawURL string) string { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "" - } - u, err := url.Parse(rawURL) - if err != nil || u.Host == "" { - return "" - } - if u.Scheme != "http" && u.Scheme != "https" { - return "" - } - return u.Scheme + "://" + u.Host -} - -const paymentOriginFetchTimeout = 5 * time.Second +const frameSrcRefreshTimeout = 5 * time.Second // SetupRouter 配置路由器中间件和路由 func SetupRouter( @@ -54,50 +34,18 @@ func SetupRouter( redisClient *redis.Client, ) *gin.Engine { // 缓存 iframe 页面的 origin 列表,用于动态注入 CSP frame-src - // 包含 purchase_subscription_url 和所有 custom_menu_items 的 origin(去重) var cachedFrameOrigins atomic.Pointer[[]string] emptyOrigins := []string{} cachedFrameOrigins.Store(&emptyOrigins) refreshFrameOrigins := func() { - ctx, cancel := context.WithTimeout(context.Background(), paymentOriginFetchTimeout) + ctx, cancel := context.WithTimeout(context.Background(), frameSrcRefreshTimeout) defer cancel() - settings, err := settingService.GetPublicSettings(ctx) + origins, err := settingService.GetFrameSrcOrigins(ctx) if err != nil { // 获取失败时保留已有缓存,避免 frame-src 被意外清空 return } - - seen := make(map[string]struct{}) - var origins []string - - // purchase subscription URL - if settings.PurchaseSubscriptionEnabled { - if origin := extractOrigin(settings.PurchaseSubscriptionURL); origin != "" { - if _, ok := seen[origin]; !ok { - seen[origin] = struct{}{} - origins = append(origins, origin) - } - } - } - - // custom menu items - if raw := strings.TrimSpace(settings.CustomMenuItems); raw != "" && raw != "[]" { - var items []struct { - URL string `json:"url"` - } - if err := json.Unmarshal([]byte(raw), &items); err == nil { - for _, item := range items { - if origin := extractOrigin(item.URL); origin != "" { - if _, ok := seen[origin]; !ok { - seen[origin] = struct{}{} - origins = append(origins, origin) - } - } - } - } - } - cachedFrameOrigins.Store(&origins) } refreshFrameOrigins() // 启动时初始化 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 2311e150..a2bb06a4 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "strconv" "strings" "sync/atomic" @@ -237,23 +238,133 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, SoraClientEnabled: settings.SoraClientEnabled, - CustomMenuItems: sanitizeCustomMenuItemsJSON(settings.CustomMenuItems), + CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, Version: s.version, }, nil } -// sanitizeCustomMenuItemsJSON validates a raw JSON string and returns it as json.RawMessage. -// Returns "[]" if the input is empty or invalid JSON. +// sanitizeCustomMenuItemsJSON validates a raw JSON string is a valid JSON array +// and returns it as json.RawMessage. Returns "[]" if the input is empty, not a +// valid JSON array, or is a non-array JSON value (e.g. object, string). func sanitizeCustomMenuItemsJSON(raw string) json.RawMessage { raw = strings.TrimSpace(raw) if raw == "" || raw == "[]" { return json.RawMessage("[]") } - if json.Valid([]byte(raw)) { - return json.RawMessage(raw) + // Verify it's actually a JSON array, not an object or other type + var arr []json.RawMessage + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + return json.RawMessage("[]") } - return json.RawMessage("[]") + return json.RawMessage(raw) +} + +// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON +// array string, returning only items with visibility != "admin". +func filterUserVisibleMenuItems(raw string) json.RawMessage { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return json.RawMessage("[]") + } + var items []struct { + Visibility string `json:"visibility"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return json.RawMessage("[]") + } + + // Parse full items to preserve all fields + var fullItems []json.RawMessage + if err := json.Unmarshal([]byte(raw), &fullItems); err != nil { + return json.RawMessage("[]") + } + + var filtered []json.RawMessage + for i, item := range items { + if item.Visibility != "admin" { + filtered = append(filtered, fullItems[i]) + } + } + if len(filtered) == 0 { + return json.RawMessage("[]") + } + result, err := json.Marshal(filtered) + if err != nil { + return json.RawMessage("[]") + } + return result +} + +// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url +// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. +func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { + settings, err := s.GetPublicSettings(ctx) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + var origins []string + + addOrigin := func(rawURL string) { + if origin := extractOriginFromURL(rawURL); origin != "" { + if _, ok := seen[origin]; !ok { + seen[origin] = struct{}{} + origins = append(origins, origin) + } + } + } + + // purchase subscription URL + if settings.PurchaseSubscriptionEnabled { + addOrigin(settings.PurchaseSubscriptionURL) + } + + // all custom menu items (including admin-only, since CSP must allow all iframes) + for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) { + addOrigin(item) + } + + return origins, nil +} + +// extractOriginFromURL returns the scheme+host origin from rawURL. +// Only http and https schemes are accepted. +func extractOriginFromURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + return u.Scheme + "://" + u.Host +} + +// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items. +func parseCustomMenuItemURLs(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return nil + } + var items []struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + urls := make([]string, 0, len(items)) + for _, item := range items { + if item.URL != "" { + urls = append(urls, item.URL) + } + } + return urls } // UpdateSettings 更新系统设置