feat: SAC 0.7.2 security hardening (rate limit, JWT DB check, DOMPurify)

Path traversal fix in SPA fallback, admin-only host delete, login rate limit
with 3 attempts and Telegram alert, JWT validated against active users in DB,
and DOMPurify for agent report HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-01 11:04:14 +10:00
parent 06a8ed8614
commit d3a337992c
19 changed files with 554 additions and 98 deletions
+78 -64
View File
@@ -1,64 +1,78 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
from app.config import get_settings
from app.database import get_db
from app.services.user_auth import authenticate_user
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
role: str
class MeResponse(BaseModel):
username: str
role: str
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
settings = get_settings()
if not settings.sac_admin_password and not _has_any_user(db):
raise HTTPException(
status_code=503,
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
)
user = authenticate_user(db, body.username, body.password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(user.username, user.role)
return TokenResponse(
access_token=token,
username=user.username,
role=user.role,
)
@router.get("/me", response_model=MeResponse)
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
return MeResponse(username=current_user.username, role=current_user.role)
def _has_any_user(db: Session) -> bool:
from sqlalchemy import func, select
from app.models.user import User
try:
count = db.scalar(select(func.count()).select_from(User)) or 0
return count > 0
except Exception:
db.rollback()
return False
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
from app.config import get_settings
from app.database import get_db
from app.services.login_rate_limit import (
ensure_login_allowed,
record_login_failure,
record_login_success,
)
from app.services.user_auth import authenticate_user
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
role: str
class MeResponse(BaseModel):
username: str
role: str
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
settings = get_settings()
if not settings.sac_admin_password and not _has_any_user(db):
raise HTTPException(
+2 -2
View File
@@ -3,7 +3,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user
from app.auth.jwt_auth import get_current_user, require_admin
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
@@ -91,7 +91,7 @@ class HostDeleteResponse(BaseModel):
def delete_host(
host_id: int,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> HostDeleteResponse:
result = delete_host_and_related(db, host_id)
if result is None:
+6 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import verify_access_token
from app.database import SessionLocal
from app.database import SessionLocal, get_db
from app.models import Event, Problem
from app.config import get_settings
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
@@ -66,8 +66,11 @@ async def _sse_generator():
return
def _sse_auth(token: str = Query(..., description="JWT access_token")) -> str:
return verify_access_token(token)
def _sse_auth(
token: str = Query(..., description="JWT access_token"),
db: Session = Depends(get_db),
) -> str:
return verify_access_token(token, db=db)
@router.get("/events")