diff --git a/frontend/src/composables/useIcons.ts b/frontend/src/composables/useIcons.ts index 30c42a44..5b2b7bb7 100644 --- a/frontend/src/composables/useIcons.ts +++ b/frontend/src/composables/useIcons.ts @@ -5,34 +5,50 @@ 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:'] +const URL_FUNCTION_ATTRIBUTES = new Set([ + 'fill', + 'stroke', + 'filter', + 'clip-path', + 'mask', + 'marker-start', + 'marker-mid', + 'marker-end', + 'cursor', +]) -function isDangerousHref(raw: string): boolean { - // Normalize obfuscated schemes like "java\nscript:" before checking. - const normalized = raw - .trim() - .toLowerCase() - .replace(/[\u0000-\u001f\u007f\s]+/g, '') - return normalized.startsWith('javascript:') +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() 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 SMIL animation elements that can mutate attributes post-sanitization. - doc.querySelectorAll('animate, animateMotion, animateTransform, set').forEach((el) => el.remove()) + // Drop executable, externally embeddable, or dynamic mutation content. + doc.querySelectorAll('script,style,foreignObject,iframe,object,embed,audio,video,animate,set,animateMotion,animateTransform').forEach((el) => el.remove()) - // Remove dangerous attributes and fixed dimensions - for (const el of Array.from(doc.querySelectorAll('*'))) { + for (const el of [svg, ...Array.from(doc.querySelectorAll('*'))]) { for (const attr of Array.from(el.attributes)) { const name = attr.name.toLowerCase() - const value = attr.value + const localName = (attr.localName || attr.name).toLowerCase() + const lowerValue = attr.value.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 } @@ -42,7 +58,20 @@ function sanitizeSvg(raw: string): string { continue } - if ((name === 'href' || name === 'xlink:href') && isDangerousHref(value)) { + 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) + continue + } + + if (URL_FUNCTION_ATTRIBUTES.has(localName) && hasBlockedCssUrlFunction(attr.value)) { el.removeAttribute(attr.name) } } diff --git a/obs/config.py b/obs/config.py index 7f825302..d22415bb 100644 --- a/obs/config.py +++ b/obs/config.py @@ -144,6 +144,7 @@ def _import_legacy_env_vars() -> None: present, prefer an explicitly supplied OPENTWS_* value for compatibility. """ legacy_prefix = "OPENTWS_" + legacy_prefix_upper = legacy_prefix.upper() new_prefix = "OBS_" docker_defaults = { "OBS_CONFIG": "/data/config.yaml", @@ -151,7 +152,7 @@ def _import_legacy_env_vars() -> None: } 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) :]}" existing_key = _get_existing_env_key_case_insensitive(mapped_key) @@ -173,6 +174,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() @@ -223,11 +228,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