From eb091166b1c895137308fe24207c58a2e3db1907 Mon Sep 17 00:00:00 2001 From: Micsi Date: Sun, 17 May 2026 21:13:47 +0200 Subject: [PATCH 1/6] fix(frontend): sanitize inline svg icons before rendering --- frontend/src/composables/useIcons.ts | 33 +++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index ee4f4d9f..abe295a8 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -7,8 +7,39 @@ const svgCache: Record = {} // name → normalised SVG string let listPromise: Promise | null = null function normalizeSvg(raw: string): string { + // Parse and sanitize untrusted SVG to prevent script execution via v-html. + const parser = new DOMParser() + const doc = parser.parseFromString(raw, 'image/svg+xml') + const root = doc.documentElement + + if (!root || root.tagName.toLowerCase() !== 'svg') return '' + + // Drop executable or externally embeddable content. + root.querySelectorAll('script,foreignObject,iframe,object,embed,audio,video').forEach((el) => el.remove()) + + for (const el of root.querySelectorAll('*')) { + for (const attr of [...el.attributes]) { + const name = attr.name.toLowerCase() + const value = attr.value.trim().toLowerCase() + + // Remove event handlers and dangerous URL-bearing attributes. + if (name.startsWith('on')) { + el.removeAttribute(attr.name) + continue + } + if ((name === 'href' || name === 'xlink:href' || name === 'src') && ( + value.startsWith('javascript:') || + value.startsWith('data:') || + value.startsWith('http:') || + value.startsWith('https:') + )) { + el.removeAttribute(attr.name) + } + } + } + // Strip fixed width/height from root so CSS can control the size - return raw.replace(/]*)>/, (_, attrs: string) => { + return root.outerHTML.replace(/]*)>/, (_, attrs: string) => { const cleaned = attrs .replace(/\s+width="[^"]*"/g, '') .replace(/\s+height="[^"]*"/g, '') From bc4f30516296a8dd29f2ecd0b1b810fdbd510ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Ha=CC=88berle?= Date: Tue, 26 May 2026 10:44:57 +0200 Subject: [PATCH 2/6] docs(release): note SVG sanitization fix in release notes --- RELEASENOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bc903514..daaa138b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -15,6 +15,7 @@ * Visu: Duplication, Import, Export of visu sites ### Fixes: +* Security: Sanitize inline SVG icon content before rendering in Visu widgets 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 From 2ed6c2ba936de0a7af3f292baa75edab7bd79cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Ha=CC=88berle?= Date: Tue, 26 May 2026 12:12:54 +0200 Subject: [PATCH 3/6] fix(frontend): harden SVG sanitizer against root/URL bypasses --- frontend/src/composables/useIcons.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index abe295a8..04179d44 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -5,6 +5,7 @@ import { icons as iconsApi } from '@/api/client' const iconNames = ref([]) const svgCache: Record = {} // name → normalised SVG string let listPromise: Promise | null = null +const BLOCKED_URL_SCHEMES = ['javascript:', 'data:', 'http:', 'https:'] function normalizeSvg(raw: string): string { // Parse and sanitize untrusted SVG to prevent script execution via v-html. @@ -14,25 +15,21 @@ function normalizeSvg(raw: string): string { if (!root || root.tagName.toLowerCase() !== 'svg') return '' - // Drop executable or externally embeddable content. - root.querySelectorAll('script,foreignObject,iframe,object,embed,audio,video').forEach((el) => el.remove()) + // Drop executable, externally embeddable, or dynamic mutation content. + root.querySelectorAll('script,foreignObject,iframe,object,embed,audio,video,animate,set,animateMotion,animateTransform').forEach((el) => el.remove()) - for (const el of root.querySelectorAll('*')) { + for (const el of [root, ...root.querySelectorAll('*')]) { for (const attr of [...el.attributes]) { const name = attr.name.toLowerCase() - const value = attr.value.trim().toLowerCase() + const localName = (attr.localName || attr.name).toLowerCase() + const normalizedValue = attr.value.toLowerCase().replace(/[\u0000-\u0020]+/g, '') // Remove event handlers and dangerous URL-bearing attributes. if (name.startsWith('on')) { el.removeAttribute(attr.name) continue } - if ((name === 'href' || name === 'xlink:href' || name === 'src') && ( - value.startsWith('javascript:') || - value.startsWith('data:') || - value.startsWith('http:') || - value.startsWith('https:') - )) { + if ((localName === 'href' || localName === 'src') && BLOCKED_URL_SCHEMES.some((scheme) => normalizedValue.startsWith(scheme))) { el.removeAttribute(attr.name) } } From af4c9266c86b76ae3076dddb84d37d55a4343d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Ha=CC=88berle?= Date: Thu, 28 May 2026 08:29:57 +0200 Subject: [PATCH 4/6] fix(security): address PR #44 sanitizer and config review findings --- frontend/src/composables/useIcons.ts | 2 +- obs/config.py | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index 8112af86..dc028409 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -23,7 +23,7 @@ function sanitizeSvg(raw: string): string { const localName = (attr.localName || attr.name).toLowerCase() const normalizedValue = attr.value.toLowerCase().replace(/[\u0000-\u0020]+/g, '') - if (name === 'width' || name === 'height') { + if (el === svg && (name === 'width' || name === 'height')) { el.removeAttribute(attr.name) continue } diff --git a/obs/config.py b/obs/config.py index 8f41bc01..99d11aa0 100644 --- a/obs/config.py +++ b/obs/config.py @@ -135,10 +135,11 @@ def _get_env_case_insensitive(*env_keys: str) -> str | None: def _import_legacy_env_vars() -> None: """Import OPENTWS_* variables as OBS_* when OBS_* is not set.""" legacy_prefix = "OPENTWS_" + legacy_prefix_upper = legacy_prefix.upper() new_prefix = "OBS_" for key, value in list(os.environ.items()): - if not key.startswith(legacy_prefix): + if not key.upper().startswith(legacy_prefix_upper): continue mapped_key = f"{new_prefix}{key[len(legacy_prefix) :]}" if not _has_env_key_case_insensitive(mapped_key): @@ -154,6 +155,10 @@ def _resolve_default_db_path(default_path: str = "/data/obs.db") -> str: return default_path +def _is_builtin_default_db_path(path: str) -> bool: + return Path(path).as_posix() == "/data/obs.db" + + _import_legacy_env_vars() @@ -204,11 +209,20 @@ def _inject_database_path_fallback(cls, data: Any) -> Any: return result if isinstance(database_value, dict): - has_path = any(isinstance(key, str) and key.lower() == "path" for key in database_value) - if not has_path: + path_key = next( + (key for key in database_value if isinstance(key, str) and key.lower() == "path"), + None, + ) + if path_key is None: merged_database = dict(database_value) merged_database["path"] = default_path result[database_key] = merged_database + else: + current_path = database_value.get(path_key) + if isinstance(current_path, str) and _is_builtin_default_db_path(current_path): + merged_database = dict(database_value) + merged_database[path_key] = default_path + result[database_key] = merged_database return result From 12d3240ddcf1dda7a61e011a58f29537b7d5eee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Ha=CC=88berle?= Date: Thu, 28 May 2026 10:51:00 +0200 Subject: [PATCH 5/6] fix(security): block CSS and scheme-relative URL loads in SVG sanitizer --- frontend/src/composables/useIcons.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index dc028409..440701c5 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -15,12 +15,13 @@ function sanitizeSvg(raw: string): string { if (!svg || svg.tagName.toLowerCase() !== 'svg') return '' // Drop executable, externally embeddable, or dynamic mutation content. - doc.querySelectorAll('script,foreignObject,iframe,object,embed,audio,video,animate,set,animateMotion,animateTransform').forEach((el) => el.remove()) + doc.querySelectorAll('script,style,foreignObject,iframe,object,embed,audio,video,animate,set,animateMotion,animateTransform').forEach((el) => el.remove()) for (const el of [svg, ...Array.from(doc.querySelectorAll('*'))]) { for (const attr of Array.from(el.attributes)) { const name = attr.name.toLowerCase() const localName = (attr.localName || attr.name).toLowerCase() + const lowerValue = attr.value.toLowerCase() const normalizedValue = attr.value.toLowerCase().replace(/[\u0000-\u0020]+/g, '') if (el === svg && (name === 'width' || name === 'height')) { @@ -33,7 +34,15 @@ function sanitizeSvg(raw: string): string { continue } - if ((localName === 'href' || localName === 'src') && BLOCKED_URL_SCHEMES.some((scheme) => normalizedValue.startsWith(scheme))) { + if (name === 'style' && (lowerValue.includes('url(') || lowerValue.includes('@import'))) { + el.removeAttribute(attr.name) + continue + } + + if ((localName === 'href' || localName === 'src') && ( + normalizedValue.startsWith('//') || + BLOCKED_URL_SCHEMES.some((scheme) => normalizedValue.startsWith(scheme)) + )) { el.removeAttribute(attr.name) } } From 6a6c79909158b6e79b155131139ce33044ba4d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Ha=CC=88berle?= Date: Thu, 28 May 2026 12:03:07 +0200 Subject: [PATCH 6/6] fix(security): block external url() SVG attribute references --- frontend/src/composables/useIcons.ts | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index 440701c5..5b2b7bb7 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -6,6 +6,30 @@ const iconNames = ref([]) const svgCache: Record = {} // name → normalised SVG string let listPromise: Promise | null = null const BLOCKED_URL_SCHEMES = ['javascript:', 'data:', 'http:', 'https:'] +const URL_FUNCTION_ATTRIBUTES = new Set([ + 'fill', + 'stroke', + 'filter', + 'clip-path', + 'mask', + 'marker-start', + 'marker-mid', + 'marker-end', + 'cursor', +]) + +function isBlockedUrlReference(rawValue: string): boolean { + const normalized = rawValue.toLowerCase().replace(/[\u0000-\u0020]+/g, '') + return normalized.startsWith('//') || BLOCKED_URL_SCHEMES.some((scheme) => normalized.startsWith(scheme)) +} + +function hasBlockedCssUrlFunction(value: string): boolean { + for (const match of value.matchAll(/url\(([^)]*)\)/gi)) { + const rawRef = (match[1] || '').trim().replace(/^['"]|['"]$/g, '') + if (isBlockedUrlReference(rawRef)) return true + } + return false +} function sanitizeSvg(raw: string): string { const parser = new DOMParser() @@ -44,6 +68,11 @@ function sanitizeSvg(raw: string): string { BLOCKED_URL_SCHEMES.some((scheme) => normalizedValue.startsWith(scheme)) )) { el.removeAttribute(attr.name) + continue + } + + if (URL_FUNCTION_ATTRIBUTES.has(localName) && hasBlockedCssUrlFunction(attr.value)) { + el.removeAttribute(attr.name) } } }