fix: harden dashboard auth and terminal flows
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:
Gan, Jimmy
2026-03-06 23:53:58 +08:00
parent c9f06fbbbb
commit 44f0ed5d04
9 changed files with 337 additions and 175 deletions
+23 -18
View File
@@ -102,7 +102,6 @@ async def login(creds: LoginRequest, request: Request):
async def proxy_auth(request: Request):
"""Auto-login when Authelia has already authenticated the user via forward-auth.
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
import config
from rbac import resolve_role
if not config.is_trusted_proxy(request.client.host):
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}")
@@ -195,16 +194,18 @@ async def rbac_config(current_user = Depends(auth.get_current_user)):
async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac
from rbac import update_rbac
body = await request.json()
pages = body.get("pages")
if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field")
rbac = load_rbac()
existing = rbac.setdefault("user_overrides", {}).get(username, {})
existing["pages"] = pages
rbac["user_overrides"][username] = existing
save_rbac(rbac)
def mutator(rbac):
existing = rbac.setdefault("user_overrides", {}).get(username, {})
existing["pages"] = pages
rbac["user_overrides"][username] = existing
update_rbac(mutator)
return {"ok": True}
@router.get("/preferences")
@@ -235,17 +236,19 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac
rbac = load_rbac()
rbac.get("user_overrides", {}).pop(username, None)
save_rbac(rbac)
from rbac import update_rbac
def mutator(rbac):
rbac.get("user_overrides", {}).pop(username, None)
update_rbac(mutator)
return {"ok": True}
@router.put("/rbac/roles/{role}")
async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac
from rbac import update_rbac
body = await request.json()
pages = body.get("pages")
sidebar_links = body.get("sidebar_links")
@@ -255,10 +258,12 @@ async def rbac_update_role(role: str, request: Request, current_user = Depends(a
raise HTTPException(status_code=400, detail="pages must be '*' or a list")
if sidebar_links is not None and sidebar_links != "*" and not isinstance(sidebar_links, list):
raise HTTPException(status_code=400, detail="sidebar_links must be '*' or a list")
rbac = load_rbac()
role_config = {"pages": pages}
if sidebar_links is not None:
role_config["sidebar_links"] = sidebar_links
rbac.setdefault("role_defaults", {})[role] = role_config
save_rbac(rbac)
def mutator(rbac):
role_config = {"pages": pages}
if sidebar_links is not None:
role_config["sidebar_links"] = sidebar_links
rbac.setdefault("role_defaults", {})[role] = role_config
update_rbac(mutator)
return {"ok": True}
+20 -5
View File
@@ -100,13 +100,28 @@ async def upload(path: str, file: UploadFile = File(...)):
@router.delete("/delete")
def delete(path: str):
def delete(path: str, recursive: bool = False):
try:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
if target == BASE.resolve():
raise HTTPException(status_code=403, detail="Cannot delete base root")
try:
if target.is_dir():
if not recursive:
raise HTTPException(status_code=400, detail="Directory deletion requires recursive=true")
shutil.rmtree(target)
else:
target.unlink()
except HTTPException:
raise
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Path not found")
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True}
+6 -7
View File
@@ -5,6 +5,7 @@ import platform
import time
import httpx
from fastapi import APIRouter, Query, Request, HTTPException
import config
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
router = APIRouter()
@@ -116,13 +117,11 @@ def _classify(method, path, status, user):
@router.get("/openclaw-token")
def openclaw_token(request: Request):
ip = request.client.host
import config
if config.is_trusted_proxy(ip):
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
ip = forwarded
import config
user = request.state.user
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
ip = config.get_client_ip(request)
if not config.is_lan_ip(ip):
raise HTTPException(status_code=403, detail="Access denied")
return {"token": OPENCLAW_GATEWAY_TOKEN}
+49 -33
View File
@@ -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)