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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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