Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* Visu: Duplication, Import, Export of visu sites

### Fixes:
* Security: Sanitize uploaded SVG icon content before ValueDisplay `v-html` injection to prevent stored XSS.
* General: Fix used tags at docker images
* General: Implement contract tests for dependencies
* Backend: History give only last 1000 entries now default 10'000 with amximum of 100'000
Expand Down
42 changes: 33 additions & 9 deletions frontend/src/composables/useIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,38 @@ const iconNames = ref<string[]>([])
const svgCache: Record<string, string> = {} // name → normalised SVG string
let listPromise: Promise<void> | null = null

function normalizeSvg(raw: string): string {
// Strip fixed width/height from root <svg> so CSS can control the size
return raw.replace(/<svg([^>]*)>/, (_, attrs: string) => {
const cleaned = attrs
.replace(/\s+width="[^"]*"/g, '')
.replace(/\s+height="[^"]*"/g, '')
return `<svg${cleaned}>`
})
function sanitizeSvg(raw: string): string {
const parser = new DOMParser()
const doc = parser.parseFromString(raw, 'image/svg+xml')
const svg = doc.documentElement
if (!svg || svg.tagName.toLowerCase() !== 'svg') return ''

// Remove executable or HTML-capable elements
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove())

// Remove dangerous attributes and fixed dimensions
for (const el of Array.from(doc.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase()
const value = attr.value.trim().toLowerCase()

if (name === 'width' || name === 'height') {
el.removeAttribute(attr.name)
continue
}

if (name.startsWith('on')) {
el.removeAttribute(attr.name)
continue
}

if ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:')) {
el.removeAttribute(attr.name)
}
}
}

return svg.outerHTML
}

export function useIcons() {
Expand All @@ -24,7 +48,7 @@ export function useIcons() {
.then(({ icons }) => {
// Populate cache from the list response (content is already included)
for (const icon of icons) {
svgCache[icon.name] = normalizeSvg(icon.content)
svgCache[icon.name] = sanitizeSvg(icon.content)
}
iconNames.value = icons.map((i) => i.name)
})
Expand Down