fix: harden dashboard auth and terminal flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m29s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m29s
Tighten terminal websocket auth and proxy trust handling while making file-backed auth/RBAC writes atomic to reduce high-impact security and persistence risks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import asyncio
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
from fastapi import WebSocket, WebSocketDisconnect, Query
|
||||
from collections import Counter
|
||||
from fastapi import WebSocket, Query
|
||||
import asyncssh
|
||||
import jwt
|
||||
import config
|
||||
from auth import get_current_user_ws
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSIONS = 5
|
||||
_active_sessions: set = set()
|
||||
MAX_SESSIONS_PER_USER = 2
|
||||
_session_lock = asyncio.Lock()
|
||||
_active_sessions: dict[int, str] = {}
|
||||
|
||||
HOSTS = {
|
||||
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
|
||||
@@ -31,43 +33,57 @@ except Exception:
|
||||
logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS)
|
||||
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
await websocket.accept()
|
||||
async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
|
||||
async with _session_lock:
|
||||
if len(_active_sessions) >= MAX_SESSIONS:
|
||||
return False, "Too many sessions", 1013
|
||||
user_sessions = Counter(_active_sessions.values())
|
||||
if user_sessions[username] >= MAX_SESSIONS_PER_USER:
|
||||
return False, "Too many sessions for user", 1013
|
||||
_active_sessions[session_id] = username
|
||||
return True, None, session_id
|
||||
|
||||
|
||||
async def _release_session(session_id: int | None):
|
||||
if session_id is None:
|
||||
return
|
||||
async with _session_lock:
|
||||
_active_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)):
|
||||
if host not in HOSTS:
|
||||
await websocket.close(code=1008, reason="Unknown host")
|
||||
return
|
||||
|
||||
if not token:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Auth: expect token as first text message
|
||||
try:
|
||||
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
|
||||
payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "access":
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
username = payload.get("sub")
|
||||
role = payload.get("role", "admin")
|
||||
if username is None:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
# RBAC: check terminal page access
|
||||
from rbac import get_pages
|
||||
pages = get_pages(username, role)
|
||||
if pages != "*" and "terminal" not in pages:
|
||||
await websocket.close(code=1008, reason="Access denied")
|
||||
return
|
||||
user = await get_current_user_ws(token)
|
||||
except Exception:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
if user.pages != "*" and "terminal" not in user.pages:
|
||||
await websocket.close(code=1008, reason="Access denied")
|
||||
return
|
||||
|
||||
if not _known_hosts_available:
|
||||
await websocket.close(code=1011, reason="SSH host verification unavailable")
|
||||
return
|
||||
|
||||
if len(_active_sessions) >= MAX_SESSIONS:
|
||||
await websocket.close(code=1013, reason="Too many sessions")
|
||||
return
|
||||
|
||||
session_id = id(websocket)
|
||||
_active_sessions.add(session_id)
|
||||
reserved, reason, session_or_code = await _reserve_session(session_id, user.username)
|
||||
if not reserved:
|
||||
await websocket.close(code=session_or_code, reason=reason)
|
||||
return
|
||||
session_id = session_or_code
|
||||
|
||||
h = HOSTS.get(host, HOSTS["nas"])
|
||||
await websocket.accept()
|
||||
|
||||
h = HOSTS[host]
|
||||
try:
|
||||
conn = await asyncssh.connect(
|
||||
h["host"], username=h["user"],
|
||||
@@ -76,7 +92,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
keepalive_count_max=3,
|
||||
)
|
||||
except Exception:
|
||||
_active_sessions.discard(session_id)
|
||||
await _release_session(session_id)
|
||||
await websocket.close(code=1011, reason="SSH connection failed")
|
||||
return
|
||||
|
||||
@@ -93,7 +109,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
if not data:
|
||||
break
|
||||
await websocket.send_bytes(data)
|
||||
except (asyncssh.Error, WebSocketDisconnect, asyncio.CancelledError):
|
||||
except (asyncssh.Error, asyncio.CancelledError):
|
||||
pass
|
||||
|
||||
read_task = asyncio.create_task(read_ssh())
|
||||
@@ -113,11 +129,11 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
if msg["text"] == "__ping__":
|
||||
continue
|
||||
process.stdin.write(msg["text"].encode())
|
||||
except WebSocketDisconnect:
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
read_task.cancel()
|
||||
process.close()
|
||||
finally:
|
||||
conn.close()
|
||||
_active_sessions.discard(session_id)
|
||||
await _release_session(session_id)
|
||||
|
||||
Reference in New Issue
Block a user