Add CORS, rate limiting, security headers, and files endpoint protection

This commit is contained in:
Gan, Jimmy
2026-02-21 11:18:29 +08:00
parent 7b4c52f646
commit b1dee47126
5 changed files with 63 additions and 8 deletions
+3
View File
@@ -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_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "") TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
# CORS
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com").split(",")
+30 -2
View File
@@ -1,13 +1,41 @@
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends, Request
from fastapi.staticfiles import StaticFiles 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 from routers import docker_router, gitea, files, terminal, system, auth
import auth as auth_module import auth as auth_module
import asyncio import asyncio
import httpx 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 = 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(): async def monitor_containers():
import docker import docker
+1
View File
@@ -8,3 +8,4 @@ pyjwt==2.8.0
passlib==1.7.4 passlib==1.7.4
pyotp==2.9.0 pyotp==2.9.0
asyncssh==2.17.0 asyncssh==2.17.0
slowapi==0.1.9
+6 -1
View File
@@ -3,11 +3,14 @@ import asyncio
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
import config import config
import auth import auth
import pyotp import pyotp
router = APIRouter() router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str 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) print(f"Failed to send Telegram alert: {e}", flush=True)
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@limiter.limit("5/minute")
async def login(creds: LoginRequest, request: Request): async def login(creds: LoginRequest, request: Request):
client_ip = request.client.host client_ip = request.client.host
# Verify username/password # Verify username/password
@@ -121,7 +125,8 @@ class ChangePasswordRequest(BaseModel):
new_password: str new_password: str
@router.post("/change-password") @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()): if not auth.verify_password(req.current_password, auth.load_password_hash()):
raise HTTPException(status_code=400, detail="Current password is incorrect") raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8: if len(req.new_password) < 8:
+19 -1
View File
@@ -1,24 +1,33 @@
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, UploadFile, File from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from config import VOLUME_ROOT from config import VOLUME_ROOT
router = APIRouter() router = APIRouter()
BASE = Path(VOLUME_ROOT) BASE = Path(VOLUME_ROOT)
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp"}
def _safe_path(path: str) -> Path: def _safe_path(path: str) -> Path:
target = (BASE / path).resolve() target = (BASE / path).resolve()
if not str(target).startswith(str(BASE.resolve())): if not str(target).startswith(str(BASE.resolve())):
raise ValueError("forbidden") 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 return target
@router.get("/browse") @router.get("/browse")
def browse(path: str = ""): def browse(path: str = ""):
try:
target = _safe_path(path) target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
entries = [] entries = []
for item in sorted(target.iterdir()): for item in sorted(target.iterdir()):
try: try:
@@ -31,12 +40,18 @@ def browse(path: str = ""):
@router.get("/download") @router.get("/download")
def download(path: str): def download(path: str):
try:
return FileResponse(_safe_path(path)) return FileResponse(_safe_path(path))
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
@router.post("/upload") @router.post("/upload")
async def upload(path: str, file: UploadFile = File(...)): async def upload(path: str, file: UploadFile = File(...)):
try:
target = _safe_path(path) / file.filename target = _safe_path(path) / file.filename
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
with open(target, "wb") as f: with open(target, "wb") as f:
shutil.copyfileobj(file.file, f) shutil.copyfileobj(file.file, f)
return {"ok": True} return {"ok": True}
@@ -44,7 +59,10 @@ async def upload(path: str, file: UploadFile = File(...)):
@router.delete("/delete") @router.delete("/delete")
def delete(path: str): def delete(path: str):
try:
target = _safe_path(path) target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if target.is_dir(): if target.is_dir():
shutil.rmtree(target) shutil.rmtree(target)
else: else: