PYSEC-2026-2751

Vulnerability from pysec - Published: 2026-07-13 15:46 - Updated: 2026-07-13 16:05
VLAI
Details

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:

# 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_path mitigation.
Impacted products
Name purl
open-webui pkg:pypi/open-webui

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "open-webui",
        "purl": "pkg:pypi/open-webui"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.9.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "0.1.124",
        "0.1.125",
        "0.2.0",
        "0.2.1",
        "0.2.2",
        "0.2.3",
        "0.2.4",
        "0.2.5",
        "0.3.0",
        "0.3.1",
        "0.3.10",
        "0.3.12",
        "0.3.13",
        "0.3.14",
        "0.3.15",
        "0.3.16",
        "0.3.17",
        "0.3.17.dev2",
        "0.3.17.dev3",
        "0.3.17.dev4",
        "0.3.17.dev5",
        "0.3.18",
        "0.3.19",
        "0.3.2",
        "0.3.20",
        "0.3.21",
        "0.3.22",
        "0.3.23",
        "0.3.24",
        "0.3.25",
        "0.3.26",
        "0.3.27",
        "0.3.27.dev1",
        "0.3.27.dev2",
        "0.3.27.dev3",
        "0.3.28",
        "0.3.29",
        "0.3.3",
        "0.3.30",
        "0.3.30.dev1",
        "0.3.30.dev2",
        "0.3.31",
        "0.3.31.dev1",
        "0.3.32",
        "0.3.33",
        "0.3.33.dev1",
        "0.3.34",
        "0.3.35",
        "0.3.4",
        "0.3.5",
        "0.3.6",
        "0.3.7",
        "0.3.8",
        "0.3.9",
        "0.4.0",
        "0.4.0.dev1",
        "0.4.0.dev2",
        "0.4.1",
        "0.4.2",
        "0.4.3",
        "0.4.4",
        "0.4.5",
        "0.4.6",
        "0.4.6.dev1",
        "0.4.7",
        "0.4.8",
        "0.5.0",
        "0.5.0.dev1",
        "0.5.0.dev2",
        "0.5.1",
        "0.5.10",
        "0.5.11",
        "0.5.12",
        "0.5.13",
        "0.5.14",
        "0.5.15",
        "0.5.16",
        "0.5.17",
        "0.5.18",
        "0.5.19",
        "0.5.2",
        "0.5.20",
        "0.5.3",
        "0.5.3.dev1",
        "0.5.4",
        "0.5.5",
        "0.5.6",
        "0.5.7",
        "0.5.8",
        "0.5.9",
        "0.6.0",
        "0.6.1",
        "0.6.10",
        "0.6.11",
        "0.6.12",
        "0.6.13",
        "0.6.14",
        "0.6.15",
        "0.6.16",
        "0.6.18",
        "0.6.19",
        "0.6.2",
        "0.6.20",
        "0.6.21",
        "0.6.22",
        "0.6.23",
        "0.6.24",
        "0.6.25",
        "0.6.26",
        "0.6.26.dev1",
        "0.6.27",
        "0.6.28",
        "0.6.29",
        "0.6.3",
        "0.6.30",
        "0.6.31",
        "0.6.32",
        "0.6.33",
        "0.6.34",
        "0.6.35",
        "0.6.36",
        "0.6.37",
        "0.6.38",
        "0.6.39",
        "0.6.4",
        "0.6.40",
        "0.6.41",
        "0.6.42",
        "0.6.43",
        "0.6.5",
        "0.6.6",
        "0.6.6.dev1",
        "0.6.7",
        "0.6.8",
        "0.6.9",
        "0.7.0",
        "0.7.1",
        "0.7.2",
        "0.8.0",
        "0.8.1",
        "0.8.10",
        "0.8.11",
        "0.8.12",
        "0.8.2",
        "0.8.3",
        "0.8.4",
        "0.8.5",
        "0.8.6",
        "0.8.7",
        "0.8.8",
        "0.8.9",
        "0.9.0",
        "0.9.1",
        "0.9.2",
        "0.9.3",
        "0.9.4",
        "0.9.5"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54017",
    "GHSA-r2wg-2mcr-66rv"
  ],
  "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": "PYSEC-2026-2751",
  "modified": "2026-07-13T16:05:14.387214Z",
  "published": "2026-07-13T15:46:19.727130Z",
  "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"
    },
    {
      "type": "PACKAGE",
      "url": "https://pypi.org/project/open-webui"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-r2wg-2mcr-66rv"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54017"
    }
  ],
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…