Open WebUI: Path traversal / SSRF in terminal server proxy via encoded path traversal
Description
Summary
The terminal-server reverse proxy in backend/open_webui/routers/terminals.py does not fully confine the user-controlled path segment before forwarding it to an admin-configured terminal server. An authenticated user who has been granted access to a terminal server can craft path values containing encoded ../ traversal sequences that escape the intended path (or policy) scope on that server, reaching unintended endpoints and files on the terminal-server host. Where the terminal server fans requests out to internal services, this also gives SSRF-style reach into those services.
This is a separate code path from the /api/v1/retrieval/process/web SSRF (GHSA-c6xv-rcvw-v685), with its own input. Two distinct vectors are consolidated here:
- Raw path forwarding / single-encoded traversal (original report).
- A bypass of the subsequently-added
_sanitize_proxy_pathmitigation using double-encoded dots (%252e%252e).
The attacker-controlled input is the request path, supplied by the non-admin user, not anything an administrator configures, so this is not an admin-trust / Rule-9 situation.
Affected code
The proxy route forwards an arbitrary trailing path to the configured terminal server:
# routers/terminals.py
@router.api_route('/{server_id}/{path:path}', methods=PROXY_METHODS)
async def proxy_terminal(server_id, path, request, user=Depends(get_verified_user)):
...
safe_path = _sanitize_proxy_path(path)
if safe_path is None:
return JSONResponse({'error': 'Invalid path'}, status_code=400)
target_url = f'{base_url}/{safe_path}'
policy_id = connection.get('policy_id')
if policy_id:
target_url = f'{base_url}/p/{policy_id}/{safe_path}'
Access requires has_connection_access(user, connection, ...), i.e. a non-admin user the administrator has granted to that terminal server.
Vector 1 — single-encoded traversal (original)
The path was originally concatenated to the base URL with no sanitization (target_url = f"{base_url}/{path}"), so single-encoded traversal escaped the intended scope:
GET /api/v1/terminals/server1/..%2F..%2F..%2Finternal-api/secrets
# proxied to: {base_url}/../../../internal-api/secrets
This vector is closed at HEAD: _sanitize_proxy_path now URL-decodes once, runs posixpath.normpath, strips leading slashes, and rejects results beginning with .. (unquote('..%2F..%2F') -> '../../' -> normpath -> '../..' -> rejected).
Vector 2 — double-encoded bypass of _sanitize_proxy_path
_sanitize_proxy_path decodes the path only once before the .. check, so a double-encoded payload survives:
def _sanitize_proxy_path(path: str) -> str | None:
decoded = unquote(path) # single decode pass only
normalized = posixpath.normpath(decoded)
cleaned = normalized.lstrip('/')
if cleaned.startswith('..') or cleaned == '.':
return None
...
unquote('%252e%252e/secret') yields %2e%2e/secret (not ..), which normpath leaves unchanged and which does not start with .., so it passes the check. The proxy then forwards {base_url}/%2e%2e/secret, and the upstream terminal server decodes %2e%2e into .. and resolves the traversal the check was meant to prevent.
GET /api/v1/terminals/server1/%252e%252e/%252e%252e/sensitive-file
# passes _sanitize_proxy_path as %2e%2e/%2e%2e/sensitive-file
# upstream decodes -> ../../sensitive-file
The policy_id form ({base_url}/p/{policy_id}/{safe_path}) is the higher-impact target: traversal escapes the policy namespace and reaches other policies or the terminal-server root.
Impact
An authenticated user with access to a terminal server can escape the intended path/policy scope on that server, reaching unintended endpoints and files, and, where the terminal server routes onward to internal services, reach those services. CWE-22 (Path Traversal) and CWE-918 (SSRF).
Fix
Decode the proxy path until it is stable before normalising and checking, so no depth of encoding can smuggle a traversal sequence past the check to be re-decoded upstream:
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
normalized = posixpath.normpath(decoded)
cleaned = normalized.lstrip('/')
if cleaned.startswith('..') or cleaned == '.':
return None
This rejects %2e%2e, %252e%252e, %25252e%25252e, ..%2f..%2f, etc., while leaving legitimate paths (including singly-encoded characters such as %20) intact.
Credits
- Tulgaaaaaaaa — original report (terminal-proxy path SSRF / single-encoded traversal).
- sermikr0 — double-encoded (
%252e%252e) bypass of the_sanitize_proxy_pathmitigation.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Affected products
2- Range: <= 0.9.5
Patches
Vulnerability mechanics
Root cause
"The `_sanitize_proxy_path` function in `backend/open_webui/routers/terminals.py` performs only a single URL-decode pass before checking for `..` sequences, allowing double-encoded traversal payloads (`%252e%252e`) to pass the check and be re-decoded by the upstream terminal server."
Attack vector
An authenticated user who has been granted access to a terminal server (via `has_connection_access`) sends a crafted HTTP request to the proxy route `/{server_id}/{path:path}`. In the original vector, a single-encoded path like `..%2F..%2F..%2Finternal-api/secrets` is forwarded verbatim to the terminal server, escaping the intended scope [ref_id=1]. In the bypass vector, the attacker uses double-encoded dots (`%252e%252e`) which survive the single-decode pass in `_sanitize_proxy_path`; the upstream terminal server then decodes `%2e%2e` into `..` and resolves the traversal [ref_id=1]. When a `policy_id` is configured, the traversal escapes the policy namespace (`/p/{policy_id}/`) and can reach other policies or the terminal-server root [ref_id=1].
Affected code
The vulnerable code is in `backend/open_webui/routers/terminals.py` in the `proxy_terminal` route handler and the `_sanitize_proxy_path` function [ref_id=1]. The route captures a user-controlled `path` segment via `/{server_id}/{path:path}` and forwards it to an admin-configured terminal server. The original code had no sanitization; the subsequently-added `_sanitize_proxy_path` performs only a single `unquote` pass before its `..` check, which double-encoded payloads bypass [ref_id=1].
What the fix does
The fix replaces the single `unquote(path)` call with a loop that repeatedly decodes the path until it is stable (no further changes) or up to 8 iterations [ref_id=1]. After full decoding, `posixpath.normpath` and the `..` prefix check are applied as before. This catches any depth of encoding — `%2e%2e`, `%252e%252e`, `%25252e%25252e`, etc. — because all layers are peeled away before the traversal check runs [ref_id=1]. Legitimate paths containing singly-encoded characters such as `%20` remain intact [ref_id=1].
Preconditions
- authAttacker must be an authenticated user of the Open WebUI instance.
- configAn administrator must have granted the attacker access to a terminal server connection.
- networkAttacker must be able to send HTTP requests to the Open WebUI API.
- inputAttacker supplies a crafted `path` segment containing encoded `../` traversal sequences (single-encoded `%2F` or double-encoded `%252e%252e`).
Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.