mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-07 08:27:05 +00:00
fix: isolate admin-mode state, harden deepMerge, disable stale defaults cache
- InterfaceSettings now reads from a reactive activeSettings alias (initialSettings in admin mode, $settings otherwise) instead of reading $settings directly. toggleTitleAutoGenerate spreads activeSettings.title and the chat-bubble template branch uses the local chatBubble state, so the admin defaults modal no longer mixes the current admin's personal settings into the payload or the render logic. - deepMerge skips __proto__, constructor, and prototype keys before copying. Inputs come from API payloads and localStorage, both untrusted channels, and a crafted payload could otherwise mutate the object prototype chain or reassign the constructor during app init. - GET /configs/interface/defaults swaps Cache-Control: private, max-age=600 for no-store. The endpoint is fetched on every authenticated app load and merged into runtime settings, so a 10-minute freshness window made admin "save defaults" and "reset all users" appear to have no effect for up to ten minutes. The read is cheap (in-memory config), so skipping the cache is fine.
This commit is contained in:
parent
6cd3c7238b
commit
5835cd93cf
3 changed files with 42 additions and 23 deletions
|
|
@ -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"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if !$settings.chatBubble}
|
||||
{#if !chatBubble}
|
||||
<div>
|
||||
<div class=" py-0.5 flex w-full justify-between">
|
||||
<div id="chat-bubble-username-label" class=" self-center text-xs">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue