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_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(",")
+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.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
+1
View File
@@ -8,3 +8,4 @@ pyjwt==2.8.0
passlib==1.7.4
pyotp==2.9.0
asyncssh==2.17.0
slowapi==0.1.9
+6 -1
View File
@@ -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:
+23 -5
View File
@@ -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: