GitHub Security Advisory: GHSA-45m8-cpm2-3v65
Package: open-webui (PIP)
Severity: HIGH (CVSS 8.1)
CWE: CWE-384: Session Fixation, CWE-863: Incorrect Authorization
Vulnerable versions: <= 0.8.12
Patched in: 0.9.0

Summary: Open WebUI: Stale Admin Role in Socket.IO Session Pool Enables Post-Demotion Cross-User Note Access

Details:
# Stale Admin Role in Socket.IO Session Pool Enables Post-Demotion Cross-User Note Access

## Affected Component

Socket.IO session state and role-check callsites:
- `backend/open_webui/socket/main.py` (lines 330-351, `connect` handler — role snapshotted into SESSION_POOL)
- `backend/open_webui/socket/main.py` (lines 393-398, `heartbeat` handler — does not refresh role)
- `backend/open_webui/socket/main.py` (line 538, `ydoc:document:join` — uses cached role for admin check)
- `backend/open_webui/socket/main.py` (line 611, `document_save_handler` — uses cached role for admin check)
- `backend/open_webui/routers/users.py` (lines 557-633, role update — does not invalidate SESSION_POOL)
- `backend/open_webui/routers/users.py` (line 641, user delete — does not invalidate SESSION_POOL)

## Affected Versions

Current main branch (commit `6fdd19bf1`) and likely all versions with the collaborative document (Yjs) Socket.IO handlers.

## Description

When a user connects via Socket.IO, the `connect` handler authenticates them via JWT and stores their user record (including `role`) in the in-memory `SESSION_POOL` dictionary keyed by session ID. The `heartbeat` handler keeps the session alive indefinitely but only refreshes the `last_seen_at` timestamp — never the role.

Role checks in the Yjs collaborative document handlers (`ydoc:document:join`, `document_save_handler`) consult the cached `SESSION_POOL` role rather than the database. Meanwhile, administrative role changes and user deletions do not iterate `SESSION_POOL` to disconnect affected sessions. As a result, a user whose admin role has been revoked retains admin privileges within their existing Socket.IO session for as long as they keep the connection alive (via automatic heartbeats).

HTTP endpoints are not affected — `get_current_user` at [utils/auth.py](backend/open_webui/utils/auth.py) refetches the user record from the database on every request. The gap is exclusive to the Socket.IO session cache.

```python
# socket/main.py:330-351 — role snapshotted at connect time
async def connect(sid, environ, auth):
    user = None
    if auth and 'token' in auth:
        data = decode_token(auth['token'])
        if data is not None and 'id' in data:
            user = Users.get_user_by_id(data['id'])
        if user:
            SESSION_POOL[sid] = {
                'id': user.id,
                'role': user.role,   # ← snapshotted, never refreshed
                ...
            }

# socket/main.py:393-398 — heartbeat refreshes last_seen_at only
async def heartbeat(sid, data):
    user = SESSION_POOL.get(sid)
    if user:
        SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())}
        # role is carried forward unchanged

# socket/main.py:538 — admin check against cached role
if user.get('role') != 'admin' and not has_access(user_id, 'note', note_id, 'read', db=db):
    return
```

## Attack Scenario

1. User B is an admin and has an active browser session with a live Socket.IO connection