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:
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user