diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ad311a371a..2f44750fe0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,6 +18,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r **Before submitting, make sure you've checked the following:** +- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review. - [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.** - [ ] **Description:** Provide a concise description of the changes made in this pull request down below. - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a76993b2d..95d97dcea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.5] - 2026-05-09 + +### Added + +- 🛡️ **Redirect-based SSRF protection.** All outbound HTTP requests now block 3xx redirects by default via a new `AIOHTTP_CLIENT_ALLOW_REDIRECTS` environment variable, preventing redirect-based SSRF where a public URL silently redirects to internal addresses (RFC 1918, loopback, cloud-metadata endpoints). Affected call sites include web fetch, image loading, OAuth discovery, tool server execution, and code interpreter login. [#24491](https://github.com/open-webui/open-webui/pull/24491) +- 🛡️ **Iframe content security policy.** Administrators can now configure a Content-Security-Policy for all srcdoc iframes (Artifacts, tool embeds, file previews, citation modals) via the `IFRAME_CSP` environment variable, restricting what LLM-generated or user-uploaded HTML can load and execute inside previews. [Commit](https://github.com/open-webui/open-webui/commit/3bba1c227059a44c7eeefa97b8c69a63bf4f3454) +- 🎛️ **Granular markdown rendering controls.** Users can now independently disable Markdown rendering for user messages and assistant responses from Interface settings, preventing unintended formatting when pasting text that contains Markdown-sensitive characters. [Commit](https://github.com/open-webui/open-webui/commit/4a1064cefd6f48a8b3b02cd31f77838c8802b635) +- 🔧 **Terminal proxy response headers.** Administrators can now inject custom response headers into terminal proxy responses via the `TERMINAL_PROXY_HEADERS` environment variable (JSON object), enabling deployment-specific security headers like sandbox policies for proxied content. [Commit](https://github.com/open-webui/open-webui/commit/8d3133fe2835122bffaa4f2ce584730bc9c78981) +- 🔌 **Channel streaming and tool support.** Mentioning a model in a Channel now streams responses in real time and supports the full chat completion pipeline, including native and default function calling, built-in tools (web search, image generation), user tools, MCP tools, filters, and RAG knowledge injection — the same capabilities available in standard chats. + +### Fixed + +- 📝 **Notes create and open reliability.** Creating new notes and opening existing notes no longer fails with a TypeError caused by `is_pinned` being passed to the SQLAlchemy model on create, and passed twice to `NoteResponse` on read. [#24484](https://github.com/open-webui/open-webui/issues/24484), [#24486](https://github.com/open-webui/open-webui/pull/24486) +- 🔐 **Skill public sharing permission enforcement.** Creating or updating skills now filters access grants through the `sharing.public_skills` permission, preventing non-admin users from making skills publicly accessible without the required permission. [#24494](https://github.com/open-webui/open-webui/pull/24494) +- 🔐 **Calendar public sharing permission enforcement.** Creating or updating calendars now filters access grants through a new `sharing.public_calendars` permission, preventing users from making calendars publicly readable or writable without explicit admin-granted sharing permission. [#24493](https://github.com/open-webui/open-webui/pull/24493) +- 🔐 **Feedback user attribution spoofing.** Submitting evaluation feedback can no longer forge the `user_id` field through mass-assignment, preventing authenticated users from attributing ratings to other users and corrupting Elo leaderboard rankings and admin feedback exports. [#24508](https://github.com/open-webui/open-webui/pull/24508) +- 🛡️ **Image URL redirect-based SSRF.** Chat messages containing image URLs no longer follow 3xx redirects to internal addresses during base64 conversion, closing the most reachable redirect-based SSRF variant that required no special permissions or feature flags. [#24524](https://github.com/open-webui/open-webui/pull/24524) +- 🛡️ **Collection write access on file processing.** The `process_file` and `process_files_batch` retrieval endpoints now enforce collection write-access checks before embedding content, preventing authenticated users from injecting file content into another user's knowledge-base collection. [#24524](https://github.com/open-webui/open-webui/pull/24524) +- 🔐 **Tool source code update authorization.** Updating a tool's Python source code now requires `workspace.tools` or `workspace.tools_import` permission, preventing users with only a write-access grant from overwriting executable tool code while still allowing metadata edits. [#24513](https://github.com/open-webui/open-webui/pull/24513) +- 🔐 **Channel message ownership enforcement.** Updating or deleting messages in group and DM channels now requires message ownership, preventing channel members from tampering with or silently removing other members' messages. [#24506](https://github.com/open-webui/open-webui/pull/24506) +- 🔐 **Channel pin write permission.** Pinning and unpinning messages on standard channels now requires write permission instead of read permission, preventing read-only users from modifying pinned content. [#24521](https://github.com/open-webui/open-webui/pull/24521) +- 🛡️ **Image generation URL validation.** Generated image URLs are now validated through `validate_url()` before fetching, aligning the defense-in-depth posture with sibling image-loading paths. [#24518](https://github.com/open-webui/open-webui/pull/24518) +- 🔐 **Model params exposure for read-only users.** The per-model API endpoint now strips the `params` dict (including system prompts) from responses to callers without write access, preventing read-only users from viewing admin-curated model configuration. [#24525](https://github.com/open-webui/open-webui/pull/24525) +- 🛡️ **URL parser SSRF bypass.** URL validation now rejects backslash, tab, CR, and LF characters that cause urllib and requests/aiohttp to disagree on the target host, closing a parser-confusion SSRF bypass. [#24534](https://github.com/open-webui/open-webui/pull/24534) +- 🛡️ **Profile image MIME-type allowlist.** Serving profile images from data URIs now enforces a strict MIME-type allowlist (PNG, JPEG, GIF, WEBP by default, configurable via `PROFILE_IMAGE_ALLOWED_MIME_TYPES`) and sets `X-Content-Type-Options: nosniff`, preventing stored-XSS through SVG or other executable content types. [Commit](https://github.com/open-webui/open-webui/commit/15e696691cad98692c329de62ed8a5bdb3a26d4e) +- 🔐 **File ownership in folder and knowledge attachments.** Attaching files to folders or knowledge bases now verifies per-file read access, and folder file lists in chat middleware are filtered to entries the caller can read, preventing unauthorized file content from being injected into RAG context. [Commit](https://github.com/open-webui/open-webui/commit/2dbf7b6764a7922458d3b0139687ad6dcd7596d9) +- 🔐 **Shared chat access for owners and admins.** Chat owners can now view and clone their own shared chats without requiring an explicit access grant, and administrators can manage shared chat access controls on any chat. [Commit](https://github.com/open-webui/open-webui/commit/3a21b334cce30226750c5c537345dc51bb8bef17), [Commit](https://github.com/open-webui/open-webui/commit/315566064aedeff071854b023d09e5f1ea0eb950) +- 🧵 **Legacy chat history self-healing.** Loading legacy conversations now automatically detects broken parent-link graphs in migrated message records, merges missing messages from the embedded JSON history, and backfills them to the normalized table so future loads use the fast path without data loss. [Commit](https://github.com/open-webui/open-webui/commit/1388f4568b8f508c26542673dd01f1fa049e798a) +- 🎛️ **Filter selector reactivity.** Model filter checkboxes now derive state reactively from the current filter list and selected IDs instead of capturing a one-time snapshot at mount, so checkboxes update correctly when model contexts or filter configurations change at runtime. [Commit](https://github.com/open-webui/open-webui/commit/d1ef5382377f590f97a6dbaee88f369e6d7c5f6f) +- 🌐 **Portuguese (Brazil) translation updates.** Translations for newly added UI items were added along with a consistency pass across existing entries. [#24503](https://github.com/open-webui/open-webui/pull/24503) + +### Changed + +- 🧹 **Removed unauthenticated retrieval status endpoint.** The unauthenticated `GET /api/v1/retrieval/` status endpoint has been removed as dead code — retrieval configuration is already available through authenticated admin endpoints. [#24497](https://github.com/open-webui/open-webui/pull/24497) +- 📋 **PR template issue requirement.** Pull requests now require a linked Issue or Discussion reference, ensuring better traceability for all contributions. PRs without a linked issue or discussion may be closed without review. + ## [0.9.4] - 2026-05-09 ### Fixed diff --git a/Dockerfile b/Dockerfile index 88ffd09752..36e29e7069 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH} RUN npm run build ######## WebUI backend ######## -FROM python:3.11.14-slim-bookworm AS base +FROM python:3.11-slim-bookworm AS base # Use args ARG USE_CUDA diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index dbf9fad19b..6a29504cb6 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1226,6 +1226,11 @@ TERMINAL_SERVER_CONNECTIONS = PersistentConfig( terminal_server_connections, ) +try: + TERMINAL_PROXY_HEADERS = json.loads(os.environ.get('TERMINAL_PROXY_HEADERS', '{}')) +except Exception: + TERMINAL_PROXY_HEADERS = {} + #################################### # WEBUI #################################### @@ -1371,6 +1376,7 @@ RESPONSE_WATERMARK = PersistentConfig( os.environ.get('RESPONSE_WATERMARK', ''), ) +IFRAME_CSP = os.environ.get('IFRAME_CSP', '') USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = ( os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS', 'False').lower() == 'true' @@ -1465,6 +1471,10 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = ( os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) +USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = ( + os.environ.get('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' +) + USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = ( os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true' ) @@ -1585,6 +1595,7 @@ DEFAULT_USER_PERMISSIONS = { 'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING, 'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, 'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING, + 'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING, }, 'access_grants': { 'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 8a9b3af365..903b3effcc 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -260,6 +260,15 @@ ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'tr # controlled origins) and fall through to the default image instead. ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get('ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True').lower() == 'true' +PROFILE_IMAGE_ALLOWED_MIME_TYPES = frozenset( + t.strip() + for t in os.environ.get( + 'PROFILE_IMAGE_ALLOWED_MIME_TYPES', + 'image/png,image/jpeg,image/gif,image/webp', + ).split(',') + if t.strip() +) + #################################### # WEBUI_BUILD_HASH #################################### @@ -824,6 +833,13 @@ else: AIOHTTP_CLIENT_SESSION_SSL = os.environ.get('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true' +# When False (default), outbound HTTP requests do not follow 3xx redirects. +# This prevents redirect-based SSRF where a public URL 302-redirects to an +# internal address (RFC 1918, loopback, cloud-metadata 169.254.169.254). +# Set to True only if your deployment requires redirect following and you +# have other SSRF protections in place (e.g. egress firewall). +AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.environ.get('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true' + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( 'AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST', os.environ.get('AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST', '10'), diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 52e59d26e0..d47c88fc54 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -460,6 +460,7 @@ from open_webui.config import ( OAUTH_PROVIDERS, WEBUI_URL, RESPONSE_WATERMARK, + IFRAME_CSP, # Admin ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_ANALYTICS, @@ -1795,7 +1796,9 @@ async def chat_completion( if metadata.get('chat_id') and user: chat_id = metadata['chat_id'] - if not chat_id.startswith('local:'): # temporary chats are not stored + if not chat_id.startswith('local:') and not chat_id.startswith( + 'channel:' + ): # temporary/channel chats are not stored if is_new_chat: # Build the full history upfront with ALL assistant placeholders user_message = metadata.get('user_message') or {} @@ -2011,7 +2014,7 @@ async def chat_completion( if metadata.get('chat_id') and metadata.get('message_id'): # Update the chat message with the error try: - if not metadata['chat_id'].startswith('local:'): + if not metadata['chat_id'].startswith('local:') and not metadata['chat_id'].startswith('channel:'): await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -2274,7 +2277,7 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)): @app.get('/api/tasks/chat/{chat_id:path}') async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): - if chat_id.startswith('local:'): + if chat_id.startswith('local:') or chat_id.startswith('channel:'): socket_id = chat_id[len('local:') :] owner_id = get_user_id_from_session_pool(socket_id) if owner_id != user.id and user.role != 'admin': @@ -2292,7 +2295,7 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De @app.post('/api/tasks/chat/{chat_id:path}/stop') async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): - if chat_id.startswith('local:'): + if chat_id.startswith('local:') or chat_id.startswith('channel:'): socket_id = chat_id[len('local:') :] owner_id = get_user_id_from_session_pool(socket_id) if owner_id != user.id and user.role != 'admin': @@ -2444,6 +2447,7 @@ async def get_app_config(request: Request): 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, 'response_watermark': app.state.config.RESPONSE_WATERMARK, + 'iframe_csp': IFRAME_CSP, }, 'license_metadata': app.state.LICENSE_METADATA, **( diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index af999f21cd..957492d817 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -459,24 +459,87 @@ class ChatTable: return None return row[0] or 'New Chat' + @staticmethod + def get_unresolved_parent_ids(messages_map: dict) -> set[str]: + """Return parent IDs referenced by messages but absent from the map. + + An empty set means the message graph is fully connected. + """ + return { + msg['parentId'] + for msg in messages_map.values() + if msg.get('parentId') and msg['parentId'] not in messages_map + } + + async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None: + """Write messages to the ``chat_message`` table so future lookups + use the fast path. Errors are logged but never raised. + """ + for message_id, message in messages.items(): + if not isinstance(message, dict) or not message.get('role'): + continue + try: + await ChatMessages.upsert_message( + message_id=message_id, + chat_id=chat_id, + user_id=user_id, + data=message, + ) + except Exception as e: + log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e) + async def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: """Message map for walking history (see ``get_message_list``). - Prefer ``chat_message`` rows to avoid loading the large ``chat`` - JSON blob; fall back to embedded history when no rows exist - (legacy chats). + Prefer ``chat_message`` rows to avoid loading the large embedded + history; fall back to the legacy JSON when no rows exist. + When rows exist but the parent-link graph has gaps (e.g. migration + failures), missing messages are merged from the legacy history + and backfilled so future requests self-heal. """ # Fast path: build from normalized chat_message rows. messages_map = await ChatMessages.get_messages_map_by_chat_id(id) + if messages_map is not None: + unresolved_ids = self.get_unresolved_parent_ids(messages_map) + if not unresolved_ids: + return messages_map + + # Graph has gaps — enrich from the legacy embedded history. + log.info( + 'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history', + id, + len(unresolved_ids), + ) + chat = await self.get_chat_by_id(id) + if chat: + history_messages = chat.chat.get('history', {}).get('messages', {}) or {} + missing_messages = { + message_id: history_messages[message_id] + for message_id in unresolved_ids + if message_id in history_messages + } + + if missing_messages: + messages_map.update(missing_messages) + + # Backfill so future requests use the fast path. + await self.backfill_messages_by_chat_id(id, chat.user_id, missing_messages) + return messages_map - # No rows — fall back to the embedded JSON blob for legacy chats. + # No rows — fall back to the legacy embedded history. chat = await self.get_chat_by_id(id) if chat is None: return None - return chat.chat.get('history', {}).get('messages', {}) or {} + history_messages = chat.chat.get('history', {}).get('messages', {}) or {} + + # Backfill so future requests use the fast path. + if history_messages: + await self.backfill_messages_by_chat_id(id, chat.user_id, history_messages) + + return history_messages async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]: chat = await self.get_chat_by_id(id) diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 02f61f82ee..d8ae4dc9b1 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -103,7 +103,8 @@ class FeedbackForm(BaseModel): data: Optional[RatingData] = None meta: Optional[dict] = None snapshot: Optional[SnapshotData] = None - model_config = ConfigDict(extra='allow') + # ignore: drop client-supplied id/user_id/version/timestamps at parse time. + model_config = ConfigDict(extra='ignore') class UserResponse(BaseModel): @@ -145,12 +146,13 @@ class FeedbackTable: ) -> Optional[FeedbackModel]: async with get_async_db_context(db) as db: id = str(uuid.uuid4()) + # Spread form_data first so server-controlled fields win on duplicate keys. feedback = FeedbackModel( **{ + **form_data.model_dump(), 'id': id, 'user_id': user_id, 'version': 0, - **form_data.model_dump(), 'created_at': int(time.time()), 'updated_at': int(time.time()), } diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index a665004a95..f651d226ca 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -140,7 +140,7 @@ class NoteTable: } ) - new_note = Note(**note.model_dump(exclude={'access_grants'})) + new_note = Note(**note.model_dump(exclude={'access_grants', 'is_pinned'})) db.add(new_note) await db.commit() diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 14a64fed60..8e672b7a8f 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -43,6 +43,7 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, + AIOHTTP_CLIENT_ALLOW_REDIRECTS, OFFLINE_MODE, ENABLE_FORWARD_USER_INFO_HEADERS, AIOHTTP_CLIENT_SESSION_SSL, @@ -180,8 +181,12 @@ def get_content_from_url(request, url: str) -> str: validate_url(url) # Streamed GET to check Content-Type without downloading the body. + # allow_redirects=False prevents redirect-based SSRF: validate_url() above is + # called on the originally-submitted URL only; following 3xx redirects without + # re-validation would let an attacker reach private IPs (RFC1918, loopback, + # cloud-metadata 169.254.169.254) via a public host that redirects internally. try: - response = requests.get(url, stream=True, timeout=30) + response = requests.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS) response.raise_for_status() content_type = response.headers.get('Content-Type', '') except Exception: diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 6ee0e3781a..c2ce6bdbd1 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -48,7 +48,7 @@ from open_webui.config import ( WEB_FETCH_FILTER_LIST, ) from open_webui.utils.misc import is_string_allowed -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS log = logging.getLogger(__name__) @@ -69,6 +69,14 @@ def validate_url(url: Union[str, Sequence[str]]): if isinstance(validators.url(url), validators.ValidationError): raise ValueError(ERROR_MESSAGES.INVALID_URL) + # Reject parser-confusing chars: urlparse and requests/aiohttp split + # on these differently, e.g. http://127.0.0.1\@1.1.1.1 → urlparse + # extracts 1.1.1.1 (public, passes filter) while requests connects + # to 127.0.0.1 (internal). Same shape with tab/CR/LF. + if any(ch in url for ch in ('\\', '\t', '\n', '\r')): + log.warning(f'Blocked URL with parser-confusing char: {url!r}') + raise ValueError(ERROR_MESSAGES.INVALID_URL) + parsed_url = urllib.parse.urlparse(url) # Protocol validation - only allow http/https @@ -485,6 +493,17 @@ class SafeWebBaseLoader(WebBaseLoader): """ super().__init__(*args, **kwargs) self.trust_env = trust_env + # Prevent redirect-based SSRF on the synchronous _scrape() path. + # validate_url() is called once on the originally-submitted URL, but the + # parent WebBaseLoader's _scrape() invokes self.session.get(url, **self.requests_kwargs) + # which by default follows redirects. Without the override below, an attacker + # can submit a public URL that 302-redirects to an internal address (RFC1918, + # 127.0.0.1, 169.254.169.254, etc.) and the redirected target is fetched without + # re-validation. Matches the policy enforced on the async _fetch() path below. + self.requests_kwargs = { + **(self.requests_kwargs or {}), + 'allow_redirects': AIOHTTP_CLIENT_ALLOW_REDIRECTS, + } async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str: async with aiohttp.ClientSession(trust_env=self.trust_env) as session: @@ -502,7 +521,7 @@ class SafeWebBaseLoader(WebBaseLoader): async with session.get( url, **(self.requests_kwargs | kwargs), - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: if self.raise_for_status: response.raise_for_status() diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index c95888ebfa..bdc06e819b 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -22,7 +22,7 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import UserModel from open_webui.utils.auth import get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.utils.calendar import expand_recurring_event from open_webui.constants import ERROR_MESSAGES @@ -112,6 +112,17 @@ async def get_calendars(request: Request, user: UserModel = Depends(get_verified async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): """Create a new user calendar.""" await check_calendar_permission(request, user) + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/models pattern). Without this, any verified user + # could create a calendar with `principal_id='*' permission='read'|'write'`, + # making their events readable or writable by any other verified user. + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_calendars', + ) return await Calendars.insert_new_calendar(user.id, form_data) @@ -350,6 +361,20 @@ async def update_calendar( if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=403, detail='Only owner can manage sharing') + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/models pattern). The owner-only check above + # only restricts WHO can set grants; this filter restricts WHICH grants + # they may set, so a non-admin owner cannot make their calendar + # publicly readable/writable without the corresponding sharing permission. + if form_data.access_grants is not None: + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_calendars', + ) + updated = await Calendars.update_calendar_by_id(calendar_id, form_data) if not updated: raise HTTPException(status_code=500, detail='Failed to update') diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 487899fccf..70eb799ea6 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -57,7 +57,6 @@ from open_webui.utils.models import ( get_all_models, get_filtered_models, ) -from open_webui.utils.chat import generate_chat_completion from open_webui.utils.auth import get_admin_user, get_verified_user @@ -979,57 +978,48 @@ async def model_response_handler(request, channel, message, user, db=None): ], ] + # Resolve model config (same helpers automations use) + from open_webui.utils.automations import ( + _resolve_model_tool_ids, + _resolve_model_features, + _resolve_model_filter_ids, + ) + + tool_ids = _resolve_model_tool_ids(request.app, model_id) + features = _resolve_model_features(request.app, model_id) + filter_ids = _resolve_model_filter_ids(request.app, model_id) + + # Build full form_data — same shape as frontend POST. + # The channel: prefix routes pipeline events to the + # channel emitter in socket/main.py instead of the + # default chat emitter. form_data = { 'model': model_id, 'messages': [ system_message, {'role': 'user', 'content': content}, ], - 'stream': False, + 'stream': True, + 'chat_id': f'channel:{channel.id}', + 'id': response_message.id, + 'session_id': f'channel:{channel.id}', + 'background_tasks': {}, } + if tool_ids: + form_data['tool_ids'] = tool_ids + if features: + form_data['features'] = features + if filter_ids: + form_data['filter_ids'] = filter_ids - res = await generate_chat_completion( - request, - form_data=form_data, - user=user, - ) + # Call the full chat completion pipeline — streaming, + # tools, filters, RAG — everything. The pipeline runs as + # an async task; the channel emitter handles progressive + # message updates via socket events. + await request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) - if res: - if res.get('choices', []) and len(res['choices']) > 0: - await update_message_by_id( - request, - channel.id, - response_message.id, - MessageForm( - **{ - 'content': res['choices'][0]['message']['content'], - 'meta': { - 'done': True, - }, - } - ), - user, - db, - ) - elif res.get('error', None): - await update_message_by_id( - request, - channel.id, - response_message.id, - MessageForm( - **{ - 'content': f'Error: {res["error"]}', - 'meta': { - 'done': True, - }, - } - ), - user, - db, - ) except Exception as e: - log.info(e) - pass + log.exception(e) return True @@ -1256,7 +1246,8 @@ async def pin_channel_message( if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): + # Pin/unpin mutates is_pinned/pinned_by/pinned_at — require write. + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='write', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) message = await Messages.get_message_by_id(message_id, db=db) @@ -1368,6 +1359,9 @@ async def update_message_by_id( if channel.type in ['group', 'dm']: if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Membership is not authorship — block cross-member edits. + if user.role != 'admin' and message.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: if ( user.role != 'admin' @@ -1569,6 +1563,9 @@ async def delete_message_by_id( if channel.type in ['group', 'dm']: if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Membership is not authorship — block cross-member deletes. + if user.role != 'admin' and message.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: if ( user.role != 'admin' diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 6c1e2119a0..9c4609477c 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -877,7 +877,7 @@ async def get_shared_chat_by_id( # Look up the original chat_id to check access grants (admins bypass) if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS: shared = await SharedChats.get_by_id(share_id, db=db) - if shared: + if shared and shared.user_id != user.id: has_grant = await AccessGrants.has_access( user_id=user.id, resource_type='shared_chat', @@ -1241,9 +1241,9 @@ async def clone_shared_chat_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - # Enforce access grants + # Enforce access grants (owner and admins bypass) shared = await SharedChats.get_by_id(id, db=db) - if shared and user.role != 'admin': + if shared and user.role != 'admin' and shared.user_id != user.id: has_grant = await AccessGrants.has_access( user_id=user.id, resource_type='shared_chat', @@ -1412,19 +1412,16 @@ async def update_shared_chat_access_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if user.role == 'admin': + chat = await Chats.get_chat_by_id(id, db=db) + else: + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if not chat: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - if chat.user_id != user.id and user.role != 'admin': - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, @@ -1449,19 +1446,16 @@ async def get_shared_chat_access_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if user.role == 'admin': + chat = await Chats.get_chat_by_id(id, db=db) + else: + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if not chat: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - if chat.user_id != user.id and user.role != 'admin': - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db) return [ { diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index ebd0c0cb17..7dda918821 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -16,8 +16,6 @@ from open_webui.models.folders import ( Folders, ) from open_webui.models.chats import Chats -from open_webui.models.files import Files -from open_webui.models.knowledge import Knowledges from open_webui.config import UPLOAD_DIR @@ -32,6 +30,7 @@ from fastapi.responses import FileResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control.files import get_accessible_folder_files log = logging.getLogger(__name__) @@ -75,20 +74,10 @@ async def get_folders( if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db): folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db) - if folder.data: - if 'files' in folder.data: - valid_files = [] - for file in folder.data['files']: - if file.get('type') == 'file': - if await Files.check_access_by_user_id(file.get('id'), user.id, 'read', db=db): - valid_files.append(file) - elif file.get('type') == 'collection': - if await Knowledges.check_access_by_user_id(file.get('id'), user.id, 'read', db=db): - valid_files.append(file) - else: - valid_files.append(file) - - folder.data['files'] = valid_files + if folder.data and 'files' in folder.data: + accessible_files = await get_accessible_folder_files(folder.data['files'], user, db=db) + if len(accessible_files) != len(folder.data.get('files', [])): + folder.data['files'] = accessible_files await Folders.update_folder_by_id_and_user_id( folder.id, user.id, FolderUpdateForm(data=folder.data), db=db ) @@ -173,6 +162,16 @@ async def update_folder_name_by_id( detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) + # Validate read access to every file/collection being attached. + # Folder files are consumed by chat middleware as RAG context. + if form_data.data and isinstance(form_data.data.get('files'), list): + accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db) + if len(accessible_files) != len(form_data.data['files']): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + try: folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db) return folder diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 7ef4938b2e..e55b7c5798 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -22,7 +22,7 @@ from open_webui.config import ( ) from open_webui.constants import ERROR_MESSAGES from open_webui.retrieval.web.utils import validate_url -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS, ENABLE_FORWARD_USER_INFO_HEADERS from open_webui.utils.session_pool import get_session from open_webui.models.chats import Chats @@ -442,6 +442,8 @@ GenerateImageForm = CreateImageForm # Alias for backward compatibility async def get_image_data(data: str, headers=None): try: if data.startswith('http://') or data.startswith('https://'): + # Defense-in-depth: gate before fetch (mirrors load_url_image). + validate_url(data) session = await get_session() async with session.get( data, @@ -807,10 +809,16 @@ async def image_edits( return data if data.startswith('http://') or data.startswith('https://'): - # Validate URL to prevent SSRF attacks against local/private networks + # Validate URL to prevent SSRF attacks against local/private networks. + # allow_redirects=False prevents redirect-based SSRF: validate_url() is + # called only on the originally-submitted URL; following 3xx redirects + # without re-validation would let an attacker reach private IPs via a + # public host that redirects internally (e.g. cloud-metadata exfil). validate_url(data) session = await get_session() - async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: + async with session.get( + data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS + ) as r: r.raise_for_status() image_data = base64.b64encode(await r.read()).decode('utf-8') diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index f503169fc0..8ff987b610 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -31,6 +31,7 @@ from open_webui.storage.provider import Storage from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_verified_user, get_admin_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants +from open_webui.utils.access_control.files import has_access_to_file from open_webui.models.access_grants import AccessGrants @@ -656,6 +657,14 @@ async def add_file_to_knowledge_by_id( detail=ERROR_MESSAGES.FILE_NOT_PROCESSED, ) + # KB write-access alone is not enough — caller must also be able to read the file. + if file.user_id != user.id and user.role != 'admin': + if not await has_access_to_file(file.id, 'read', user, db=db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Add content to the vector database try: await process_file( @@ -1017,6 +1026,15 @@ async def add_files_to_knowledge_batch( detail=f'File {missing_ids[0]} not found', ) + # Per-file read-access check — same gate as the single-file endpoint. + if user.role != 'admin': + for file in files: + if file.user_id != user.id and not await has_access_to_file(file.id, 'read', user, db=db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Process files try: result = await process_files_batch( diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 1ced11b358..2a78daa94d 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -413,30 +413,37 @@ class ModelIdForm(BaseModel): async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): model = await Models.get_model_by_id(id, db=db) if model: - if ( + write_access = ( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) - or model.user_id == user.id + or user.id == model.user_id or await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model.id, - permission='read', + permission='write', db=db, ) + ) + + if write_access or await AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=model.id, + permission='read', + db=db, ): + model_dict = model.model_dump() + # Strip params (system prompt and other admin-curated config) + # for read-only callers — matches the params strip already + # enforced on /api/models in utils/models.py. Owners, admins + # under BYPASS_ADMIN_ACCESS_CONTROL, and write-grant holders + # still receive the full object so the workspace edit UI keeps + # working for users who legitimately curate the model. + if not write_access: + model_dict['params'] = {} return ModelAccessResponse( - **model.model_dump(), - write_access=( - (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == model.user_id - or await AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model.id, - permission='write', - db=db, - ) - ), + **model_dict, + write_access=write_access, ) else: raise HTTPException( diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 9a23a104c9..5ed46b5d61 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -294,7 +294,10 @@ async def get_note_by_id( ) pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) - return NoteResponse(**note.model_dump(), write_access=write_access, is_pinned=note.id in pinned_note_ids) + return NoteResponse( + **{**note.model_dump(), 'is_pinned': note.id in pinned_note_ids}, + write_access=write_access, + ) ############################ diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 274eafe8ae..201e6a63fb 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -260,22 +260,6 @@ class SearchForm(BaseModel): queries: List[str] -@router.get('/') -async def get_status(request: Request): - return { - 'status': True, - 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, - 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, - 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, - 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, - 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, - 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, - 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, - 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, - } - - @router.get('/embedding') async def get_embedding_config(request: Request, user=Depends(get_admin_user)): return { @@ -1583,6 +1567,8 @@ async def process_file( if collection_name is None: collection_name = f'file-{file.id}' + else: + await _validate_collection_access([collection_name], user, access_type='write') if form_data.content: # Update the content in the file @@ -2633,6 +2619,9 @@ async def process_files_batch( collection_name = form_data.collection_name + if collection_name: + await _validate_collection_access([collection_name], user, access_type='write') + file_results: List[BatchProcessFilesResult] = [] file_errors: List[BatchProcessFilesResult] = [] file_updates: List[FileUpdateForm] = [] diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 490d1706d5..ede5afd814 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -176,6 +176,19 @@ async def create_new_skill( detail=ERROR_MESSAGES.ID_TAKEN, ) + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/calendar pattern). Without this, a user with + # workspace.skills permission could attach principal_id='*' read/write + # grants in the create payload, bypassing the sharing.public_skills gate + # that the dedicated /access/update endpoint already enforces. + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_skills', + ) + try: skill = await Skills.insert_new_skill(user.id, form_data, db=db) if skill: @@ -276,6 +289,19 @@ async def update_skill_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/calendar pattern). The access check above only + # restricts WHO can write to the skill; this filter restricts WHICH grants + # they may set, so a non-admin owner cannot make their own skill publicly + # readable/writable without sharing.public_skills permission. + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_skills', + ) + try: updated = { **form_data.model_dump(exclude={'id'}), diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 003db06968..c251b20d48 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -17,6 +17,7 @@ from starlette.background import BackgroundTask from open_webui.utils.auth import get_verified_user from open_webui.utils.access_control import has_connection_access from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.config import TERMINAL_PROXY_HEADERS from open_webui.models.groups import Groups from open_webui.models.users import Users @@ -151,6 +152,8 @@ async def proxy_terminal( for key, value in upstream_response.headers.items() if key.lower() not in STRIPPED_RESPONSE_HEADERS } + if TERMINAL_PROXY_HEADERS: + filtered_headers.update(TERMINAL_PROXY_HEADERS) # Stream binary responses directly if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES): diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 04d845c3de..cd11bcde5e 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -480,6 +480,17 @@ async def update_tools_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Content edits trigger exec on load — gate them behind workspace.tools (matches /create). + if form_data.content != tools.content: + if user.role != 'admin' and not ( + await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) + or await has_permission(user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + try: form_data.content = replace_imports(form_data.content) tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index bcf11936e2..33d1cd425c 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -29,7 +29,7 @@ from open_webui.models.users import ( ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, STATIC_DIR +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR from open_webui.internal.db import get_async_session @@ -194,6 +194,7 @@ class SharingPermissions(BaseModel): notes: bool = False public_notes: bool = True public_chats: bool = False + public_calendars: bool = False class AccessGrantsPermissions(BaseModel): @@ -235,6 +236,7 @@ class FeaturesPermissions(BaseModel): code_interpreter: bool = True memories: bool = True automations: bool = False + calendar: bool = True class SettingsPermissions(BaseModel): @@ -492,12 +494,18 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u header, base64_data = user.profile_image_url.split(',', 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) - media_type = header.split(';')[0].lstrip('data:') + media_type = header.split(';')[0].lstrip('data:').lower() + + if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: + return FileResponse(f'{STATIC_DIR}/user.png') return StreamingResponse( image_buffer, media_type=media_type, - headers={'Content-Disposition': 'inline'}, + headers={ + 'Content-Disposition': 'inline', + 'X-Content-Type-Options': 'nosniff', + }, ) except Exception as e: pass diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 20e78d6c37..d59ff53277 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -832,7 +832,76 @@ async def disconnect(sid): # print(f"Unknown session ID {sid} disconnected") +async def _make_channel_emitter(request_info): + """Event emitter that routes pipeline output to a channel message. + + Translates chat:completion events into channel message:update socket + emissions, throttled to avoid flooding with per-token updates. + """ + channel_id = request_info['chat_id'].removeprefix('channel:') + message_id = request_info['message_id'] + + state = {'last_emit_at': 0.0} + THROTTLE_INTERVAL = 0.15 # ~6 updates/sec + + async def _emit_channel_update(content: str, done: bool = False): + from open_webui.models.messages import Messages, MessageForm + + update_form = MessageForm(content=content) + if done: + # Merge done flag into existing meta (preserve model_id etc.) + msg = await Messages.get_message_by_id(message_id) + existing_meta = (msg.meta or {}) if msg else {} + update_form = MessageForm( + content=content, + meta={**existing_meta, 'done': True}, + ) + + await Messages.update_message_by_id(message_id, update_form) + message = await Messages.get_message_by_id(message_id) + if message: + await sio.emit( + 'events:channel', + { + 'channel_id': channel_id, + 'message_id': message_id, + 'data': { + 'type': 'message:update', + 'data': message.model_dump(), + }, + }, + to=f'channel:{channel_id}', + ) + + async def __channel_emitter__(event_data): + event_type = event_data.get('type') + + if event_type == 'chat:completion': + data = event_data.get('data', {}) + content = data.get('content', '') + done = data.get('done', False) + + if not content and not done: + return + + now = __import__('time').time() + if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL: + state['last_emit_at'] = now + await _emit_channel_update(content, done) + + elif event_type == 'chat:message:error': + error = event_data.get('data', {}).get('error', {}) + error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error) + await _emit_channel_update(f'Error: {error_content}', done=True) + + return __channel_emitter__ + + async def get_event_emitter(request_info, update_db=True): + # Channel mode: route pipeline output to channel message updates + if request_info.get('chat_id', '').startswith('channel:'): + return await _make_channel_emitter(request_info) + async def __event_emitter__(event_data): user_id = request_info['user_id'] chat_id = request_info['chat_id'] diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index a48dfeb0f1..fb318e3c66 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -87,3 +87,38 @@ async def has_access_to_file( return True return False + + +async def get_accessible_folder_files( + entries: list[dict] | None, + user: UserModel, + db: AsyncSession | None = None, +) -> list[dict]: + """Filter folder.data['files'] entries to those the caller can read. + + Each entry is expected to have 'type' ('file' or 'collection') and 'id'. + Admins bypass all checks. Unknown types are kept as-is. + """ + if not entries: + return [] + if user.role == 'admin': + return list(entries) + + accessible: list[dict] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + entry_type = entry.get('type') + entry_id = entry.get('id') + if not entry_id: + accessible.append(entry) + continue + if entry_type == 'file': + if await has_access_to_file(entry_id, 'read', user, db=db): + accessible.append(entry) + elif entry_type == 'collection': + if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db): + accessible.append(entry) + else: + accessible.append(entry) + return accessible diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index 3e30c419ae..52ddea24a7 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -8,6 +8,8 @@ import aiohttp import websockets from pydantic import BaseModel +from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS + logger = logging.getLogger(__name__) @@ -88,7 +90,7 @@ class JupyterCodeExecuter: async with self.session.post( 'login', data={'_xsrf': xsrf_token, 'password': self.password}, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: response.raise_for_status() self.session.cookie_jar.update_cookies(response.cookies) diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 8149987fe4..6b821d58b6 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -26,7 +26,11 @@ import base64 import io import re -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK +from open_webui.env import ( + AIOHTTP_CLIENT_ALLOW_REDIRECTS, + AIOHTTP_CLIENT_SESSION_SSL, + ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK, +) from open_webui.utils.session_pool import get_session BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE) @@ -53,11 +57,17 @@ _IMAGE_MIME_FALLBACK = { async def get_image_base64_from_url(url: str) -> Optional[str]: try: if url.startswith('http'): - # Validate URL to prevent SSRF attacks against local/private networks + # Validate URL to prevent SSRF attacks against local/private networks. + # allow_redirects=False prevents redirect-based SSRF: validate_url() is + # called only on the originally-submitted URL; following 3xx redirects + # without re-validation would let an attacker reach private IPs via a + # public host that redirects internally (e.g. cloud-metadata exfil). validate_url(url) # Download the image from the URL session = await get_session() - async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response: + async with session.get( + url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS + ) as response: response.raise_for_status() image_data = await response.read() encoded_string = base64.b64encode(image_data).decode('utf-8') diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 3e25effa9a..56226fc226 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -106,6 +106,7 @@ from open_webui.utils.tools import ( get_terminal_tools, ) from open_webui.utils.access_control import has_connection_access +from open_webui.utils.access_control.files import get_accessible_folder_files from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.filter import ( get_sorted_filter_ids, @@ -1707,7 +1708,7 @@ async def add_file_context(messages: list, chat_id: str, user) -> list: """ Add file URLs to messages for native function calling. """ - if not chat_id or chat_id.startswith('local:'): + if not chat_id or chat_id.startswith('local:') or chat_id.startswith('channel:'): return messages chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) @@ -1763,7 +1764,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: return form_data - if chat_id.startswith('local:'): + if chat_id.startswith('local:') or chat_id.startswith('channel:'): message_list = form_data.get('messages', []) else: chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) @@ -2295,7 +2296,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): chat_id = metadata.get('chat_id') user_message_id = metadata.get('user_message_id') - if chat_id and user_message_id and not chat_id.startswith('local:'): + if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'): db_messages = await load_messages_from_db(chat_id, user_message_id) if db_messages: # Continue: frontend sends assistant_message_id when continuing @@ -2407,15 +2408,17 @@ async def process_chat_payload(request, form_data, user, metadata, model): if 'system_prompt' in folder.data: form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user) if 'files' in folder.data: + # Defensive: filter to entries the caller can still read. + allowed_files = await get_accessible_folder_files(folder.data['files'], user) if metadata.get('params', {}).get('function_calling') != 'native': form_data['files'] = [ - *folder.data['files'], + *allowed_files, *form_data.get('files', []), ] else: # Native FC: skip RAG injection, builtin tools # will read folder knowledge from metadata. - metadata['folder_knowledge'] = folder.data['files'] + metadata['folder_knowledge'] = allowed_files # Model "Knowledge" handling user_message = get_last_user_message(form_data['messages']) @@ -2615,7 +2618,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) if folder and folder.data and 'files' in folder.data: files = [f for f in files if f.get('id', None) != folder_id] - files = [*files, *folder.data['files']] + files = [*files, *await get_accessible_folder_files(folder.data['files'], user)] # files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]] # Remove duplicate files based on their content @@ -3055,7 +3058,11 @@ async def background_tasks_handler(ctx): message = None messages = [] - if 'chat_id' in metadata and not metadata['chat_id'].startswith('local:'): + if ( + 'chat_id' in metadata + and not metadata['chat_id'].startswith('local:') + and not metadata['chat_id'].startswith('channel:') + ): messages_map = await Chats.get_messages_map_by_chat_id(metadata['chat_id']) message = messages_map.get(metadata['message_id']) if messages_map else None @@ -3135,7 +3142,9 @@ async def background_tasks_handler(ctx): } ) - if not metadata.get('chat_id', '').startswith('local:'): + if not metadata.get('chat_id', '').startswith('local:') and not metadata.get( + 'chat_id', '' + ).startswith('channel:'): await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -3147,7 +3156,9 @@ async def background_tasks_handler(ctx): except Exception as e: pass - if not metadata.get('chat_id', '').startswith('local:'): # Only update titles and tags for non-temp chats + if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith( + 'channel:' + ): # Only update titles and tags for non-temp chats if TASKS.TITLE_GENERATION in tasks: user_message = get_last_user_message(messages) if user_message and len(user_message) > 100: @@ -3271,7 +3282,7 @@ async def outlet_filter_handler(ctx): if not chat_id or not message_id: return - is_temp_chat = chat_id.startswith('local:') + is_temp_chat = chat_id.startswith('local:') or chat_id.startswith('channel:') try: messages_map = None @@ -3413,13 +3424,14 @@ async def non_streaming_chat_response_handler(response, ctx): log.error('Provider returned error (non-streaming): %s', error) - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'error': {'content': error}, - }, - ) + if not metadata['chat_id'].startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) if isinstance(error, str) or isinstance(error, dict): await event_emitter( { @@ -3428,7 +3440,7 @@ async def non_streaming_chat_response_handler(response, ctx): } ) - if 'selected_model_id' in response_data: + if 'selected_model_id' in response_data and not metadata['chat_id'].startswith('channel:'): await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -3449,7 +3461,11 @@ async def non_streaming_chat_response_handler(response, ctx): } ) - title = await Chats.get_chat_title_by_id(metadata['chat_id']) + title = ( + await Chats.get_chat_title_by_id(metadata['chat_id']) + if not metadata['chat_id'].startswith('channel:') + else '' + ) # Use output from backend if provided (OR-compliant backends), # otherwise generate from response content @@ -3480,17 +3496,18 @@ async def non_streaming_chat_response_handler(response, ctx): # Save message in the database usage = normalize_usage(response_data.get('usage', {}) or {}) - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'done': True, - 'role': 'assistant', - 'content': content, - 'output': response_output, - **({'usage': usage} if usage else {}), - }, - ) + if not metadata['chat_id'].startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'role': 'assistant', + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), + }, + ) # Send a webhook notification if the user is not active if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): @@ -4345,7 +4362,7 @@ async def streaming_chat_response_handler(response, ctx): if end: break - if ENABLE_REALTIME_CHAT_SAVE: + if ENABLE_REALTIME_CHAT_SAVE and not metadata['chat_id'].startswith('channel:'): # Save message in the database await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], @@ -5021,7 +5038,11 @@ async def streaming_chat_response_handler(response, ctx): if item.get('status') == 'in_progress': item['status'] = 'completed' - title = await Chats.get_chat_title_by_id(metadata['chat_id']) + title = ( + await Chats.get_chat_title_by_id(metadata['chat_id']) + if not metadata['chat_id'].startswith('channel:') + else '' + ) data = { 'done': True, 'content': serialize_output(output), @@ -5030,30 +5051,31 @@ async def streaming_chat_response_handler(response, ctx): **({'usage': usage} if usage else {}), } - if not ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'done': True, - 'content': serialize_output(output), - 'output': output, - **({'usage': usage} if usage else {}), - }, - ) - elif usage: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True, 'usage': usage}, - ) - else: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True}, - ) + if not metadata['chat_id'].startswith('channel:'): + if not ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), + }, + ) + elif usage: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True, 'usage': usage}, + ) + else: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) # Send a webhook notification if the user is not active if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): @@ -5100,22 +5122,23 @@ async def streaming_chat_response_handler(response, ctx): async def save_cancelled_state(): await event_emitter({'type': 'chat:tasks:cancel'}) - if not ENABLE_REALTIME_CHAT_SAVE: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'done': True, - 'content': serialize_output(output), - 'output': output, - }, - ) - else: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True}, - ) + if not metadata['chat_id'].startswith('channel:'): + if not ENABLE_REALTIME_CHAT_SAVE: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + }, + ) + else: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) try: await asyncio.shield(save_cancelled_state()) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 56341bdca3..320124ba4d 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -71,6 +71,7 @@ from open_webui.config import ( from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_ALLOW_REDIRECTS, WEBUI_NAME, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, @@ -740,7 +741,7 @@ class OAuthClientManager: async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( authorization_url, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as resp: if resp.status < 400: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 20e8ce365c..6489443285 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -47,6 +47,7 @@ from open_webui.utils.access_control import has_access, has_connection_access from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, @@ -1433,7 +1434,7 @@ async def execute_tool_server( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: if response.status >= 400: text = await response.text() @@ -1458,7 +1459,7 @@ async def execute_tool_server( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: if response.status >= 400: text = await response.text() diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index 1e98b41105..68a56dfadc 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -3,17 +3,13 @@ import re from urllib.parse import urlparse -# Matches the OWUI-generated profile image route. ``[^/?#]+`` accepts -# any user-ID without allowing path-traversal or query/fragment injection, -# and the ``$`` anchor rejects trailing path components. +from open_webui.env import PROFILE_IMAGE_ALLOWED_MIME_TYPES + _USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$') -# Validates MIME type and structure of base64 data URIs. Only the prefix -# is checked — validating the full base64 payload would mean running a -# regex across megabytes of data on every Pydantic instantiation for zero -# security benefit (corrupt base64 simply renders a broken image, same as -# a 404 URL). SVG is intentionally excluded: it can carry embedded scripts. -_SAFE_DATA_URI_RE = re.compile(r'^data:image/(png|jpeg|gif|webp);base64,', re.IGNORECASE) +# Data-URI prefix validator derived from PROFILE_IMAGE_ALLOWED_MIME_TYPES. +_mime_suffixes = '|'.join(re.escape(t.split('/')[-1]) for t in sorted(PROFILE_IMAGE_ALLOWED_MIME_TYPES)) +_SAFE_DATA_URI_RE = re.compile(rf'^data:image/({_mime_suffixes});base64,', re.IGNORECASE) # Exact relative paths accepted as profile images. These are the only # static-asset paths OWUI itself assigns; no prefix/wildcard matching is diff --git a/package-lock.json b/package-lock.json index cbca943492..d39b8f2060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.9.4", + "version": "0.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.9.4", + "version": "0.9.5", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 08b50a26bb..04cd864710 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.9.4", + "version": "0.9.5", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 313834bfdc..6523419531 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -410,6 +410,24 @@ {/if} {/if} + + {#if permissions.features.calendar} +