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) 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 系统设置处理器 // SettingHandler 系统设置处理器
type SettingHandler struct { type SettingHandler struct {
settingService *service.SettingService settingService *service.SettingService
@@ -116,7 +102,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
CustomMenuItems: parseCustomMenuItems(settings.CustomMenuItems), CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
DefaultConcurrency: settings.DefaultConcurrency, DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance, DefaultBalance: settings.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
@@ -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 customMenuJSON := previousSettings.CustomMenuItems
if req.CustomMenuItems != nil { if req.CustomMenuItems != nil {
items := *req.CustomMenuItems items := *req.CustomMenuItems
if len(items) > maxCustomMenuItems {
response.BadRequest(c, "Too many custom menu items (max 20)")
return
}
for i, item := range items { for i, item := range items {
if strings.TrimSpace(item.Label) == "" { if strings.TrimSpace(item.Label) == "" {
response.BadRequest(c, "Custom menu item label is required") response.BadRequest(c, "Custom menu item label is required")
return 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) == "" { if strings.TrimSpace(item.URL) == "" {
response.BadRequest(c, "Custom menu item URL is required") response.BadRequest(c, "Custom menu item URL is required")
return 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 { if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL") response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
return return
@@ -346,11 +352,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
return 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 // Auto-generate ID if missing
if strings.TrimSpace(item.ID) == "" { if strings.TrimSpace(item.ID) == "" {
items[i].ID = generateMenuItemID() 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) menuBytes, err := json.Marshal(items)
if err != nil { if err != nil {
response.BadRequest(c, "Failed to serialize custom menu items") response.BadRequest(c, "Failed to serialize custom menu items")
@@ -510,7 +532,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
SoraClientEnabled: updatedSettings.SoraClientEnabled, SoraClientEnabled: updatedSettings.SoraClientEnabled,
CustomMenuItems: parseCustomMenuItems(updatedSettings.CustomMenuItems), CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance, DefaultBalance: updatedSettings.DefaultBalance,
DefaultSubscriptions: updatedDefaultSubscriptions, DefaultSubscriptions: updatedDefaultSubscriptions,
@@ -674,6 +696,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion { if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
changed = append(changed, "min_claude_code_version") 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 return changed
} }

View File

@@ -1,5 +1,10 @@
package dto package dto
import (
"encoding/json"
"strings"
)
// CustomMenuItem represents a user-configured custom menu entry. // CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct { type CustomMenuItem struct {
ID string `json:"id"` ID string `json:"id"`
@@ -150,3 +155,17 @@ type StreamTimeoutSettings struct {
ThresholdCount int `json:"threshold_count"` ThresholdCount int `json:"threshold_count"`
ThresholdWindowMinutes int `json:"threshold_window_minutes"` 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 package handler
import ( import (
"encoding/json"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@@ -53,22 +50,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton: settings.HideCcsImportButton, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
CustomMenuItems: parsePublicCustomMenuItems(settings.CustomMenuItems), CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version, 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, "hide_ccs_import_button": false,
"purchase_subscription_enabled": false, "purchase_subscription_enabled": false,
"purchase_subscription_url": "", "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" v-if="mode === 'svg' && modelValue"
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full" class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
:class="innerSizeClass" :class="innerSizeClass"
v-html="modelValue" v-html="sanitizedValue"
></span> ></span>
<!-- Image mode: show as img --> <!-- Image mode: show as img -->
<img <img
@@ -71,6 +71,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue: string modelValue: string
@@ -97,6 +98,10 @@ const error = ref('')
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*') 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 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 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') const placeholderSizeClass = computed(() => props.size === 'sm' ? 'h-5 w-5' : 'h-8 w-8')

View File

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