diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 1308be67ae..128b13add3 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -30,6 +30,7 @@ from open_webui.utils.access_control import ( has_public_read_access_grant, has_public_write_access_grant, ) +from open_webui.utils.access_control.files import has_access_to_note_files from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -533,6 +534,13 @@ async def update_note_by_id( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + if ( + form_data.data + and 'files' in form_data.data + and not await has_access_to_note_files(note, form_data.data['files'], user, db=db) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + if form_data.access_grants is not None: form_data.access_grants = await filter_allowed_access_grants( await Config.get('user.permissions'), diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index d4a5d3f647..86bfd10142 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -54,6 +54,7 @@ from open_webui.tasks import ( stop_item_tasks, ) from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control.files import has_access_to_note_files from open_webui.utils.auth import get_verified_user_by_token from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.json_codec import SOCKETIO_JSON, JSONCodec, dumps_bytes @@ -1097,6 +1098,13 @@ async def yjs_document_update(sid, data): log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') return + snapshot = data.get('data') + if isinstance(snapshot, dict) and 'files' in snapshot: + editor = await Users.get_user_by_id(user['id']) + if not editor or not await has_access_to_note_files(note, snapshot['files'], editor): + await sio.emit('error', {'message': 'Cannot attach files you cannot access'}, room=sid) + return + update = data.get('update') # List of bytes from frontend # The document lock makes initialization first-write-wins across workers. diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index a30e482215..c8a10910da 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -8,7 +8,7 @@ from open_webui.models.folders import FolderModel from open_webui.models.groups import Groups from open_webui.models.knowledge import Knowledges from open_webui.models.models import Models -from open_webui.models.notes import Notes +from open_webui.models.notes import NoteModel, Notes from open_webui.models.users import UserModel, Users from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +17,35 @@ log = logging.getLogger(__name__) FOLDER_FILE_TYPES = {'file', 'collection', 'note'} +def get_note_file_ids(entries: list[dict] | None) -> set[str]: + if not isinstance(entries, list): + return set() + return { + entry['id'] + for entry in entries + if isinstance(entry, dict) and entry.get('type') == 'file' and isinstance(entry.get('id'), str) and entry['id'] + } + + +async def has_access_to_note_files( + note: NoteModel, entries: list[dict] | None, user: UserModel, db: AsyncSession | None = None +) -> bool: + """Check the editor's read access to newly attached files.""" + if entries is not None and not isinstance(entries, list): + return False + + # Existing attachments may belong to another editor; only additions need permission. + added_ids = get_note_file_ids(entries) - get_note_file_ids((note.data or {}).get('files')) + if not added_ids or user.role == 'admin': + return True + + group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)} + for file_id in added_ids: + if not await has_access_to_file(file_id, 'read', user, db=db, user_group_ids=group_ids): + return False + return True + + async def has_access_to_file( file_id: str | None, access_type: str,