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:
2026-06-01 11:04:14 +10:00
parent 06a8ed8614
commit d3a337992c
19 changed files with 554 additions and 98 deletions
@@ -0,0 +1,35 @@
"""login_attempts for SAC UI login rate limiting
Revision ID: 011
Revises: 010
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "011"
down_revision: Union[str, None] = "010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"login_attempts",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("ip_address", sa.String(length=64), nullable=False),
sa.Column("username", sa.String(length=64), nullable=True),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_login_attempts_ip_address", "login_attempts", ["ip_address"], unique=False)
op.create_index("ix_login_attempts_created_at", "login_attempts", ["created_at"], unique=False)
def downgrade() -> None:
op.drop_index("ix_login_attempts_created_at", table_name="login_attempts")
op.drop_index("ix_login_attempts_ip_address", table_name="login_attempts")
op.drop_table("login_attempts")
+16 -2
View File
@@ -1,10 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session 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.database import get_db
from app.services.login_rate_limit import ( from app.services.login_rate_limit import (
@@ -28,16 +33,25 @@ class MeResponse(BaseModel):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
username: str username: str
password: str password: str
class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer"
username: str
role: str
+2 -2
View File
@@ -3,7 +3,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session 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.config import get_settings
from app.database import get_db from app.database import get_db
from app.models import Event, Host from app.models import Event, Host
@@ -91,7 +91,7 @@ class HostDeleteResponse(BaseModel):
def delete_host( def delete_host(
host_id: int, host_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> HostDeleteResponse: ) -> HostDeleteResponse:
result = delete_host_and_related(db, host_id) result = delete_host_and_related(db, host_id)
if result is None: if result is None:
+6 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.jwt_auth import verify_access_token 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.models import Event, Problem
from app.config import get_settings from app.config import get_settings
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
@@ -66,8 +66,11 @@ async def _sse_generator():
return return
def _sse_auth(token: str = Query(..., description="JWT access_token")) -> str: def _sse_auth(
return verify_access_token(token) token: str = Query(..., description="JWT access_token"),
db: Session = Depends(get_db),
) -> str:
return verify_access_token(token, db=db)
@router.get("/events") @router.get("/events")
+115 -14
View File
@@ -1,70 +1,171 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.database import get_db
from app.models.user import USER_ROLE_ADMIN, User from app.models.user import USER_ROLE_ADMIN, User
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
username: str
@dataclass(frozen=True)
role: str
class CurrentUser:
@property
def is_admin(self) -> bool:
username: str
role: str
@property
def is_admin(self) -> bool:
return self.role == USER_ROLE_ADMIN
def create_access_token(subject: str, role: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "role": role, "exp": expire} payload = {"sub": subject, "role": role, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _user_from_db(db: Session, username: str) -> User: def _user_from_db(db: Session, username: str) -> User:
normalized = username.strip()
user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower())) user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
if user is None or not user.is_active: if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
def _decode_current_user(token: str) -> CurrentUser:
return user
def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser: def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
settings = get_settings() settings = get_settings()
+5
View File
@@ -100,6 +100,11 @@ class Settings(BaseSettings):
sac_events_retention_days: int = 90 sac_events_retention_days: int = 90
sac_problems_retention_days: int = 180 sac_problems_retention_days: int = 180
# UI login brute-force protection
sac_login_max_failures: int = 3
sac_login_failure_window_minutes: int = 15
sac_login_alert_telegram: bool = True
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+8 -2
View File
@@ -105,8 +105,14 @@ def create_app() -> FastAPI:
def spa_fallback(spa_path: str) -> FileResponse: def spa_fallback(spa_path: str) -> FileResponse:
if spa_path.startswith("api/"): if spa_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found") raise HTTPException(status_code=404, detail="Not Found")
candidate = frontend_dist / spa_path dist_root = frontend_dist.resolve()
if spa_path and candidate.is_file(): if spa_path:
candidate = (frontend_dist / spa_path).resolve()
try:
candidate.relative_to(dist_root)
except ValueError:
raise HTTPException(status_code=404, detail="Not Found") from None
if candidate.is_file():
return FileResponse(candidate) return FileResponse(candidate)
index = frontend_dist / "index.html" index = frontend_dist / "index.html"
if not index.is_file(): if not index.is_file():
+2
View File
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
from app.models.notification_cooldown import NotificationCooldown from app.models.notification_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent from app.models.problem import Problem, ProblemEvent
from app.models.login_attempt import LoginAttempt
from app.models.event_severity_override import EventSeverityOverride from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User from app.models.user import User
@@ -19,4 +20,5 @@ __all__ = [
"ProblemEvent", "ProblemEvent",
"User", "User",
"EventSeverityOverride", "EventSeverityOverride",
"LoginAttempt",
] ]
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ip_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
index=True,
)
+144
View File
@@ -0,0 +1,144 @@
"""Rate limit failed SAC UI logins + optional Telegram alert."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, Request
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.login_attempt import LoginAttempt
from app.services.telegram_notify import send_telegram_text
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class LoginRateLimitConfig:
max_failures: int
window_minutes: int
alert_telegram: bool
def get_login_rate_limit_config() -> LoginRateLimitConfig:
settings = get_settings()
return LoginRateLimitConfig(
max_failures=max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3)),
window_minutes=max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15)),
alert_telegram=bool(getattr(settings, "sac_login_alert_telegram", True)),
)
def client_ip_from_request(request: Request) -> str:
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
if forwarded:
return forwarded.split(",")[0].strip()[:64]
if request.client and request.client.host:
return request.client.host[:64]
return "unknown"
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
return int(
db.scalar(
select(func.count())
.select_from(LoginAttempt)
.where(
LoginAttempt.ip_address == ip_address,
LoginAttempt.success.is_(False),
LoginAttempt.created_at >= since,
)
)
or 0
)
def _alert_already_sent(db: Session, ip_address: str, *, since: datetime) -> bool:
"""Avoid spamming Telegram on every subsequent 429."""
row = db.scalar(
select(LoginAttempt.username)
.where(
LoginAttempt.ip_address == ip_address,
LoginAttempt.success.is_(False),
LoginAttempt.created_at >= since,
LoginAttempt.username == "__alert_sent__",
)
.limit(1)
)
return row is not None
def _send_login_bruteforce_alert(ip_address: str, username: str, failures: int) -> None:
user_hint = username if username else ""
text = (
f"⚠️ <b>SAC: подбор пароля UI</b>\n"
f"IP: <code>{ip_address}</code>\n"
f"Логин: <code>{user_hint}</code>\n"
f"Неудачных попыток: {failures}\n"
f"Дальнейший вход с этого IP временно заблокирован."
)
try:
send_telegram_text(text, parse_mode="HTML")
except Exception:
logger.exception("failed to send SAC login brute-force Telegram alert")
def ensure_login_allowed(db: Session, request: Request) -> str:
"""Raise 429 if IP exceeded failed login threshold. Returns client IP."""
cfg = get_login_rate_limit_config()
ip_address = client_ip_from_request(request)
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
failures = _failure_count(db, ip_address, since=since)
if failures >= cfg.max_failures:
raise HTTPException(
status_code=429,
detail=f"Слишком много неудачных попыток входа. Повторите через {cfg.window_minutes} мин.",
)
return ip_address
def record_login_success(db: Session, *, ip_address: str, username: str) -> None:
db.add(
LoginAttempt(
ip_address=ip_address,
username=username[:64],
success=True,
)
)
def record_login_failure(
db: Session,
*,
ip_address: str,
username: str,
request: Request | None = None,
) -> None:
del request # reserved
cfg = get_login_rate_limit_config()
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
prior_failures = _failure_count(db, ip_address, since=since)
db.add(
LoginAttempt(
ip_address=ip_address,
username=username[:64] if username else None,
success=False,
)
)
db.flush()
new_failures = prior_failures + 1
if new_failures >= cfg.max_failures and cfg.alert_telegram and not _alert_already_sent(db, ip_address, since=since):
db.add(
LoginAttempt(
ip_address=ip_address,
username="__alert_sent__",
success=False,
)
)
_send_login_bruteforce_alert(ip_address, username, new_failures)
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.7.1" APP_VERSION = "0.7.2"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.7.1" assert APP_VERSION == "0.7.2"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.7.1" assert APP_VERSION_LABEL == "Security Alert Center v.0.7.2"
+15
View File
@@ -77,6 +77,21 @@ def test_delete_host_404(client, jwt_headers):
assert r.status_code == 404 assert r.status_code == 404
def test_delete_host_forbidden_for_monitor(client, db_session, jwt_monitor_headers):
h = Host(
hostname="monitor-blocked",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_monitor_headers)
assert r.status_code == 403
assert db_session.get(Host, h.id) is not None
def test_delete_host_requires_jwt(client, db_session): def test_delete_host_requires_jwt(client, db_session):
h = Host( h = Host(
hostname="no-auth", hostname="no-auth",
+65
View File
@@ -0,0 +1,65 @@
"""SAC UI login rate limit and Telegram alert on brute-force."""
def _failed_login(client, username: str = "test-admin") -> int:
return client.post(
"/api/v1/auth/login",
json={"username": username, "password": "wrong-password"},
).status_code
def test_login_blocked_after_max_failures(client, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3")
from app.config import get_settings
get_settings.cache_clear()
assert _failed_login(client) == 401
assert _failed_login(client) == 401
assert _failed_login(client) == 401
assert _failed_login(client) == 429
ok = client.post(
"/api/v1/auth/login",
json={"username": "test-admin", "password": "test-admin-password"},
)
assert ok.status_code == 429
get_settings.cache_clear()
def test_login_telegram_alert_on_threshold(client, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "true")
from app.config import get_settings
get_settings.cache_clear()
sent: list[str] = []
monkeypatch.setattr(
"app.services.login_rate_limit.send_telegram_text",
lambda text, **kwargs: sent.append(text),
)
for _ in range(3):
assert _failed_login(client, username="attacker") == 401
assert len(sent) == 1
assert "SAC: подбор пароля UI" in sent[0]
assert "attacker" in sent[0]
assert _failed_login(client, username="attacker") == 429
assert len(sent) == 1
get_settings.cache_clear()
def test_deactivated_user_token_rejected_on_me(client, jwt_monitor_headers, db_session):
from app.models.user import User
user = db_session.query(User).filter(User.username == "test-monitor").one()
user.is_active = False
db_session.commit()
response = client.get("/api/v1/auth/me", headers=jwt_monitor_headers)
assert response.status_code == 401
+5
View File
@@ -78,4 +78,9 @@ SAC_PRIVILEGE_SPIKE_THRESHOLD=10
SAC_EVENTS_RETENTION_DAYS=90 SAC_EVENTS_RETENTION_DAYS=90
SAC_PROBLEMS_RETENTION_DAYS=180 SAC_PROBLEMS_RETENTION_DAYS=180
# UI login: блокировка IP после N неудачных попыток (окно в минутах) + алерт в Telegram
SAC_LOGIN_MAX_FAILURES=3
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
SAC_LOGIN_ALERT_TELEGRAM=true
CORS_ORIGINS=* CORS_ORIGINS=*
+31 -2
View File
@@ -1,17 +1,19 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"version": "0.1.0", "version": "0.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sac-ui", "name": "sac-ui",
"version": "0.1.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"dompurify": "^3.2.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
@@ -901,6 +903,17 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -908,6 +921,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1130,6 +1150,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"private": true, "private": true,
"version": "0.7.1", "version": "0.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
+24 -14
View File
@@ -1,20 +1,30 @@
/** Безопасный вывод HTML из агента (только базовая разметка). */ /** Безопасный вывод HTML из агента (DOMPurify, белый список тегов). */
import DOMPurify from "dompurify";
const ALLOWED_REPORT_TAGS = [
"b",
"strong",
"i",
"em",
"u",
"code",
"pre",
"br",
"p",
"div",
"span",
"ul",
"ol",
"li",
"hr",
];
export function sanitizeAgentHtml(html: string): string { export function sanitizeAgentHtml(html: string): string {
const allowed = /^(b|strong|i|em|u|code|pre|br|p|div|span|ul|ol|li|hr)$/i; return DOMPurify.sanitize(html, {
return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (tag, name: string) => { ALLOWED_TAGS: ALLOWED_REPORT_TAGS,
if (allowed.test(name)) { ALLOWED_ATTR: [],
if (tag.toLowerCase().startsWith("</")) {
return `</${name.toLowerCase()}>`;
}
if (name.toLowerCase() === "br") {
return "<br>";
}
return `<${name.toLowerCase()}>`;
}
return "";
}); });
} }
export interface DailyReportStats { export interface DailyReportStats {
platform?: "ssh" | "windows"; platform?: "ssh" | "windows";
successful_logins?: number; successful_logins?: number;
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center"; export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.7.1"; export const APP_VERSION = "0.7.2";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;