security: harden dashboard (SSH keys, auth, uploads, CORS, non-root)
Remove SSH private keys from git, add SECRET_KEY validation, move WS auth from query string to first message, add session limits/idle timeout, PBKDF2 Fernet key, refresh token rotation, TOTP replay protection, file upload size limit + filename sanitization, symlink safety check, restrict CORS methods, IP-gate OpenClaw token, run container as non-root, rate-limit refresh/passkey endpoints, sanitize Gitea path params. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
from fastapi import WebSocket, WebSocketDisconnect, Query
|
||||
import asyncssh
|
||||
import jwt
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSIONS = 5
|
||||
IDLE_TIMEOUT = 30 * 60 # 30 minutes
|
||||
_active_sessions: set = set()
|
||||
|
||||
HOSTS = {
|
||||
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
|
||||
"vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH},
|
||||
@@ -12,26 +20,43 @@ HOSTS = {
|
||||
"command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"},
|
||||
}
|
||||
|
||||
# Load known_hosts once at import time
|
||||
_known_hosts = None
|
||||
try:
|
||||
_known_hosts = asyncssh.read_known_hosts(config.SSH_KNOWN_HOSTS)
|
||||
except Exception:
|
||||
logger.warning("SSH known_hosts file not found at %s — host key verification disabled", config.SSH_KNOWN_HOSTS)
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = Query("nas")):
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
await websocket.accept()
|
||||
|
||||
# Auth: expect token as first text message
|
||||
try:
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("sub") != config.ADMIN_USER:
|
||||
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" or payload.get("sub") != config.ADMIN_USER:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
except Exception:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
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)
|
||||
|
||||
h = HOSTS.get(host, HOSTS["nas"])
|
||||
try:
|
||||
conn = await asyncssh.connect(
|
||||
h["host"], username=h["user"],
|
||||
client_keys=[h["key"]], known_hosts=None,
|
||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||
)
|
||||
except Exception:
|
||||
_active_sessions.discard(session_id)
|
||||
await websocket.close(code=1011, reason="SSH connection failed")
|
||||
return
|
||||
|
||||
@@ -52,10 +77,18 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str =
|
||||
pass
|
||||
|
||||
read_task = asyncio.create_task(read_ssh())
|
||||
last_activity = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
try:
|
||||
msg = await asyncio.wait_for(websocket.receive(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
if time.monotonic() - last_activity > IDLE_TIMEOUT:
|
||||
await websocket.close(code=1000, reason="Idle timeout")
|
||||
break
|
||||
continue
|
||||
last_activity = time.monotonic()
|
||||
if "bytes" in msg and msg["bytes"]:
|
||||
data = msg["bytes"]
|
||||
if data[0:1] == b"\x01" and len(data) == 5:
|
||||
@@ -73,3 +106,4 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str =
|
||||
process.close()
|
||||
finally:
|
||||
conn.close()
|
||||
_active_sessions.discard(session_id)
|
||||
|
||||
Reference in New Issue
Block a user