{"id":"CVE-2026-54017","aliases":["GHSA-r2wg-2mcr-66rv","PYSEC-2026-2751"],"url":"https://o3.security/vulnerability/CVE-2026-54017","summary":"Open WebUI: Path traversal / SSRF in terminal server proxy via encoded path traversal","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('/{server_id}/{path:path}', 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({'error': 'Invalid path'}, status_code=400)\n    target_url = f'{base_url}/{safe_path}'\n    policy_id = connection.get('policy_id')\n    if policy_id:\n        target_url = f'{base_url}/p/{policy_id}/{safe_path}'\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 — 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('..%2F..%2F') -> '../../' -> normpath -> '../..'` -> rejected).\n\n### Vector 2 — 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) -> str | None:\n    decoded = unquote(path)                 # single decode pass only\n    normalized = posixpath.normpath(decoded)\n    cleaned = normalized.lstrip('/')\n    if cleaned.startswith('..') or cleaned == '.':\n        return None\n    ...\n```\n\n`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.\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 -> ../../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('/')\nif cleaned.startswith('..') or cleaned == '.':\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** — original report (terminal-proxy path SSRF / single-encoded traversal).\n- **sermikr0** — double-encoded (`%252e%252e`) bypass of the `_sanitize_proxy_path` mitigation.","published":"2026-06-18T21:09:07.606Z","modified":"2026-08-12T03:51:44.236412109Z","cvss":{"score":7.7,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N"},"epss":{"score":0.00522,"percentile":0.4277,"asOf":"2026-09-12"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"open-webui","fixedVersion":"0.9.6"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/54xxx/CVE-2026-54017.json"},{"type":"ADVISORY","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-r2wg-2mcr-66rv"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54017"},{"type":"PACKAGE","url":"https://github.com/open-webui/open-webui"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/open-webui/PYSEC-2026-2751.yaml"},{"type":"WEB","url":"https://pypi.org/project/open-webui"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:44.236412109Z"}}