security: harden dashboard (SSH keys, auth, uploads, CORS, non-root)
Deploy Dashboard / deploy (push) Successful in 3m37s
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:
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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