diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bc903514..60680e79 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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 diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index ee4f4d9f..d5ae778e 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -6,14 +6,38 @@ const iconNames = ref([]) const svgCache: Record = {} // name → normalised SVG string let listPromise: Promise | null = null -function normalizeSvg(raw: string): string { - // Strip fixed width/height from root so CSS can control the size - return raw.replace(/]*)>/, (_, attrs: string) => { - const cleaned = attrs - .replace(/\s+width="[^"]*"/g, '') - .replace(/\s+height="[^"]*"/g, '') - return `` - }) +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() { @@ -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) })