PT-2026-50589 · Pypi · Open-Webui

Published

2026-06-17

·

Updated

2026-06-17

·

CVE-2026-54017

CVSS v3.1

7.7

High

VectorAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

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:
  1. Raw path forwarding / single-encoded traversal (original report).
  2. A bypass of the subsequently-added sanitize proxy path mitigation 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:
python
# 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:
python
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:
python
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 path mitigation.

Fix

Path traversal

SSRF

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-54017
GHSA-R2WG-2MCR-66RV

Affected Products

Open-Webui