fix: custom menu security hardening and code quality improvements

- Add admin menu permission check in CustomPageView (visibility + role)
- Sanitize SVG content with DOMPurify before v-html rendering (XSS prevention)
- Decouple router.go from dto package using anonymous struct
- Consolidate duplicate parseCustomMenuItems into dto.ParseCustomMenuItems
- Enhance menu item validation (count, length, ID uniqueness limits)
- Add audit logging for purchase_subscription and custom_menu_items changes
- Update API contract test to include custom_menu_items field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-03 02:18:19 +08:00
parent e4f8799323
commit bf6fe5e962
8 changed files with 133 additions and 82 deletions

View File

@@ -30,20 +30,6 @@ func generateMenuItemID() string {
return hex.EncodeToString(b)
}
// parseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
// Returns empty slice on empty/invalid input.
func parseCustomMenuItems(raw string) []dto.CustomMenuItem {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []dto.CustomMenuItem{}
}
var items []dto.CustomMenuItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []dto.CustomMenuItem{}
}
return items
}
// SettingHandler 系统设置处理器
type SettingHandler struct {
settingService *service.SettingService
@@ -116,7 +102,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled,
CustomMenuItems: parseCustomMenuItems(settings.CustomMenuItems),
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions,
@@ -166,17 +152,17 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
// OEM设置
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
// 默认配置
@@ -326,18 +312,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
// 自定义菜单项验证
const (
maxCustomMenuItems = 20
maxMenuItemLabelLen = 50
maxMenuItemURLLen = 2048
maxMenuItemIconSVGLen = 10 * 1024 // 10KB
maxMenuItemIDLen = 32
)
customMenuJSON := previousSettings.CustomMenuItems
if req.CustomMenuItems != nil {
items := *req.CustomMenuItems
if len(items) > maxCustomMenuItems {
response.BadRequest(c, "Too many custom menu items (max 20)")
return
}
for i, item := range items {
if strings.TrimSpace(item.Label) == "" {
response.BadRequest(c, "Custom menu item label is required")
return
}
if len(item.Label) > maxMenuItemLabelLen {
response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
return
}
if strings.TrimSpace(item.URL) == "" {
response.BadRequest(c, "Custom menu item URL is required")
return
}
if len(item.URL) > maxMenuItemURLLen {
response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
return
}
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
return
@@ -346,11 +352,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
return
}
if len(item.IconSVG) > maxMenuItemIconSVGLen {
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
return
}
// Auto-generate ID if missing
if strings.TrimSpace(item.ID) == "" {
items[i].ID = generateMenuItemID()
} else if len(item.ID) > maxMenuItemIDLen {
response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)")
return
}
}
// ID uniqueness check
seen := make(map[string]struct{}, len(items))
for _, item := range items {
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)
if err != nil {
response.BadRequest(c, "Failed to serialize custom menu items")
@@ -510,7 +532,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
SoraClientEnabled: updatedSettings.SoraClientEnabled,
CustomMenuItems: parseCustomMenuItems(updatedSettings.CustomMenuItems),
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
DefaultSubscriptions: updatedDefaultSubscriptions,
@@ -674,6 +696,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
changed = append(changed, "min_claude_code_version")
}
if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled {
changed = append(changed, "purchase_subscription_enabled")
}
if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
changed = append(changed, "purchase_subscription_url")
}
if before.CustomMenuItems != after.CustomMenuItems {
changed = append(changed, "custom_menu_items")
}
return changed
}

View File

@@ -1,5 +1,10 @@
package dto
import (
"encoding/json"
"strings"
)
// CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct {
ID string `json:"id"`
@@ -37,17 +42,17 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
DefaultConcurrency int `json:"default_concurrency"`
@@ -80,28 +85,28 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
Version string `json:"version"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
Version string `json:"version"`
}
// SoraS3Settings Sora S3 存储配置 DTO响应用不含敏感字段
@@ -150,3 +155,17 @@ type StreamTimeoutSettings struct {
ThresholdCount int `json:"threshold_count"`
ThresholdWindowMinutes int `json:"threshold_window_minutes"`
}
// ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
// Returns empty slice on empty/invalid input.
func ParseCustomMenuItems(raw string) []CustomMenuItem {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []CustomMenuItem{}
}
var items []CustomMenuItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []CustomMenuItem{}
}
return items
}

View File

@@ -1,9 +1,6 @@
package handler
import (
"encoding/json"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -53,22 +50,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
CustomMenuItems: parsePublicCustomMenuItems(settings.CustomMenuItems),
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version,
})
}
// parsePublicCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
func parsePublicCustomMenuItems(raw string) []dto.CustomMenuItem {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []dto.CustomMenuItem{}
}
var items []dto.CustomMenuItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []dto.CustomMenuItem{}
}
return items
}

View File

@@ -513,7 +513,8 @@ func TestAPIContracts(t *testing.T) {
"hide_ccs_import_button": false,
"purchase_subscription_enabled": false,
"purchase_subscription_url": "",
"min_claude_code_version": ""
"min_claude_code_version": "",
"custom_menu_items": []
}
}`,
},

View File

@@ -11,7 +11,7 @@
v-if="mode === 'svg' && modelValue"
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
:class="innerSizeClass"
v-html="modelValue"
v-html="sanitizedValue"
></span>
<!-- Image mode: show as img -->
<img
@@ -71,6 +71,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
const props = withDefaults(defineProps<{
modelValue: string
@@ -97,6 +98,10 @@ const error = ref('')
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
const sanitizedValue = computed(() =>
props.mode === 'svg' ? sanitizeSvg(props.modelValue ?? '') : ''
)
const previewSizeClass = computed(() => props.size === 'sm' ? 'h-14 w-14' : 'h-20 w-20')
const innerSizeClass = computed(() => props.size === 'sm' ? 'h-7 w-7' : 'h-12 w-12')
const placeholderSizeClass = computed(() => props.size === 'sm' ? 'h-5 w-5' : 'h-8 w-8')

View File

@@ -47,7 +47,7 @@
"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
@@ -72,7 +72,7 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
@@ -94,7 +94,7 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
@@ -152,6 +152,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize'
interface NavItem {
path: string

View File

@@ -0,0 +1,6 @@
import DOMPurify from 'dompurify'
export function sanitizeSvg(svg: string): string {
if (!svg) return ''
return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
}

View File

@@ -87,7 +87,11 @@ const menuItemId = computed(() => route.params.id as string)
const menuItem = computed(() => {
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
return items.find((item) => item.id === menuItemId.value) ?? null
const found = items.find((item) => item.id === menuItemId.value) ?? null
if (found && found.visibility === 'admin' && !authStore.isAdmin) {
return null
}
return found
})
const embeddedUrl = computed(() => {