diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 3ef0838fcc..079245d550 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -4,6 +4,8 @@ import base64 import json import asyncio import logging +import posixpath +from urllib.parse import unquote from open_webui.models.groups import Groups from open_webui.models.models import ( @@ -29,12 +31,12 @@ from fastapi import ( status, Response, ) -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import RedirectResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants -from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.internal.db import get_async_session from sqlalchemy.ext.asyncio import AsyncSession @@ -43,6 +45,34 @@ log = logging.getLogger(__name__) router = APIRouter() +def _safe_static_redirect_path(url: str) -> Optional[str]: + """ + If url is a same-origin static asset path, return a normalized path safe for + RedirectResponse Location. Otherwise None (caller should fall back to default). + Rejects traversal (..), encoded dots, query/fragment, and non-/static targets. + """ + if not url or not isinstance(url, str): + return None + path = url.split('?', 1)[0].split('#', 1)[0].strip() + for _ in range(2): + decoded = unquote(path) + if decoded == path: + break + path = decoded + if '\x00' in path or '\\' in path: + return None + if not path.startswith('/'): + return None + normalized = posixpath.normpath(path) + if normalized in ('.', '/'): + return None + if not (normalized == '/static' or normalized.startswith('/static/')): + return None + if normalized == '/static': + return '/static/' + return normalized + + def is_valid_model_id(model_id: str) -> bool: return model_id and len(model_id) <= 256 @@ -465,10 +495,25 @@ async def get_model_profile_image( ) except Exception as e: pass + else: + safe_static = _safe_static_redirect_path(model.meta.profile_image_url) + if safe_static: + return RedirectResponse( + url=safe_static, + status_code=status.HTTP_302_FOUND, + ) - return FileResponse(f'{STATIC_DIR}/favicon.png') + # Canonical URL so browsers cache one asset for all default model avatars + # (distinct /profile/image?id=... URLs would otherwise re-download the same bytes). + return RedirectResponse( + url='/static/favicon.png', + status_code=status.HTTP_302_FOUND, + ) else: - return FileResponse(f'{STATIC_DIR}/favicon.png') + return RedirectResponse( + url='/static/favicon.png', + status_code=status.HTTP_302_FOUND, + ) ############################