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:
Gan, Jimmy
2026-02-26 17:22:25 +08:00
parent d25b3f0785
commit 394a1aa367
15 changed files with 164 additions and 31 deletions
+1
View File
@@ -4,3 +4,4 @@ dist/
*.tar.gz
__pycache__/
dashboard/nas-dashboard.tar
dashboard/ssh/
+3
View File
@@ -8,10 +8,13 @@ RUN npm install --registry=https://registry.npmmirror.com && npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/*
RUN adduser --disabled-password --no-create-home --gecos "" app
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./
COPY --from=frontend /build/dist /app/static
RUN chown -R app:app /app
USER app
EXPOSE 4000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000"]
+41 -5
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
import time
import jwt
from passlib.context import CryptContext
import pyotp
@@ -19,14 +20,14 @@ def get_password_hash(password):
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh"})
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
async def get_current_user(token: str = Depends(oauth2_scheme)):
@@ -70,6 +71,13 @@ import hashlib
from cryptography.fernet import Fernet
def _get_fernet():
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"nas-dashboard-fernet", iterations=480000)
key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode()))
return Fernet(key)
def _get_legacy_fernet():
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
return Fernet(key)
@@ -83,6 +91,11 @@ def _decrypt(ciphertext: str) -> str:
return ""
try:
return _get_fernet().decrypt(ciphertext.encode()).decode()
except Exception:
pass
# Fallback: try legacy SHA256-based key
try:
return _get_legacy_fernet().decrypt(ciphertext.encode()).decode()
except Exception:
return ciphertext # fallback for unencrypted legacy values
@@ -125,7 +138,16 @@ def verify_totp(token: str, secret: str = None):
if not secret:
return True # 2FA not enabled
totp = pyotp.TOTP(secret)
return totp.verify(token)
if not totp.verify(token):
return False
# Replay protection: reject reuse within same 30s window
current_ts = int(time.time()) // 30
data = _load_auth_data()
if data.get("last_totp_ts") == current_ts:
return False
data["last_totp_ts"] = current_ts
_save_auth_data(data)
return True
# Passkey credentials
def load_passkey_credentials():
@@ -142,3 +164,17 @@ def clear_passkey_credentials():
data = _load_auth_data()
data["passkey_credentials"] = []
_save_auth_data(data)
# Token version for refresh token rotation
def _load_token_version() -> int:
return _load_auth_data().get("token_version", 0)
def increment_token_version() -> int:
data = _load_auth_data()
v = data.get("token_version", 0) + 1
data["token_version"] = v
_save_auth_data(data)
return v
def verify_token_version(tv: int) -> bool:
return tv == _load_token_version()
+3
View File
@@ -8,6 +8,8 @@ VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1")
# Auth
SECRET_KEY = os.environ.get("SECRET_KEY", "")
if not SECRET_KEY or len(SECRET_KEY) < 32:
raise RuntimeError("SECRET_KEY must be set and at least 32 characters")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
@@ -19,6 +21,7 @@ TOTP_SECRET = os.environ.get("TOTP_SECRET", "")
SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal")
SSH_USER = os.environ.get("SSH_USER", "zjgump")
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519")
SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/app/ssh/known_hosts")
VPS_SSH_HOST = os.environ.get("VPS_SSH_HOST", "158.101.140.85")
VPS_SSH_USER = os.environ.get("VPS_SSH_USER", "jimmyg")
+3 -3
View File
@@ -26,8 +26,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
@app.exception_handler(RateLimitExceeded)
@@ -118,7 +118,7 @@ async def health():
async def client_ip(request: Request):
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
lan = ip.startswith("192.168.31.") or (ip.startswith("100.") and _router_reachable())
return {"ip": ip, "lan": lan}
return {"lan": lan}
def _router_reachable():
import socket
+10 -4
View File
@@ -74,7 +74,7 @@ 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"},
)
@@ -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)
+1
View File
@@ -44,6 +44,7 @@ services:
- /volume1:/volume1
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
- /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
interval: 30s
@@ -66,7 +66,7 @@
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token") || "";
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}&host=${tab.hostId}`
`${proto}//${location.host}/ws/terminal?host=${tab.hostId}`
);
ws.binaryType = "arraybuffer";
function sendSize() {
@@ -79,7 +79,7 @@
}
}
ws.onopen = () => { updateTab(tabId, { connected: true }); sendSize(); };
ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true }); sendSize(); };
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onclose = () => {
updateTab(tabId, { connected: false });