From d3a337992cac3819373432fc2e21ea08026936a1 Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 1 Jun 2026 11:04:14 +1000 Subject: [PATCH] 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 --- .../alembic/versions/011_login_attempts.py | 35 +++++ backend/app/api/v1/auth.py | 142 +++++++++-------- backend/app/api/v1/hosts.py | 4 +- backend/app/api/v1/stream.py | 9 +- backend/app/auth/jwt_auth.py | 111 +++++++++++++- backend/app/config.py | 5 + backend/app/main.py | 12 +- backend/app/models/__init__.py | 2 + backend/app/models/login_attempt.py | 21 +++ backend/app/services/login_rate_limit.py | 144 ++++++++++++++++++ backend/app/version.py | 2 +- backend/tests/test_health.py | 4 +- backend/tests/test_hosts.py | 15 ++ backend/tests/test_login_rate_limit.py | 65 ++++++++ deploy/env.native.example | 5 + frontend/package-lock.json | 33 +++- frontend/package.json | 3 +- frontend/src/utils/reportDisplay.ts | 38 +++-- frontend/src/version.ts | 2 +- 19 files changed, 554 insertions(+), 98 deletions(-) create mode 100644 backend/alembic/versions/011_login_attempts.py create mode 100644 backend/app/models/login_attempt.py create mode 100644 backend/app/services/login_rate_limit.py create mode 100644 backend/tests/test_login_rate_limit.py diff --git a/backend/alembic/versions/011_login_attempts.py b/backend/alembic/versions/011_login_attempts.py new file mode 100644 index 0000000..7a52f9f --- /dev/null +++ b/backend/alembic/versions/011_login_attempts.py @@ -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") diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 1364f5f..86d4870 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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( + status_code=503, + detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)", + ) + + ip_address = ensure_login_allowed(db, request) + + user = authenticate_user(db, body.username, body.password) + if user is None: + record_login_failure(db, ip_address=ip_address, username=body.username) + db.commit() + raise HTTPException(status_code=401, detail="Invalid username or password") + + record_login_success(db, ip_address=ip_address, username=user.username) + db.commit() + + 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 diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 41c9dba..f0fe294 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -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: diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py index b2896f6..05c7709 100644 --- a/backend/app/api/v1/stream.py +++ b/backend/app/api/v1/stream.py @@ -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") diff --git a/backend/app/auth/jwt_auth.py b/backend/app/auth/jwt_auth.py index ab9c281..e9a4776 100644 --- a/backend/app/auth/jwt_auth.py +++ b/backend/app/auth/jwt_auth.py @@ -1,70 +1,171 @@ from dataclasses import dataclass + from datetime import datetime, timedelta, timezone + + from fastapi import Depends, HTTPException, status + from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + from jose import JWTError, jwt +from sqlalchemy import func, select + +from sqlalchemy.orm import Session + + + from app.config import get_settings -from app.models.user import USER_ROLE_ADMIN + +from app.database import get_db + +from app.models.user import USER_ROLE_ADMIN, User + + bearer_scheme = HTTPBearer(auto_error=False) + + + @dataclass(frozen=True) + class CurrentUser: + 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) + payload = {"sub": subject, "role": role, "exp": expire} + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) -def _decode_current_user(token: str) -> CurrentUser: + + + +def _user_from_db(db: Session, username: str) -> User: + + normalized = username.strip() + + user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower())) + + if user is None or not user.is_active: + + raise HTTPException(status_code=401, detail="Invalid token") + + return user + + + + + +def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser: + settings = get_settings() + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + username = payload.get("sub") + if not username: + raise HTTPException(status_code=401, detail="Invalid token") + + if db is not None: + + user = _user_from_db(db, str(username)) + + return CurrentUser(username=user.username, role=user.role) + role = str(payload.get("role") or USER_ROLE_ADMIN) + return CurrentUser(username=str(username), role=role) + except JWTError as exc: + raise HTTPException(status_code=401, detail="Invalid token") from exc -def verify_access_token(token: str) -> str: + + + +def verify_access_token(token: str, db: Session | None = None) -> str: + """Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username.""" - return _decode_current_user(token).username + + return _decode_current_user(token, db=db).username + + + def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + + db: Session = Depends(get_db), + ) -> CurrentUser: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) - return _decode_current_user(credentials.credentials) + + return _decode_current_user(credentials.credentials, db=db) + + + def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if not user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + return user + + \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index f94f629..90680c6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -100,6 +100,11 @@ class Settings(BaseSettings): sac_events_retention_days: int = 90 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 def get_settings() -> Settings: diff --git a/backend/app/main.py b/backend/app/main.py index 2e5359a..4858002 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -105,9 +105,15 @@ def create_app() -> FastAPI: def spa_fallback(spa_path: str) -> FileResponse: if spa_path.startswith("api/"): raise HTTPException(status_code=404, detail="Not Found") - candidate = frontend_dist / spa_path - if spa_path and candidate.is_file(): - return FileResponse(candidate) + dist_root = frontend_dist.resolve() + 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) index = frontend_dist / "index.html" if not index.is_file(): raise HTTPException(status_code=404, detail="UI not built") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 641d9b4..65f4b2e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel from app.models.notification_cooldown import NotificationCooldown from app.models.notification_policy import NotificationPolicy 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.user import User @@ -19,4 +20,5 @@ __all__ = [ "ProblemEvent", "User", "EventSeverityOverride", + "LoginAttempt", ] diff --git a/backend/app/models/login_attempt.py b/backend/app/models/login_attempt.py new file mode 100644 index 0000000..56b0df6 --- /dev/null +++ b/backend/app/models/login_attempt.py @@ -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, + ) diff --git a/backend/app/services/login_rate_limit.py b/backend/app/services/login_rate_limit.py new file mode 100644 index 0000000..f2fb9a8 --- /dev/null +++ b/backend/app/services/login_rate_limit.py @@ -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"⚠️ SAC: подбор пароля UI\n" + f"IP: {ip_address}\n" + f"Логин: {user_hint}\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) diff --git a/backend/app/version.py b/backend/app/version.py index 5759b2c..9aafedc 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.7.1" +APP_VERSION = "0.7.2" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index a4e941e..8567206 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -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" diff --git a/backend/tests/test_hosts.py b/backend/tests/test_hosts.py index c27a961..34fffdc 100644 --- a/backend/tests/test_hosts.py +++ b/backend/tests/test_hosts.py @@ -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", diff --git a/backend/tests/test_login_rate_limit.py b/backend/tests/test_login_rate_limit.py new file mode 100644 index 0000000..fc0b5a2 --- /dev/null +++ b/backend/tests/test_login_rate_limit.py @@ -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 diff --git a/deploy/env.native.example b/deploy/env.native.example index 609fa94..a34cbfc 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -78,4 +78,9 @@ SAC_PRIVILEGE_SPIKE_THRESHOLD=10 SAC_EVENTS_RETENTION_DAYS=90 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=* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3371d55..b549af2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,17 +1,19 @@ { "name": "sac-ui", - "version": "0.1.0", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sac-ui", - "version": "0.1.0", + "version": "0.7.2", "dependencies": { + "dompurify": "^3.2.4", "vue": "^3.5.13", "vue-router": "^4.5.0" }, "devDependencies": { + "@types/dompurify": "^3.2.0", "@vitejs/plugin-vue": "^5.2.1", "typescript": "~5.7.2", "vite": "^6.0.3", @@ -901,6 +903,17 @@ "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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -908,6 +921,13 @@ "dev": true, "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": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1130,6 +1150,15 @@ "dev": true, "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": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5cf835a..8c40411 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "sac-ui", "private": true, - "version": "0.7.1", + "version": "0.7.2", "type": "module", "scripts": { "dev": "vite", @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.2.4", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/frontend/src/utils/reportDisplay.ts b/frontend/src/utils/reportDisplay.ts index 277ea57..8eba0d4 100644 --- a/frontend/src/utils/reportDisplay.ts +++ b/frontend/src/utils/reportDisplay.ts @@ -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 { - const allowed = /^(b|strong|i|em|u|code|pre|br|p|div|span|ul|ol|li|hr)$/i; - return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (tag, name: string) => { - if (allowed.test(name)) { - if (tag.toLowerCase().startsWith("`; - } - if (name.toLowerCase() === "br") { - return "
"; - } - return `<${name.toLowerCase()}>`; - } - return ""; + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ALLOWED_REPORT_TAGS, + ALLOWED_ATTR: [], }); } - export interface DailyReportStats { platform?: "ssh" | "windows"; successful_logins?: number; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index ce77a57..e07f152 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,3 +1,3 @@ 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}`;