Files
nas-tools/dashboard/backend/routers/files.py
Gan, Jimmy 4201917e17
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
fix: harden dashboard RBAC and cookie auth flows
Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 23:44:23 +08:00

129 lines
4.1 KiB
Python

import os
import re
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter()
BASE = Path(VOLUME_ROOT)
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(_BASE_RESOLVED):
raise ValueError("forbidden")
# Block access to sensitive directories
rel = target.relative_to(BASE.resolve())
if rel.parts and rel.parts[0] in BLOCKED_DIRS:
raise ValueError("forbidden")
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:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
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, OSError):
continue
return {"path": str(target.relative_to(BASE)), "entries": entries}
@router.get("/download")
def download(path: str):
try:
return FileResponse(_safe_path(path))
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
@router.post("/upload", dependencies=[Depends(require_admin())])
async def upload(path: str, file: UploadFile = File(...)):
try:
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:
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}
@router.delete("/delete", dependencies=[Depends(require_admin())])
def delete(path: str, recursive: bool = False):
try:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
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}