diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index fd2ad14602..5512dba5ed 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -713,16 +713,21 @@ async def get_interface_defaults(user=Depends(get_verified_user)): """ Get global interface defaults for all users. Returns empty dict if no defaults are configured. - Cached for 10 minutes to reduce redundant fetches. + + Served with a no-store cache policy: this endpoint is fetched on + authenticated app load and merged into runtime settings, so caching + here would leave users seeing stale defaults for up to the cache TTL + after an admin 'save defaults' or 'reset all users' action. The call + is lightweight (in-memory config read), so the extra hit is cheap. """ from fastapi.responses import JSONResponse - + config = await asyncio.to_thread(get_config) defaults = config.get("ui", {}).get("interface_defaults", {}) - + return JSONResponse( content=defaults, - headers={"Cache-Control": "private, max-age=600"} + headers={"Cache-Control": "no-store"}, ) diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 6c144db2d6..c1238d28aa 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -24,6 +24,13 @@ // In admin mode, we don't apply CSS side effects like text scale $: isAdminMode = initialSettings !== null; + // Reading $settings directly mixes the current admin's personal settings + // into composition (e.g. ...$settings.title) and render branches (e.g. + // {#if !$settings.chatBubble}) when this component is reused by the + // defaults modal. activeSettings resolves to initialSettings in admin + // mode and $settings in the normal user mode, so neither path leaks. + $: activeSettings = isAdminMode ? (initialSettings ?? {}) : $settings; + let backgroundImageUrl = null; let inputFiles = null; let filesInputElement; @@ -135,7 +142,7 @@ const toggleTitleAutoGenerate = async () => { saveSettings({ title: { - ...$settings.title, + ...(activeSettings?.title ?? {}), auto: titleAutoGenerate } }); @@ -716,7 +723,7 @@ - {#if !$settings.chatBubble} + {#if !chatBubble}
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 4d26d1ff4d..52d4190ee1 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1883,24 +1883,31 @@ export const deepMerge = (target: any, source: any): any => { const result = { ...target }; for (const key in source) { - if (Object.prototype.hasOwnProperty.call(source, key)) { - const sourceValue = source[key]; - const targetValue = result[key]; + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; - // Recursively merge if both are non-null objects - if ( - sourceValue !== null && - targetValue !== null && - typeof sourceValue === 'object' && - typeof targetValue === 'object' && - !Array.isArray(sourceValue) && - !Array.isArray(targetValue) - ) { - result[key] = deepMerge(targetValue, sourceValue); - } else { - // Source value takes precedence (including explicit null) - result[key] = sourceValue; - } + // Sources for this merge include API payloads and localStorage, so + // never copy keys that would let a crafted value mutate the + // prototype chain or reassign the object constructor. + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + + const sourceValue = source[key]; + const targetValue = result[key]; + + // Recursively merge if both are non-null objects + if ( + sourceValue !== null && + targetValue !== null && + typeof sourceValue === 'object' && + typeof targetValue === 'object' && + !Array.isArray(sourceValue) && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge(targetValue, sourceValue); + } else { + // Source value takes precedence (including explicit null) + result[key] = sourceValue; } }