From 55a6198e267d7d53ba86c3f492a6af9af907f0b7 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:20:13 +0200 Subject: [PATCH] Gate the model profile image endpoint on model read access (#29700) Any authenticated user could fetch the profile image of a model they have no access to, and could tell an existing model id from an unknown one by whether the response carried the image or the default logo. The endpoint now serves an image only to callers who can see the model itself: the owner, an admin under the admin bypass, or the holder of a read grant, with the same rule applied to arena models defined in config. Everyone else gets the default logo, byte for byte the response an unknown id already returned, so ids can no longer be probed. BYPASS_MODEL_ACCESS_CONTROL is honoured here because it is what decides which models reach a user's model list to begin with. Avatars now fall back to the default logo wherever a viewer meets a model id without holding a grant on it: a model reply in a channel shown to the other members, and the admin analytics and evaluation pages when the admin bypass is switched off. --- backend/open_webui/models/models.py | 8 ++++--- backend/open_webui/routers/models.py | 33 +++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 5094276bf4..e435a492ba 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -449,11 +449,13 @@ class ModelsTable: return ModelListResponse(items=models, total=total) - async def get_model_meta_by_id(self, id: str, db: AsyncSession | None = None) -> tuple[dict, int | None]: - """Return (meta, updated_at) for a model, skipping access grant resolution.""" + async def get_model_meta_by_id( + self, id: str, db: AsyncSession | None = None + ) -> tuple[dict, str, int | None] | None: + """Return (meta, user_id, updated_at) for a model, skipping access grant resolution.""" try: async with get_async_db_context(db) as db: - result = await db.execute(select(Model.meta, Model.updated_at).filter_by(id=id)) + result = await db.execute(select(Model.meta, Model.user_id, Model.updated_at).filter_by(id=id)) return result.first() except Exception: return None diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index a0fd97705d..baa8bae604 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -19,7 +19,11 @@ from fastapi import ( from fastapi.responses import RedirectResponse, StreamingResponse from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES -from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES +from open_webui.env import ( + BYPASS_MODEL_ACCESS_CONTROL, + ENABLE_PROFILE_IMAGE_URL_FORWARDING, + PROFILE_IMAGE_ALLOWED_MIME_TYPES, +) from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants @@ -36,7 +40,7 @@ from open_webui.models.models import ( ModelResponse, Models, ) -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control import filter_allowed_access_grants, has_access, has_permission from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.chat_variables import get_chat_variables_schema @@ -649,18 +653,37 @@ async def get_model_profile_image( profile_image_url = None updated_at = None + bypass_access_control = BYPASS_MODEL_ACCESS_CONTROL or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + # First, check the database for regular models model_meta = await Models.get_model_meta_by_id(id, db=db) if model_meta: - meta, updated_at = model_meta - profile_image_url = (meta or {}).get('profile_image_url') + meta, model_user_id, model_updated_at = model_meta + # Denied callers get the default image rather than an error, so model ids stay unprobeable. + if ( + bypass_access_control + or user.id == model_user_id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=id, + permission='read', + db=db, + ) + ): + profile_image_url = (meta or {}).get('profile_image_url') + updated_at = model_updated_at # Fallback: check arena models stored in config (not in the DB) if not profile_image_url: arena_models = await Config.get('evaluation.arena.models', []) or [] for arena_model in arena_models: if arena_model.get('id') == id: - profile_image_url = arena_model.get('meta', {}).get('profile_image_url') + arena_meta = arena_model.get('meta', {}) + if bypass_access_control or await has_access( + user.id, permission='read', access_grants=arena_meta.get('access_grants', []), db=db + ): + profile_image_url = arena_meta.get('profile_image_url') break if profile_image_url: