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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
6
frontend/src/utils/sanitize.ts
Normal file
6
frontend/src/utils/sanitize.ts
Normal 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 } })
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user