Skip to content

Commit 3660bc0

Browse files
authored
Merge pull request open-webui#24492 from open-webui/dev
0.9.5
2 parents f51d2b0 + 41b48b5 commit 3660bc0

109 files changed

Lines changed: 1058 additions & 355 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/pull_request_template.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
1818

1919
**Before submitting, make sure you've checked the following:**
2020

21+
- [ ] **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.
2122
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
2223
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
2324
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.5] - 2026-05-09
9+
10+
### Added
11+
12+
- 🛡️ **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)
13+
- 🛡️ **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)
14+
- 🎛️ **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)
15+
- 🔧 **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)
16+
- 🔌 **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.
17+
18+
### Fixed
19+
20+
- 📝 **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)
21+
- 🔐 **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)
22+
- 🔐 **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)
23+
- 🔐 **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)
24+
- 🛡️ **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)
25+
- 🛡️ **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)
26+
- 🔐 **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)
27+
- 🔐 **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)
28+
- 🔐 **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)
29+
- 🛡️ **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)
30+
- 🔐 **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)
31+
- 🛡️ **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)
32+
- 🛡️ **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)
33+
- 🔐 **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)
34+
- 🔐 **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)
35+
- 🧵 **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)
36+
- 🎛️ **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)
37+
- 🌐 **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)
38+
39+
### Changed
40+
41+
- 🧹 **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)
42+
- 📋 **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.
43+
844
## [0.9.4] - 2026-05-09
945

1046
### Fixed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
4343
RUN npm run build
4444

4545
######## WebUI backend ########
46-
FROM python:3.11.14-slim-bookworm AS base
46+
FROM python:3.11-slim-bookworm AS base
4747

4848
# Use args
4949
ARG USE_CUDA

backend/open_webui/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,11 @@ def reachable(host: str, port: int) -> bool:
12261226
terminal_server_connections,
12271227
)
12281228

1229+
try:
1230+
TERMINAL_PROXY_HEADERS = json.loads(os.environ.get('TERMINAL_PROXY_HEADERS', '{}'))
1231+
except Exception:
1232+
TERMINAL_PROXY_HEADERS = {}
1233+
12291234
####################################
12301235
# WEBUI
12311236
####################################
@@ -1371,6 +1376,7 @@ def reachable(host: str, port: int) -> bool:
13711376
os.environ.get('RESPONSE_WATERMARK', ''),
13721377
)
13731378

1379+
IFRAME_CSP = os.environ.get('IFRAME_CSP', '')
13741380

13751381
USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = (
13761382
os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS', 'False').lower() == 'true'
@@ -1465,6 +1471,10 @@ def reachable(host: str, port: int) -> bool:
14651471
os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
14661472
)
14671473

1474+
USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
1475+
os.environ.get('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
1476+
)
1477+
14681478
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
14691479
os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
14701480
)
@@ -1585,6 +1595,7 @@ def reachable(host: str, port: int) -> bool:
15851595
'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING,
15861596
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
15871597
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
1598+
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
15881599
},
15891600
'access_grants': {
15901601
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,

backend/open_webui/env.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,15 @@ def parse_section(section):
260260
# controlled origins) and fall through to the default image instead.
261261
ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get('ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True').lower() == 'true'
262262

263+
PROFILE_IMAGE_ALLOWED_MIME_TYPES = frozenset(
264+
t.strip()
265+
for t in os.environ.get(
266+
'PROFILE_IMAGE_ALLOWED_MIME_TYPES',
267+
'image/png,image/jpeg,image/gif,image/webp',
268+
).split(',')
269+
if t.strip()
270+
)
271+
263272
####################################
264273
# WEBUI_BUILD_HASH
265274
####################################
@@ -824,6 +833,13 @@ def parse_section(section):
824833

825834
AIOHTTP_CLIENT_SESSION_SSL = os.environ.get('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true'
826835

836+
# When False (default), outbound HTTP requests do not follow 3xx redirects.
837+
# This prevents redirect-based SSRF where a public URL 302-redirects to an
838+
# internal address (RFC 1918, loopback, cloud-metadata 169.254.169.254).
839+
# Set to True only if your deployment requires redirect following and you
840+
# have other SSRF protections in place (e.g. egress firewall).
841+
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.environ.get('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
842+
827843
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
828844
'AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST',
829845
os.environ.get('AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST', '10'),

backend/open_webui/main.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@
460460
OAUTH_PROVIDERS,
461461
WEBUI_URL,
462462
RESPONSE_WATERMARK,
463+
IFRAME_CSP,
463464
# Admin
464465
ENABLE_ADMIN_CHAT_ACCESS,
465466
ENABLE_ADMIN_ANALYTICS,
@@ -1795,7 +1796,9 @@ async def chat_completion(
17951796

17961797
if metadata.get('chat_id') and user:
17971798
chat_id = metadata['chat_id']
1798-
if not chat_id.startswith('local:'): # temporary chats are not stored
1799+
if not chat_id.startswith('local:') and not chat_id.startswith(
1800+
'channel:'
1801+
): # temporary/channel chats are not stored
17991802
if is_new_chat:
18001803
# Build the full history upfront with ALL assistant placeholders
18011804
user_message = metadata.get('user_message') or {}
@@ -2011,7 +2014,7 @@ async def emit_cancel_event():
20112014
if metadata.get('chat_id') and metadata.get('message_id'):
20122015
# Update the chat message with the error
20132016
try:
2014-
if not metadata['chat_id'].startswith('local:'):
2017+
if not metadata['chat_id'].startswith('local:') and not metadata['chat_id'].startswith('channel:'):
20152018
await Chats.upsert_message_to_chat_by_id_and_message_id(
20162019
metadata['chat_id'],
20172020
metadata['message_id'],
@@ -2274,7 +2277,7 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
22742277

22752278
@app.get('/api/tasks/chat/{chat_id:path}')
22762279
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
2277-
if chat_id.startswith('local:'):
2280+
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
22782281
socket_id = chat_id[len('local:') :]
22792282
owner_id = get_user_id_from_session_pool(socket_id)
22802283
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
22922295

22932296
@app.post('/api/tasks/chat/{chat_id:path}/stop')
22942297
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
2295-
if chat_id.startswith('local:'):
2298+
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
22962299
socket_id = chat_id[len('local:') :]
22972300
owner_id = get_user_id_from_session_pool(socket_id)
22982301
if owner_id != user.id and user.role != 'admin':
@@ -2444,6 +2447,7 @@ async def get_app_config(request: Request):
24442447
'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE,
24452448
'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT,
24462449
'response_watermark': app.state.config.RESPONSE_WATERMARK,
2450+
'iframe_csp': IFRAME_CSP,
24472451
},
24482452
'license_metadata': app.state.LICENSE_METADATA,
24492453
**(

backend/open_webui/models/chats.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -459,24 +459,87 @@ async def get_chat_title_by_id(self, id: str) -> Optional[str]:
459459
return None
460460
return row[0] or 'New Chat'
461461

462+
@staticmethod
463+
def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
464+
"""Return parent IDs referenced by messages but absent from the map.
465+
466+
An empty set means the message graph is fully connected.
467+
"""
468+
return {
469+
msg['parentId']
470+
for msg in messages_map.values()
471+
if msg.get('parentId') and msg['parentId'] not in messages_map
472+
}
473+
474+
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
475+
"""Write messages to the ``chat_message`` table so future lookups
476+
use the fast path. Errors are logged but never raised.
477+
"""
478+
for message_id, message in messages.items():
479+
if not isinstance(message, dict) or not message.get('role'):
480+
continue
481+
try:
482+
await ChatMessages.upsert_message(
483+
message_id=message_id,
484+
chat_id=chat_id,
485+
user_id=user_id,
486+
data=message,
487+
)
488+
except Exception as e:
489+
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
490+
462491
async def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
463492
"""Message map for walking history (see ``get_message_list``).
464493
465-
Prefer ``chat_message`` rows to avoid loading the large ``chat``
466-
JSON blob; fall back to embedded history when no rows exist
467-
(legacy chats).
494+
Prefer ``chat_message`` rows to avoid loading the large embedded
495+
history; fall back to the legacy JSON when no rows exist.
496+
When rows exist but the parent-link graph has gaps (e.g. migration
497+
failures), missing messages are merged from the legacy history
498+
and backfilled so future requests self-heal.
468499
"""
469500
# Fast path: build from normalized chat_message rows.
470501
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
502+
471503
if messages_map is not None:
504+
unresolved_ids = self.get_unresolved_parent_ids(messages_map)
505+
if not unresolved_ids:
506+
return messages_map
507+
508+
# Graph has gaps — enrich from the legacy embedded history.
509+
log.info(
510+
'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history',
511+
id,
512+
len(unresolved_ids),
513+
)
514+
chat = await self.get_chat_by_id(id)
515+
if chat:
516+
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
517+
missing_messages = {
518+
message_id: history_messages[message_id]
519+
for message_id in unresolved_ids
520+
if message_id in history_messages
521+
}
522+
523+
if missing_messages:
524+
messages_map.update(missing_messages)
525+
526+
# Backfill so future requests use the fast path.
527+
await self.backfill_messages_by_chat_id(id, chat.user_id, missing_messages)
528+
472529
return messages_map
473530

474-
# No rows — fall back to the embedded JSON blob for legacy chats.
531+
# No rows — fall back to the legacy embedded history.
475532
chat = await self.get_chat_by_id(id)
476533
if chat is None:
477534
return None
478535

479-
return chat.chat.get('history', {}).get('messages', {}) or {}
536+
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
537+
538+
# Backfill so future requests use the fast path.
539+
if history_messages:
540+
await self.backfill_messages_by_chat_id(id, chat.user_id, history_messages)
541+
542+
return history_messages
480543

481544
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]:
482545
chat = await self.get_chat_by_id(id)

0 commit comments

Comments
 (0)