From b1dee47126a6712ad9cae6c3837430652fe46d10 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sat, 21 Feb 2026 11:18:29 +0800 Subject: [PATCH] Add CORS, rate limiting, security headers, and files endpoint protection --- dashboard/backend/config.py | 3 +++ dashboard/backend/main.py | 32 ++++++++++++++++++++++++++++-- dashboard/backend/requirements.txt | 1 + dashboard/backend/routers/auth.py | 7 ++++++- dashboard/backend/routers/files.py | 28 +++++++++++++++++++++----- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 6f1b0b9..83947ab 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -27,3 +27,6 @@ VPS_SSH_KEY_PATH = os.environ.get("VPS_SSH_KEY_PATH", "/app/ssh/vps_id_ed25519") TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "") + +# CORS +CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com").split(",") diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index a24cbd8..c647d69 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -1,13 +1,41 @@ -from fastapi import FastAPI, Depends +from fastapi import FastAPI, Depends, Request from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded from routers import docker_router, gitea, files, terminal, system, auth import auth as auth_module import asyncio import httpx -from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID +from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS +limiter = Limiter(key_func=get_remote_address) app = FastAPI(title="NAS Dashboard") +app.state.limiter = limiter + +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"}) + +@app.middleware("http") +async def security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + return response async def monitor_containers(): import docker diff --git a/dashboard/backend/requirements.txt b/dashboard/backend/requirements.txt index 4a09f49..e0d3476 100644 --- a/dashboard/backend/requirements.txt +++ b/dashboard/backend/requirements.txt @@ -8,3 +8,4 @@ pyjwt==2.8.0 passlib==1.7.4 pyotp==2.9.0 asyncssh==2.17.0 +slowapi==0.1.9 diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 478362d..62eb01a 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -3,11 +3,14 @@ import asyncio import httpx from fastapi import APIRouter, Depends, HTTPException, status, Request from pydantic import BaseModel +from slowapi import Limiter +from slowapi.util import get_remote_address import config import auth import pyotp router = APIRouter() +limiter = Limiter(key_func=get_remote_address) class LoginRequest(BaseModel): username: str @@ -50,6 +53,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str): print(f"Failed to send Telegram alert: {e}", flush=True) @router.post("/login", response_model=Token) +@limiter.limit("5/minute") async def login(creds: LoginRequest, request: Request): client_ip = request.client.host # Verify username/password @@ -121,7 +125,8 @@ class ChangePasswordRequest(BaseModel): new_password: str @router.post("/change-password") -async def change_password(req: ChangePasswordRequest, current_user: str = Depends(auth.get_current_user)): +@limiter.limit("5/minute") +async def change_password(req: ChangePasswordRequest, request: Request, current_user: str = Depends(auth.get_current_user)): if not auth.verify_password(req.current_password, auth.load_password_hash()): raise HTTPException(status_code=400, detail="Current password is incorrect") if len(req.new_password) < 8: diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index 3594048..b4be4ed 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -1,24 +1,33 @@ import os import shutil from pathlib import Path -from fastapi import APIRouter, UploadFile, File +from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi.responses import FileResponse from config import VOLUME_ROOT router = APIRouter() BASE = Path(VOLUME_ROOT) +BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp"} + def _safe_path(path: str) -> Path: target = (BASE / path).resolve() if not str(target).startswith(str(BASE.resolve())): 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 @router.get("/browse") def browse(path: str = ""): - target = _safe_path(path) + try: + target = _safe_path(path) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") entries = [] for item in sorted(target.iterdir()): try: @@ -31,12 +40,18 @@ def browse(path: str = ""): @router.get("/download") def download(path: str): - return FileResponse(_safe_path(path)) + try: + return FileResponse(_safe_path(path)) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") @router.post("/upload") async def upload(path: str, file: UploadFile = File(...)): - target = _safe_path(path) / file.filename + try: + target = _safe_path(path) / file.filename + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") with open(target, "wb") as f: shutil.copyfileobj(file.file, f) return {"ok": True} @@ -44,7 +59,10 @@ async def upload(path: str, file: UploadFile = File(...)): @router.delete("/delete") def delete(path: str): - target = _safe_path(path) + try: + target = _safe_path(path) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") if target.is_dir(): shutil.rmtree(target) else: