Skip to content
61 changes: 45 additions & 16 deletions frontend/src/composables/useIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,50 @@ import { icons as iconsApi } from '@/api/client'
const iconNames = ref<string[]>([])
const svgCache: Record<string, string> = {} // name → normalised SVG string
let listPromise: Promise<void> | 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
}
Expand All @@ -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)
}
}
Expand Down
20 changes: 17 additions & 3 deletions obs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,15 @@ 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",
"OBS_DATABASE__PATH": "/data/obs.db",
}

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)
Expand All @@ -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()


Expand Down Expand Up @@ -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

Expand Down