mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-23 05:54:48 -06:00
perf: redirect default model profile image to canonical static URL (#24015)
- Return 302 to /static/favicon.png instead of streaming the same PNG per model id so browsers can cache one asset for default avatars. - Validate stored /static/ paths with decode, normpath, and /static prefix checks; invalid paths fall back to favicon. Made-with: Cursor
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
|
||||
Reference in New Issue
Block a user