security: harden dashboard (SSH keys, auth, uploads, CORS, non-root)
Deploy Dashboard / deploy (push) Successful in 3m37s

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:
Gan, Jimmy
2026-02-26 17:22:25 +08:00
parent db037eeb5c
commit c7a0a53fe8
19 changed files with 164 additions and 47 deletions
+11 -5
View File
@@ -74,10 +74,10 @@ async def login(creds: LoginRequest, request: Request):
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Incorrect username or password"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Check 2FA
totp_secret = auth.load_totp_secret()
if totp_secret:
@@ -92,7 +92,7 @@ async def login(creds: LoginRequest, request: Request):
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Invalid 2FA Code"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid 2FA code",
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
@@ -115,7 +115,8 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)):
return {"username": current_user, "has_2fa": bool(totp_secret)}
@router.post("/refresh")
async def refresh_token(req: RefreshRequest):
@limiter.limit("10/minute")
async def refresh_token(req: RefreshRequest, request: Request):
try:
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "refresh":
@@ -123,8 +124,11 @@ async def refresh_token(req: RefreshRequest):
username = payload.get("sub")
if username != config.ADMIN_USER:
raise HTTPException(status_code=401, detail="Invalid user")
if not auth.verify_token_version(payload.get("tv", -1)):
raise HTTPException(status_code=401, detail="Token revoked")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
auth.increment_token_version()
access_token = auth.create_access_token(
data={"sub": username},
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
@@ -217,7 +221,8 @@ async def passkey_register_verify(request: Request, current_user: str = Depends(
return {"ok": True}
@router.post("/passkey/login/options")
async def passkey_login_options():
@limiter.limit("10/minute")
async def passkey_login_options(request: Request):
creds = auth.load_passkey_credentials()
if not creds:
raise HTTPException(status_code=404, detail="No passkeys registered")
@@ -231,6 +236,7 @@ async def passkey_login_options():
return JSONResponse(content=json.loads(options_to_json(options)))
@router.post("/passkey/login/verify")
@limiter.limit("10/minute")
async def passkey_login_verify(request: Request):
body = await request.json()
challenge = _get_challenge()
+2 -2
View File
@@ -1,5 +1,5 @@
import docker
from fastapi import APIRouter
from fastapi import APIRouter, Query
from config import DOCKER_HOST
router = APIRouter()
@@ -31,6 +31,6 @@ def container_action(container_id: str, action: str):
@router.get("/containers/{container_id}/logs")
def container_logs(container_id: str, tail: int = 200):
def container_logs(container_id: str, tail: int = Query(200, le=10000)):
c = client.containers.get(container_id)
return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")}
+47 -5
View File
@@ -1,4 +1,5 @@
import os
import re
import shutil
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, HTTPException
@@ -8,12 +9,16 @@ from config import VOLUME_ROOT
router = APIRouter()
BASE = Path(VOLUME_ROOT)
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker"}
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"}
_BASE_RESOLVED = str(BASE.resolve())
def _safe_path(path: str) -> Path:
target = (BASE / path).resolve()
if not str(target).startswith(str(BASE.resolve())):
if not str(target).startswith(_BASE_RESOLVED):
raise ValueError("forbidden")
# Block access to sensitive directories
rel = target.relative_to(BASE.resolve())
@@ -22,6 +27,32 @@ def _safe_path(path: str) -> Path:
return target
def _is_safe_symlink(item: Path) -> bool:
"""Check if a symlink resolves to a path within BASE and not in blocked dirs."""
if not item.is_symlink():
return True
try:
resolved = item.resolve()
if not str(resolved).startswith(_BASE_RESOLVED):
return False
rel = resolved.relative_to(BASE.resolve())
if rel.parts and rel.parts[0] in BLOCKED_DIRS:
return False
return True
except (OSError, ValueError):
return False
def _sanitize_filename(name: str) -> str:
name = os.path.basename(name)
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
name = re.sub(r'\.\.+', '.', name)
name = name.strip(". ")
if not name:
raise ValueError("invalid filename")
return name
@router.get("/browse")
def browse(path: str = ""):
try:
@@ -31,9 +62,11 @@ def browse(path: str = ""):
entries = []
for item in sorted(target.iterdir()):
try:
if not _is_safe_symlink(item):
continue
st = item.stat()
entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0})
except PermissionError:
except (PermissionError, OSError):
continue
return {"path": str(target.relative_to(BASE)), "entries": entries}
@@ -49,11 +82,20 @@ def download(path: str):
@router.post("/upload")
async def upload(path: str, file: UploadFile = File(...)):
try:
target = _safe_path(path) / file.filename
safe_name = _sanitize_filename(file.filename)
target = _safe_path(path) / safe_name
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
# Stream with size check
total = 0
with open(target, "wb") as f:
shutil.copyfileobj(file.file, f)
while chunk := await file.read(1024 * 1024):
total += len(chunk)
if total > MAX_UPLOAD_SIZE:
f.close()
target.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="File too large (max 100MB)")
f.write(chunk)
return {"ok": True}
+5 -1
View File
@@ -1,9 +1,11 @@
import re
import httpx
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from config import GITEA_URL, GITEA_TOKEN
router = APIRouter()
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$')
@router.get("/repos")
@@ -15,6 +17,8 @@ async def list_repos():
@router.get("/repos/{owner}/{repo}/commits")
async def list_commits(owner: str, repo: str, page: int = 1):
if not _SAFE_NAME.match(owner) or not _SAFE_NAME.match(repo):
raise HTTPException(status_code=400, detail="Invalid owner or repo name")
async with httpx.AsyncClient() as c:
r = await c.get(f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/commits", headers=HEADERS, params={"page": page})
return r.json()
+5 -2
View File
@@ -4,7 +4,7 @@ import psutil
import platform
import time
import httpx
from fastapi import APIRouter, Query
from fastapi import APIRouter, Query, Request, HTTPException
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
router = APIRouter()
@@ -115,5 +115,8 @@ def _classify(method, path, status, user):
@router.get("/openclaw-token")
def openclaw_token():
def openclaw_token(request: Request):
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
if not (ip.startswith("192.168.31.") or ip.startswith("100.")):
raise HTTPException(status_code=403, detail="Access denied")
return {"token": OPENCLAW_GATEWAY_TOKEN}
+40 -6
View File
@@ -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)