From fe4b319428b58bb7c08fb3ee7b777e5644fe06be Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:32:32 +0200 Subject: [PATCH] fix: deny chained access to unregistered base models for non-admins (#26905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace model shared publicly could be used by any user even when its base model was private. Unregistered base models (no row in the model table) are admin-only for direct use — get_filtered_models hides them from non-admins and check_model_access rejects them — but has_base_model_access treated a missing row as "no ACL" and allowed the chained request through. has_base_model_access now takes the caller's role and only allows an unregistered base model hop for admins, so a shared preset can no longer reach a base model the caller could not use directly. Registered base models keep their existing grant-based enforcement. Claude-Session: https://claude.ai/code/session_018toPfJW1hMXAhokGaL43Ep Co-authored-by: Claude --- .../open_webui/utils/access_control/__init__.py | 15 ++++++++++----- backend/open_webui/utils/models.py | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index d0345ccdd5..975008b4c1 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -276,6 +276,7 @@ async def has_base_model_access( user_id: str, model_info, *, + user_role: str | None = None, user_group_ids: set[str] | None = None, db=None, ) -> bool: @@ -283,9 +284,11 @@ async def has_base_model_access( Walk the ``base_model_id`` chain and verify the caller has read access at every hop. - Returns ``True`` when access is granted (or the chain ends at a raw - provider model that has no per-model ACL). Returns ``False`` the - moment a registered base model denies access. + A base model without a ``model`` table row is admin-only, matching how + unregistered models are treated for direct use (``get_filtered_models`` + hides them from non-admins and ``check_model_access`` rejects them), so + a shared preset cannot be used to reach a base model the caller could + not use directly. Returns ``False`` the moment any hop denies access. """ from open_webui.models.access_grants import AccessGrants from open_webui.models.models import Models @@ -296,7 +299,7 @@ async def has_base_model_access( seen.add(base_model_id) base_model_info = await Models.get_model_by_id(base_model_id, db=db) if base_model_info is None: - break # Raw provider model — no per-model ACL + return user_role == 'admin' if not ( user_id == base_model_info.user_id or await AccessGrants.has_access( @@ -355,7 +358,9 @@ async def check_model_access( raise HTTPException(status_code=403, detail='Model not found') # Enforce access on chained base models - if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids): + if not await has_base_model_access( + user.id, model_info, user_role=user.role, user_group_ids=user_group_ids + ): raise HTTPException(status_code=403, detail='Model not found') else: if user.role != 'admin': diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index ddc59180fd..a5b2261b14 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -456,7 +456,7 @@ async def check_model_access(user, model, db=None): raise Exception('Model not found') # Enforce access on chained base models - if not await has_base_model_access(user.id, model_info, db=db): + if not await has_base_model_access(user.id, model_info, user_role=user.role, db=db): raise Exception('Model not found')