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:
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
assert APP_VERSION == "0.7.1"
|
||||
assert APP_VERSION == "0.7.2"
|
||||
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"
|
||||
|
||||
@@ -77,6 +77,21 @@ def test_delete_host_404(client, jwt_headers):
|
||||
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):
|
||||
h = Host(
|
||||
hostname="no-auth",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user