Files
nas-tools/dashboard/backend/routers/files.py
T
Gan, Jimmy 67c4394c2d
Deploy Dashboard (Dev) / deploy-dev (push) Failing after 2m11s
Run Tests / Backend Tests (push) Failing after 5m47s
Run Tests / Frontend Tests (push) Failing after 1m30s
Run Tests / Test Summary (push) Failing after 14s
Run Tests / Frontend Tests (pull_request) Failing after 2m20s
Run Tests / Test Summary (pull_request) Failing after 18s
Run Tests / Backend Tests (pull_request) Failing after 4m9s
fix: handle permission errors when browsing directories
2026-04-11 14:06:15 +08:00

149 lines
4.8 KiB
Python

import logging
import os
import re
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter()
logger = logging.getLogger(__name__)
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:
# Strip leading slashes to prevent absolute path interpretation
path = path.lstrip("/")
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")
# Check if we have permission to read the directory
try:
items = list(target.iterdir())
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as e:
logger.error(f"Error reading directory {target}: {e}")
raise HTTPException(status_code=500, detail="Error reading directory")
entries = []
for item in sorted(items):
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:
target = _safe_path(path)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(target)
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:
logger.exception("OS error during file deletion: %s", path)
raise HTTPException(status_code=400, detail="Failed to delete file")
return {"ok": True}