GHSA-R2WG-2MCR-66RV
Vulnerability from github – Published: 2026-06-17 17:55 – Updated: 2026-06-17 17:55Summary
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.9.5"
},
"package": {
"ecosystem": "PyPI",
"name": "open-webui"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.9.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54017"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-17T17:55:28Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nThe 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.\n\nThis 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:\n\n1. Raw path forwarding / single-encoded traversal (original report).\n2. A bypass of the subsequently-added `_sanitize_proxy_path` mitigation using double-encoded dots (`%252e%252e`).\n\nThe 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.\n\n### Affected code\n\nThe proxy route forwards an arbitrary trailing path to the configured terminal server:\n\n```python\n# routers/terminals.py\n@router.api_route(\u0027/{server_id}/{path:path}\u0027, methods=PROXY_METHODS)\nasync def proxy_terminal(server_id, path, request, user=Depends(get_verified_user)):\n ...\n safe_path = _sanitize_proxy_path(path)\n if safe_path is None:\n return JSONResponse({\u0027error\u0027: \u0027Invalid path\u0027}, status_code=400)\n target_url = f\u0027{base_url}/{safe_path}\u0027\n policy_id = connection.get(\u0027policy_id\u0027)\n if policy_id:\n target_url = f\u0027{base_url}/p/{policy_id}/{safe_path}\u0027\n```\n\nAccess requires `has_connection_access(user, connection, ...)`, i.e. a non-admin user the administrator has granted to that terminal server.\n\n### Vector 1 \u2014 single-encoded traversal (original)\n\nThe 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:\n\n```\nGET /api/v1/terminals/server1/..%2F..%2F..%2Finternal-api/secrets\n# proxied to: {base_url}/../../../internal-api/secrets\n```\n\nThis vector is closed at HEAD: `_sanitize_proxy_path` now URL-decodes once, runs `posixpath.normpath`, strips leading slashes, and rejects results beginning with `..` (`unquote(\u0027..%2F..%2F\u0027) -\u003e \u0027../../\u0027 -\u003e normpath -\u003e \u0027../..\u0027` -\u003e rejected).\n\n### Vector 2 \u2014 double-encoded bypass of `_sanitize_proxy_path`\n\n`_sanitize_proxy_path` decodes the path only once before the `..` check, so a double-encoded payload survives:\n\n```python\ndef _sanitize_proxy_path(path: str) -\u003e str | None:\n decoded = unquote(path) # single decode pass only\n normalized = posixpath.normpath(decoded)\n cleaned = normalized.lstrip(\u0027/\u0027)\n if cleaned.startswith(\u0027..\u0027) or cleaned == \u0027.\u0027:\n return None\n ...\n```\n\n`unquote(\u0027%252e%252e/secret\u0027)` 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.\n\n```\nGET /api/v1/terminals/server1/%252e%252e/%252e%252e/sensitive-file\n# passes _sanitize_proxy_path as %2e%2e/%2e%2e/sensitive-file\n# upstream decodes -\u003e ../../sensitive-file\n```\n\nThe `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.\n\n### Impact\n\nAn 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).\n\n### Fix\n\nDecode 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:\n\n```python\ndecoded = path\nfor _ in range(8):\n once = unquote(decoded)\n if once == decoded:\n break\n decoded = once\nnormalized = posixpath.normpath(decoded)\ncleaned = normalized.lstrip(\u0027/\u0027)\nif cleaned.startswith(\u0027..\u0027) or cleaned == \u0027.\u0027:\n return None\n```\n\nThis rejects `%2e%2e`, `%252e%252e`, `%25252e%25252e`, `..%2f..%2f`, etc., while leaving legitimate paths (including singly-encoded characters such as `%20`) intact.\n\n### Credits\n\n- **Tulgaaaaaaaa** \u2014 original report (terminal-proxy path SSRF / single-encoded traversal).\n- **sermikr0** \u2014 double-encoded (`%252e%252e`) bypass of the `_sanitize_proxy_path` mitigation.",
"id": "GHSA-r2wg-2mcr-66rv",
"modified": "2026-06-17T17:55:28Z",
"published": "2026-06-17T17:55:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/open-webui/open-webui/security/advisories/GHSA-r2wg-2mcr-66rv"
},
{
"type": "PACKAGE",
"url": "https://github.com/open-webui/open-webui"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Open WebUI: Path traversal / SSRF in terminal server proxy via encoded path traversal"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.