Kestrel
대시보드로 돌아가기
CVE-2026-59221HIGH· 7.7MITRENVDGHSA대응게시일: 2026. 07. 09.수정일: 2026. 07. 24.

open-webui terminal proxy path traversal guard bypass via 9x encoded traversal

SSRFPath-Traversal

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.7high

이론적 심각도 점수

EPSS
0.4%상위 71.2%

30일 내 악용 확률 예측

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위변경
영향
기밀성 영향높음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.17.7HIGH
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

상세 설명

AI assistance was used to help inspect the code and prepare this report.

Summary

The fix for GHSA-r2wg-2mcr-66rv is incomplete in v0.9.6 and current main. backend/open_webui/routers/terminals.py documents _sanitize_proxy_path() as decoding until stable, but the implementation stops after 8 unquote() passes. A 9x percent-encoded ../... path parameter remains once-encoded after the loop, passes the posixpath.normpath() and cleaned.startswith('..') checks, and is forwarded to the configured terminal server. The upstream server then receives a decoded traversal path such as /base/../admin/system.

Impact

A user who has access to an admin-configured terminal connection can bypass the terminal proxy path traversal guard and cause Open WebUI to forward requests with the configured terminal credentials and X-User-Id header to paths outside the intended normalized proxy path. For orchestrator-backed terminal connections the same sanitized path is placed under /p/{policy_id}/{safe_path}, so the bypass can also target sibling or parent routes after upstream decoding. This is a bypass of the same terminal proxy boundary covered by GHSA-r2wg-2mcr-66rv.

This does not require adding a malicious terminal server or convincing an administrator to weaken settings. The attacker only needs normal access to an existing configured terminal connection.

Reproduction

The following standalone Python script mirrors the current sanitizer and uses a local aiohttp server as the terminal-server canary. It shows that 8x encoding is rejected but 9x encoding is accepted and forwarded as a traversal after the upstream framework decodes the path.

python
1import asyncio, posixpath
2from urllib.parse import unquote
3from aiohttp import web, ClientSession, ClientTimeout
4
5def sanitize(path):
6 decoded = path
7 for _ in range(8):
8 once = unquote(decoded)
9 if once == decoded:
10 break
11 decoded = once
12 cleaned = posixpath.normpath(decoded).lstrip('/')
13 if cleaned.startswith('..') or cleaned == '.':
14 return None
15 return cleaned
16
17def enc(s, rounds):
18 out = ''.join(f'%{b:02X}' for b in s.encode())
19 for _ in range(rounds - 1):
20 out = out.replace('%', '%25')
21 return out
22
23async def main():
24 async def handler(request):
25 return web.json_response({'raw_path': request.raw_path, 'path': request.path})
26 app = web.Application()
27 app.router.add_route('*', '/{tail:.*}', handler)
28 runner = web.AppRunner(app)
29 await runner.setup()
30 site = web.TCPSite(runner, '127.0.0.1', 0)
31 await site.start()
32 port = site._server.sockets[0].getsockname()[1]
33
34 for rounds in (8, 9):
35 safe = sanitize(enc('../admin/system', rounds))
36 print(rounds, safe)
37 if safe:
38 url = f'http://127.0.0.1:{port}/base/{safe}'
39 async with ClientSession(timeout=ClientTimeout(total=10)) as session:
40 async with session.get(url) as response:
41 print(await response.json())
42 await runner.cleanup()
43
44asyncio.run(main())

Observed output on current main and v0.9.6 sanitizer:

text
18 None
29 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D
3{'raw_path': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}

The 9x encoded path argument is 285 bytes long, so this is not a megabyte-sized or impractical URL. When sent through the real route, account for the ASGI server decoding the HTTP path once before filling the {path:path} parameter: an external request can use one additional encoding layer so _sanitize_proxy_path() receives the 9x encoded parameter shown above.

Root Cause / Technical Details

_sanitize_proxy_path() in backend/open_webui/routers/terminals.py performs this loop:

text
1decoded = path
2for _ in range(8):
3 once = unquote(decoded)
4 if once == decoded:
5 break
6 decoded = once

The subsequent traversal check is applied only to the value after those 8 iterations. If the input still contains encoded dot and slash bytes after the loop, posixpath.normpath() treats them as ordinary characters rather than path separators. The code then builds target_url = f'{base_url}/{safe_path}' and sends it with aiohttp.ClientSession.request(). The upstream server receives and decodes the forwarded path, turning the accepted %2E%2E%2F... into ../....

The same vulnerable sanitizer is present in v0.9.6, the latest release. I verified the v0.9.6 backend/open_webui/routers/terminals.py hash matches current main for this file.

Remediation

Do not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:

  1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators.
  2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with /.
  3. Add regression tests for at least 9x and 10x encoded ../ payloads, including a route-level test that accounts for the ASGI server's initial path decode before the {path:path} parameter reaches _sanitize_proxy_path().

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.