NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · Open WebUI

CVE-2026-59221: open-webui terminal proxy path traversal guard bypass via 9x encoded traversal

highCVSS 3.1 7.7GHSA-frvj-c5qp-xj4w

Open WebUI's terminal proxy path guard can be bypassed with a path encoded nine times. The sanitizer decodes only eight rounds, so the leftover encoded ../ reaches the terminal server, which decodes it into a real traversal. Versions 0.9.6 up to 0.10.0 are affected. Upgrade to 0.10.0.

Published Jul 24, 2026 · Analysis Oct 25, 2026

What an attacker can do

_sanitize_proxy_path() in backend/open_webui/routers/terminals.py runs unquote() in a loop capped at eight passes, then applies posixpath.normpath() and a startswith('..') check. A path that is still once-encoded after the loop passes, since %2E%2E%2F is plain text to normpath. The proxy builds target_url as base_url/safe_path and sends it with aiohttp. The upstream server then decodes it, and /base/..%2Fadmin%2Fsystem becomes /base/../admin/system.

The reporter's script shows 8x encoding rejected and 9x accepted. The 9x payload for ../admin/system is 285 bytes, so it is practical. Through the real route, one more encoding layer is needed because the ASGI server decodes the path once first.

The attacker needs only a normal account with access to an existing admin-configured terminal connection. Open WebUI then forwards the request with the terminal's configured credentials and the X-User-Id header to paths outside the intended proxy path. For orchestrator-backed terminals, the path sits under /p/{policy_id}/, so sibling or parent routes can be hit. The data panel rates it as high confidentiality with changed scope. This is a bypass of the earlier fix for GHSA-r2wg-2mcr-66rv.

Who is affected

open-webui from 0.9.6 up to but not including 0.10.0. The reporter confirmed that terminals.py in v0.9.6 matches main. It matters where an administrator has configured terminal connections that other users can reach.

pip show open-webui

open-webui

Ecosystem
PyPI
Affected
>= 0.9.6, < 0.10.0
Fixed in
0.10.0
Check
pip show open-webui

How to fix it

Upgrade to open-webui 0.10.0:

pip install --upgrade open-webui==0.10.0

The release notes say heavily encoded request paths are now rejected behind the proxy. The report recommends decoding until stable with a strict length cap, rejecting any result that still holds encoded dot, slash or backslash separators, and rebuilding the path from fully decoded segments. The sources name no workaround; until you upgrade, remove or restrict terminal connections to trusted users.

How to tell if you were hit

Look in access logs for requests to the terminal proxy route whose path holds long runs of %25 sequences, for example %252525, or the string %2E%2E%2F after repeated encoding. On the terminal server, look for requests to paths outside the expected base, such as /admin/system, arriving with the Open WebUI service credential and an X-User-Id header. A request path that decodes to .. segments in the terminal server's own logs is the clearest sign.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

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 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 ../....

The 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.

Remediation

Do not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:

  1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators.
  2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with /.
  3. 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().

Credited to DavidCarliez, Classic298.

Questions

Why does nine rounds of encoding matter?

The sanitizer decodes at most eight times. A path encoded nine times is still encoded after the loop, so the traversal check misses it, and the terminal server decodes the last layer.

Does the attacker need to add their own terminal server?

No. The advisory says normal access to an existing configured terminal connection is enough.

Which release fixes CVE-2026-59221?

open-webui 0.10.0. Versions from 0.9.6 up to it are affected.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More Open WebUI vulnerabilities

All Open WebUI entries