Coverage for open_webui/routers/terminals.py: 12%

263 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 05:07 +0000

1"""Reverse proxy for admin-configured terminal servers. 

2 

3Routes: 

4 GET / — list terminals the user has access to 

5 * /{server_id}/{path:path} — proxy request to terminal server 

6""" 

7 

8import asyncio 

9import logging 

10import posixpath 

11from urllib.parse import unquote 

12 

13import aiohttp 

14from fastapi import APIRouter, Depends, Request, Response, WebSocket 

15from fastapi.responses import JSONResponse, StreamingResponse 

16from open_webui.config import TERMINAL_PROXY_HEADERS 

17from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL 

18from open_webui.events import EVENTS, publish_event 

19from open_webui.models.config import Config 

20from open_webui.models.groups import Groups 

21from open_webui.utils.access_control import has_connection_access 

22from open_webui.utils.auth import get_verified_user, get_verified_user_by_token 

23from open_webui.utils.headers import bearer_auth_header, normalize_bearer_token 

24from open_webui.utils.json_codec import JSONCodec 

25from open_webui.utils.terminals import ( 

26 TERMINAL_CONTEXT_HEADER, 

27 get_terminal_server_url, 

28 is_terminal_orchestrator, 

29 terminal_chat_uploads, 

30 terminal_context_available, 

31 terminal_context_config, 

32 terminal_context_id, 

33 terminal_contexts, 

34) 

35from starlette.background import BackgroundTask 

36from starlette.requests import ClientDisconnect 

37from yarl import URL 

38 

39log = logging.getLogger(__name__) 

40 

41router = APIRouter() 

42 

43STREAMING_CONTENT_TYPES = ('application/octet-stream', 'image/', 'application/pdf') 

44ADMIN_API_PATHS = ('api/v1/policies', 'api/v1/status', 'api/v1/terminals') 

45# Drop the upstream's server and date: uvicorn adds its own and forwarding both duplicates them. 

46STRIPPED_RESPONSE_HEADERS = frozenset( 

47 ('transfer-encoding', 'connection', 'content-encoding', 'content-length', 'server', 'date') 

48) 

49 

50 

51def _sanitize_proxy_path(path: str) -> str | None: 

52 """Sanitize a proxy path to prevent directory traversal / SSRF. 

53 

54 Returns the cleaned path, or None if the path is invalid. 

55 Trailing slashes are preserved — many upstream frameworks treat 

56 ``/path`` and ``/path/`` differently. 

57 """ 

58 # Decode until stable: a single unquote pass leaves %252e%252e as %2e%2e, 

59 # which the upstream then re-decodes into '..', bypassing the check below. 

60 decoded = path 

61 for _ in range(8): 

62 once = unquote(decoded) 

63 if once == decoded: 

64 break 

65 decoded = once 

66 # Fail closed: still encoded after the cap means the upstream would decode further into traversal. 

67 if unquote(decoded) != decoded: 

68 return None 

69 # posixpath splits on '/' only, so 'a/..\..\b' survives normpath as one component. 

70 # Upstreams that treat '\' as a separator would resolve it, so reject outright. 

71 # URL parsers also remove tabs/newlines, which can turn '.\t.' into '..'. 

72 if any(char in decoded for char in '\\\t\r\n'): 

73 return None 

74 had_trailing_slash = decoded.endswith('/') 

75 normalized = posixpath.normpath(decoded) 

76 # Remove any leading slashes that would reset the base 

77 cleaned = normalized.lstrip('/') 

78 # Reject if normpath resolved to parent traversal or current-dir only 

79 if cleaned.startswith('..') or cleaned == '.': 

80 return None 

81 # Restore trailing slash if the original path had one 

82 if had_trailing_slash and cleaned and not cleaned.endswith('/'): 

83 cleaned += '/' 

84 return cleaned 

85 

86 

87@router.get('/') 

88async def list_terminal_servers(request: Request, user=Depends(get_verified_user)): 

89 """Return terminal servers the authenticated user has access to.""" 

90 connections = await Config.get('terminal_server.connections', []) or [] 

91 user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} 

92 

93 return [ 

94 { 

95 'id': connection.get('id', ''), 

96 'url': connection.get('url', ''), 

97 'name': connection.get('name', ''), 

98 'contexts': terminal_contexts(connection), 

99 'config': {'chat_uploads': terminal_chat_uploads(connection)}, 

100 } 

101 for connection in connections 

102 if connection.get('enabled', True) and await has_connection_access(user, connection, user_group_ids) 

103 ] 

104 

105 

106PROXY_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] 

107 

108 

109@router.api_route('/{server_id}/{path:path}', methods=PROXY_METHODS) 

110async def proxy_terminal( 

111 server_id: str, 

112 path: str, 

113 request: Request, 

114 user=Depends(get_verified_user), 

115): 

116 """Proxy a request to the admin terminal server identified by *server_id*.""" 

117 connections = await Config.get('terminal_server.connections', []) or [] 

118 connection = next((c for c in connections if c.get('id') == server_id), None) 

119 

120 if connection is None: 120 ↛ 123line 120 didn't jump to line 123 because the condition on line 120 was always true

121 return JSONResponse({'error': f"Terminal server '{server_id}' not found"}, status_code=404) 

122 

123 if not connection.get('enabled', True): 

124 return JSONResponse({'error': 'Terminal server disabled'}, status_code=403) 

125 

126 user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} 

127 if not await has_connection_access(user, connection, user_group_ids): 

128 return JSONResponse({'error': 'Access denied'}, status_code=403) 

129 

130 base_url = get_terminal_server_url(connection) 

131 if not base_url: 

132 return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503) 

133 

134 safe_path = _sanitize_proxy_path(path) 

135 if safe_path is None: 

136 return JSONResponse({'error': 'Invalid path'}, status_code=400) 

137 

138 target_url = f'{base_url}/{safe_path}' 

139 

140 # Check the path aiohttp will send, relative to the configured server root. 

141 base_path = URL(str(connection.get('url') or '')).path.rstrip('/') 

142 target_path = URL(target_url).path 

143 if any( 

144 target_path == f'{base_path}/{prefix}' or target_path.startswith(f'{base_path}/{prefix}/') 

145 for prefix in ADMIN_API_PATHS 

146 ): 

147 return JSONResponse({'error': 'Path not allowed'}, status_code=403) 

148 

149 if request.query_params: 

150 target_url += f'?{request.query_params}' 

151 

152 headers = {'X-User-Id': user.id} 

153 # Forward per-session cwd tracking header 

154 session_id = request.headers.get('x-session-id') 

155 if session_id: 

156 headers['X-Session-Id'] = session_id 

157 if not terminal_context_available(connection, 'chat'): 

158 return JSONResponse({'error': 'Terminal server is not available in chats'}, status_code=403) 

159 context_id = terminal_context_id(connection, {'chat_id': session_id}, 'chat') 

160 if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id: 

161 return JSONResponse({'error': 'A saved chat is required for this terminal'}, status_code=409) 

162 if context_id: 

163 headers[TERMINAL_CONTEXT_HEADER] = context_id 

164 cookies = getattr(request, 'cookies', {}) if connection.get('forward_cookies', False) else {} 

165 auth_type = connection.get('auth_type', 'bearer') 

166 

167 if auth_type == 'bearer': 

168 headers.update(bearer_auth_header(connection.get('key', ''))) 

169 elif auth_type == 'session': 

170 headers.update(bearer_auth_header(request.state.token.credentials)) 

171 elif auth_type == 'system_oauth': 

172 # Resolve the token server-side from the caller's OAuth session; never trust a client header. 

173 oauth_token = None 

174 try: 

175 if request.cookies.get('oauth_session_id', None): 

176 oauth_token = await request.app.state.oauth_manager.get_oauth_token( 

177 user.id, 

178 request.cookies.get('oauth_session_id', None), 

179 ) 

180 except Exception as e: 

181 log.error(f'Error getting OAuth token: {e}') 

182 if oauth_token: 

183 headers.update(bearer_auth_header(oauth_token.get('access_token', ''))) 

184 # auth_type == "none": no Authorization header 

185 

186 content_type = request.headers.get('content-type') 

187 if content_type: 

188 headers['Content-Type'] = content_type 

189 

190 session = aiohttp.ClientSession( 

191 timeout=aiohttp.ClientTimeout(total=300, connect=10), 

192 trust_env=True, 

193 ) 

194 

195 try: 

196 body = await request.body() 

197 

198 upstream_response = await session.request( 

199 method=request.method, 

200 url=target_url, 

201 headers=headers, 

202 cookies=cookies, 

203 data=body or None, 

204 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

205 allow_redirects=False, 

206 ) 

207 

208 upstream_content_type = upstream_response.headers.get('content-type', '') 

209 filtered_headers = { 

210 key: value 

211 for key, value in upstream_response.headers.items() 

212 if key.lower() not in STRIPPED_RESPONSE_HEADERS 

213 } 

214 if TERMINAL_PROXY_HEADERS: 

215 filtered_headers.update(TERMINAL_PROXY_HEADERS) 

216 

217 # Stream binary responses directly 

218 if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES): 

219 

220 async def cleanup(): 

221 await upstream_response.release() 

222 await session.close() 

223 

224 return StreamingResponse( 

225 content=upstream_response.content.iter_any(), 

226 status_code=upstream_response.status, 

227 headers=filtered_headers, 

228 background=BackgroundTask(cleanup), 

229 ) 

230 

231 # Buffer text/JSON responses 

232 response_body = await upstream_response.read() 

233 status_code = upstream_response.status 

234 await upstream_response.release() 

235 await session.close() 

236 

237 return Response(content=response_body, status_code=status_code, headers=filtered_headers) 

238 

239 except ClientDisconnect: 

240 await session.close() 

241 return Response(status_code=499) 

242 except (aiohttp.ClientConnectionError, TimeoutError) as error: 

243 await session.close() 

244 log.error('Terminal proxy error: %s', str(error) or type(error).__name__) 

245 return JSONResponse({'error': f'Terminal proxy error: {error}'}, status_code=502) 

246 except Exception as error: 

247 await session.close() 

248 log.exception('Terminal proxy error: %s', error) 

249 return JSONResponse({'error': f'Terminal proxy error: {error}'}, status_code=502) 

250 

251 

252# --------------------------------------------------------------------------- 

253# WebSocket proxy for interactive terminal sessions 

254# --------------------------------------------------------------------------- 

255 

256 

257async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): 

258 """Authenticate a WebSocket via first-message auth and resolve the terminal server. 

259 

260 The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first 

261 message after connecting. 

262 

263 Returns ``(user, connection, chat_id, token)`` on success, or ``None`` after 

264 closing *ws* with an appropriate error code. 

265 """ 

266 # First-message authentication 

267 try: 

268 raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) 

269 payload = JSONCodec.loads(raw) 

270 if payload.get('type') != 'auth': 

271 await ws.close(code=4001, reason='Expected auth message') 

272 return None 

273 token = payload.get('token', '') 

274 except (TimeoutError, JSONCodec.JSONDecodeError): 

275 await ws.close(code=4001, reason='Auth timeout or invalid payload') 

276 return None 

277 except Exception: 

278 await ws.close(code=4001, reason='Invalid token') 

279 return None 

280 

281 result = await _resolve_terminal_access(ws, server_id, token) 

282 if result is None: 

283 return None 

284 user, connection = result 

285 chat_id = payload.get('chat_id', '') 

286 return user, connection, chat_id if isinstance(chat_id, str) else '', token 

287 

288 

289async def _resolve_terminal_access(ws: WebSocket, server_id: str, token: str): 

290 """Resolve current access for both the handshake and an open terminal session.""" 

291 try: 

292 user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None)) 

293 if user is None: 

294 await ws.close(code=4001, reason='Invalid token') 

295 return None 

296 except Exception: 

297 await ws.close(code=4001, reason='Invalid token') 

298 return None 

299 

300 # Resolve terminal server 

301 connections = await Config.get('terminal_server.connections', []) or [] 

302 connection = next((c for c in connections if c.get('id') == server_id), None) 

303 

304 if connection is None: 

305 await ws.close(code=4004, reason='Terminal server not found') 

306 return None 

307 

308 if not connection.get('enabled', True): 

309 await ws.close(code=4003, reason='Terminal server disabled') 

310 return None 

311 

312 if not await has_connection_access(user, connection): 

313 await ws.close(code=4003, reason='Access denied') 

314 return None 

315 

316 if not terminal_context_available(connection, 'chat'): 

317 await ws.close(code=4003, reason='Terminal server is not available in chats') 

318 return None 

319 return user, connection 

320 

321 

322@router.websocket('/{server_id}/api/terminals/{session_id}') 

323async def ws_terminal( 

324 ws: WebSocket, 

325 server_id: str, 

326 session_id: str, 

327): 

328 """Proxy an interactive WebSocket terminal session to a terminal server. 

329 

330 Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}`` 

331 as its first message. The proxy validates the JWT, then connects to the 

332 upstream terminal server using the configured terminal auth mode. 

333 """ 

334 await ws.accept() 

335 

336 result = await _resolve_authenticated_connection(ws, server_id) 

337 if result is None: 

338 return 

339 user, connection, chat_id, token = result 

340 

341 base_url = get_terminal_server_url(connection) 

342 if not base_url: 

343 await ws.close(code=4003, reason='Terminal server URL not configured') 

344 return 

345 

346 # Build upstream WebSocket URL (no token in URL) 

347 ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://') 

348 

349 upstream_params = {} 

350 # For orchestrator-backed servers, pass user_id 

351 upstream_params['user_id'] = user.id 

352 context_id = terminal_context_id(connection, {'chat_id': chat_id}, 'chat') 

353 upstream_headers = {'X-User-Id': user.id, 'X-Session-Id': chat_id} 

354 if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id: 

355 await ws.close(code=4003, reason='A saved chat is required for this terminal') 

356 return 

357 if context_id: 

358 upstream_headers[TERMINAL_CONTEXT_HEADER] = context_id 

359 

360 import urllib.parse 

361 

362 # Encode session_id as an opaque path segment so it cannot smuggle '?'/'#'/'&' (at any 

363 # decode depth) and inject an attacker-chosen user_id ahead of the one appended below. 

364 safe_session_id = urllib.parse.quote(session_id, safe='') 

365 

366 upstream_url = f'{ws_base}/api/terminals/{safe_session_id}' 

367 if upstream_params: 

368 upstream_url += f'?{urllib.parse.urlencode(upstream_params)}' 

369 

370 app = ws.scope.get('app') 

371 opened = False 

372 session = aiohttp.ClientSession() 

373 try: 

374 async with session.ws_connect( 

375 upstream_url, 

376 headers=upstream_headers, 

377 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

378 ) as upstream: 

379 import json as _json 

380 

381 # First-message auth to upstream terminal server 

382 auth_type = connection.get('auth_type', 'bearer') 

383 if auth_type == 'bearer': 

384 key = normalize_bearer_token(connection.get('key', '')) 

385 await upstream.send_str(_json.dumps({'type': 'auth', 'token': key})) 

386 elif auth_type == 'session' and is_terminal_orchestrator(connection): 

387 await upstream.send_str(_json.dumps({'type': 'auth', 'token': token})) 

388 else: 

389 await upstream.send_str(_json.dumps({'type': 'auth', 'token': ''})) 

390 

391 await publish_event( 

392 app, 

393 EVENTS.TERMINAL_SESSION_OPENED, 

394 actor=user, 

395 subject_id=session_id, 

396 subject_type='terminal.session', 

397 data={'server_id': server_id}, 

398 ) 

399 opened = True 

400 

401 async def _client_to_upstream(): 

402 """Forward client → upstream.""" 

403 try: 

404 while True: 

405 msg = await ws.receive() 

406 if msg['type'] == 'websocket.disconnect': 

407 break 

408 elif 'bytes' in msg and msg['bytes']: 

409 await upstream.send_bytes(msg['bytes']) 

410 elif 'text' in msg and msg['text']: 

411 await upstream.send_str(msg['text']) 

412 except Exception: 

413 pass 

414 

415 async def _upstream_to_client(): 

416 """Forward upstream → client.""" 

417 try: 

418 async for msg in upstream: 

419 if msg.type == aiohttp.WSMsgType.BINARY: 

420 await ws.send_bytes(msg.data) 

421 elif msg.type == aiohttp.WSMsgType.TEXT: 

422 await ws.send_text(msg.data) 

423 elif msg.type in ( 

424 aiohttp.WSMsgType.CLOSE, 

425 aiohttp.WSMsgType.ERROR, 

426 ): 

427 break 

428 except Exception: 

429 pass 

430 

431 async def _watch_access(): 

432 try: 

433 while True: 

434 # Poll current state so revocation also works across workers. 

435 await asyncio.sleep(10) 

436 if await _resolve_terminal_access(ws, server_id, token) is None: 

437 return 

438 except Exception: 

439 log.exception('Terminal access recheck failed') 

440 

441 # End the proxy as soon as any task finishes (e.g. a 

442 # graceful upstream CLOSE) and cancel the rest, which would 

443 # otherwise hang on a blocked ws.receive() until the browser leaves. 

444 tasks = [ 

445 asyncio.create_task(_client_to_upstream()), 

446 asyncio.create_task(_upstream_to_client()), 

447 asyncio.create_task(_watch_access()), 

448 ] 

449 try: 

450 await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 

451 finally: 

452 for task in tasks: 

453 task.cancel() 

454 await asyncio.gather(*tasks, return_exceptions=True) 

455 except Exception as e: 

456 log.exception('Terminal WebSocket proxy error: %s', e) 

457 finally: 

458 await session.close() 

459 if opened: 

460 await publish_event( 

461 app, 

462 EVENTS.TERMINAL_SESSION_CLOSED, 

463 actor=user, 

464 subject_id=session_id, 

465 subject_type='terminal.session', 

466 data={'server_id': server_id}, 

467 ) 

468 try: 

469 await ws.close() 

470 except Exception: 

471 pass