From 049c45db5cf7a75425041ab2419b88c194557e1e Mon Sep 17 00:00:00 2001 From: mi Date: Wed, 29 Jul 2026 18:21:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BA=D1=80=D1=8B=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=81=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20+=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backlog.md | 51 +- codebase/backend/.env.example | 2 +- codebase/backend/api-backend/app/auth.py | 4 + codebase/backend/api-backend/app/main.py | 25 +- .../tests/contract/test_openapi.py | 43 +- .../backend/deployment/DEPLOYMENT_GUIDE.ru.md | 11 +- codebase/backend/deployment/RUNBOOK.md | 6 +- codebase/backend/deployment/RUNBOOK.ru.md | 6 +- .../backend/deployment/scripts/setup-vm.sh | 80 +- .../backend/deployment/scripts/ssl-renew.sh | 21 +- codebase/backend/nginx/docker-compose.yml | 2 +- codebase/backend/tests/test_config.py | 23 + modules/module-05-message-safety.md | 781 +++++++++++------- 13 files changed, 716 insertions(+), 339 deletions(-) diff --git a/backlog.md b/backlog.md index 675cb17..ecffa5a 100644 --- a/backlog.md +++ b/backlog.md @@ -22,10 +22,17 @@ 22. Ограничить кол-во символов в сообщении на фронте. Показывать в моменте счетчик: n/max, где n сколько символов уже напечатано, max сколько может быть отправлено. Максимальное кол-во символов - положить в app_settings. 7. Убрать с экрана ввода номера телефона тексты согласий внизу экрана: Нажимая «Получить код», вы соглашаетесь с условиями использования и политикой конфиденциальности. Согласия пользователь дает ранее на отдельном экране. + + # В разработку: + +3. Унифицировать сообщения гостевого режима о необходмости +На экране профиля в гостевом режиме добавить кнопку "Авторизоваться" + + 2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. 3. аудит безопасности вм -3. На экране профиля в гостевом режиме добавить кнопку "Авторизоваться" + 5. Store-review вход: точечный bypass в Keycloak OTP SPI по номеру из `.env` (`STORE_REVIEW_ENABLED` / `STORE_REVIEW_PHONE` / `STORE_REVIEW_OTP`) — для этого телефона SMS не шлётся, verify принимает фиксированный OTP; остальные номера идут обычным OTP/SMS. Не путать с глобальным `KEYCLOAK_OTP_MOCK_*`. Учётные данные только в Review Notes стора (не в бинарнике/UI); пользователь с демо-контентом; в production включать только на время ревью. 6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) 9. Веб-пуши для PWA @@ -40,6 +47,8 @@ 18. Разработка notification-service 20. Поднять второй контур для продакшн 21. Спрятать сеть за балансировщиком нагрузки +22. Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.) +23. WireGuard-only SSH. 24. Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.). 25. Nginx metrics/tracing в signoz @@ -61,4 +70,42 @@ debounce на отправку СМС (сейчас есть Фиксирова 4. Разработка notification-service 5. Подключить OTLP-провайдер 6. Починить баги -7. Второй контур для продакшн \ No newline at end of file +7. Второй контур для продакшн + +# Переезд на тестовый домен +**Нет — одного `.env` и новых сертификатов недостаточно.** + +Нужно пройти цепочку: + +### 1. DNS +`A`-запись нового домена → IP ВМ (до выпуска сертификата). + +### 2. `.env` — не одно поле, а все публичные URL +- `PUBLIC_HOST`, `PUBLIC_WEB_URL`, `PUBLIC_API_URL`, `PUBLIC_AUTH_URL` +- `KEYCLOAK_PUBLIC_URL` +- `NGINX_TLS_CERTIFICATE` / `NGINX_TLS_CERTIFICATE_KEY` (путь `/etc/letsencrypt/live/<новый-домен>/...`) +- `BITRIX_PUBLIC_BASE_URL` +- `IDGTL_SMS_CALLBACK_PUBLIC_URL` (если SMS уже подключён) + +### 3. Сертификат +Certbot на новый `-d` / `--cert-name`, затем nginx с TLS. + +### 4. Пересборка / перезапуск сервисов +- **frontend-static** — URL зашиты на build (`EXPO_PUBLIC_*` из `PUBLIC_WEB_URL` / `PUBLIC_AUTH_URL`) +- **keycloak** — `KC_HOSTNAME` из `KEYCLOAK_PUBLIC_URL` +- **nginx**, **api-backend** и связанные сервисы — подхватить новый env + +### 5. Настройки в БД (seed / app-settings) +В `app-settings.production-like.yaml`: +- `security.cors.allowed_origins` → `https://новый-домен` +- `notification.instruction.allowed_hosts` → новый хост + +После правки — снова `deployment/scripts/seed.sh` (или ручное обновление в БД). + +### 6. Внешние системы +- **S3 CORS** (Selectel) — `Allowed origin: https://новый-домен` +- **Bitrix24** — URL установки/обработчика (`/bitrix/install`, `/bitrix/handler`) +- **Keycloak client** — redirect URIs / web origins (в realm сейчас зашиты конкретные домены вроде `chat.han0107.ru`) +- **i-Digital** — callback URL, если провайдер его фиксирует + +Итого: `.env` + сертификат — ядро, но без DNS, CORS (API + S3), rebuild frontend, Keycloak hostname/redirects, Bitrix URL и seed CORS логин/загрузки/интеграции сломаются. \ No newline at end of file diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index dabe926..25f599d 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -39,7 +39,7 @@ NGINX_HTTPS_PORT=443 NGINX_TLS_ENABLED=true NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem -NGINX_HSTS_MAX_AGE=0 +NGINX_HSTS_MAX_AGE=31536000 NGINX_CLIENT_MAX_BODY_SIZE=8m NGINX_RATE_LIMIT_API=60r/m NGINX_RATE_LIMIT_AUTH=60r/m diff --git a/codebase/backend/api-backend/app/auth.py b/codebase/backend/api-backend/app/auth.py index f37196d..0903f7e 100644 --- a/codebase/backend/api-backend/app/auth.py +++ b/codebase/backend/api-backend/app/auth.py @@ -31,6 +31,10 @@ class JWKSValidator: self._loaded_at = 0.0 self._lock = asyncio.Lock() + @property + def has_keys(self) -> bool: + return bool(self._keys) + async def refresh(self) -> None: async with self._lock: discovery_url = ( diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index fa3eb72..9a1843d 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -113,6 +113,21 @@ async def refresh_settings_cache(app: FastAPI) -> None: await asyncio.sleep(30) +async def refresh_jwks_cache(app: FastAPI) -> None: + retry_delay = 5 + refresh_delay = max(30, app.state.settings.jwks_cache_ttl_seconds) + delay = refresh_delay if app.state.jwks.has_keys else retry_delay + while True: + await asyncio.sleep(delay) + try: + await app.state.jwks.refresh() + except Exception: + log.warning("jwks.refresh_failed") + delay = retry_delay + else: + delay = refresh_delay + + @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() @@ -142,12 +157,14 @@ async def lifespan(app: FastAPI): await app.state.jwks.refresh() except Exception: structlog.get_logger().warning("jwks.warmup_failed") + jwks_task = asyncio.create_task(refresh_jwks_cache(app)) try: yield finally: - settings_task.cancel() - with suppress(asyncio.CancelledError): - await settings_task + for task in (settings_task, jwks_task): + task.cancel() + with suppress(asyncio.CancelledError): + await task await app.state.http.aclose() await app.state.redis.aclose() await app.state.redis_rt.aclose() @@ -462,7 +479,7 @@ async def ready(request: Request, db: Session): components[name] = "ok" except Exception: components[name] = "failed" - components["jwks"] = "ok" if request.app.state.jwks._keys else "failed" + components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed" try: safety_response = await request.app.state.http.get( f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready", diff --git a/codebase/backend/api-backend/tests/contract/test_openapi.py b/codebase/backend/api-backend/tests/contract/test_openapi.py index 9a742ee..c74b52f 100644 --- a/codebase/backend/api-backend/tests/contract/test_openapi.py +++ b/codebase/backend/api-backend/tests/contract/test_openapi.py @@ -1,14 +1,22 @@ +import asyncio import base64 import json from pathlib import Path from types import SimpleNamespace +import pytest import yaml from alembic.config import Config from alembic.script import ScriptDirectory from pydantic import SecretStr -from app.main import EXPECTED_API_DB_REVISION, app, otp_settings, websocket_token +from app.main import ( + EXPECTED_API_DB_REVISION, + app, + otp_settings, + refresh_jwks_cache, + websocket_token, +) from app.services import SettingsSnapshot EXPECTED_PATHS = { @@ -65,6 +73,39 @@ def test_readiness_expected_revision_matches_alembic_head() -> None: assert EXPECTED_API_DB_REVISION == scripts.get_current_head() +@pytest.mark.asyncio +async def test_jwks_refresh_loop_recovers_after_startup_race(monkeypatch) -> None: + class FakeJWKS: + has_keys = False + refresh_calls = 0 + + async def refresh(self) -> None: + self.refresh_calls += 1 + self.has_keys = True + + jwks = FakeJWKS() + test_app = SimpleNamespace( + state=SimpleNamespace( + jwks=jwks, + settings=SimpleNamespace(jwks_cache_ttl_seconds=300), + ) + ) + delays: list[int] = [] + + async def fake_sleep(delay: int) -> None: + delays.append(delay) + if len(delays) > 1: + raise asyncio.CancelledError + + monkeypatch.setattr("app.main.asyncio.sleep", fake_sleep) + + with pytest.raises(asyncio.CancelledError): + await refresh_jwks_cache(test_app) + + assert jwks.refresh_calls == 1 + assert delays == [5, 300] + + def test_websocket_route_is_registered() -> None: assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes) diff --git a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md index 41f7238..5f6a41e 100644 --- a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md +++ b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md @@ -66,11 +66,14 @@ sudo /tmp/setup-vm.sh - откроет только SSH, HTTP и HTTPS; - создаст `/opt/han-chat/backend`; - создаст swap; -- включит автоматические обновления безопасности. +- включит автоматические обновления безопасности; +- отключит парольный SSH-вход и X11 forwarding; +- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей. -По умолчанию скрипт не отключает парольный SSH-вход. Не используйте -`HARDEN_SSH=true`, пока не проверили вход пользователем `deploy` по ключу в -отдельной сессии. +Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до +блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход +пользователем `root` и SSH TCP forwarding; включайте этот режим только после +проверки входа пользователем `deploy` по ключу в отдельной сессии. Если SSH работает на нестандартном порту или имя внешнего интерфейса известно заранее, передайте параметры: diff --git a/codebase/backend/deployment/RUNBOOK.md b/codebase/backend/deployment/RUNBOOK.md index de2c397..2a52608 100644 --- a/codebase/backend/deployment/RUNBOOK.md +++ b/codebase/backend/deployment/RUNBOOK.md @@ -26,8 +26,10 @@ On a fresh Ubuntu 24.04 VM, run: sudo deployment/scripts/setup-vm.sh ``` -Before setting `HARDEN_SSH=true`, verify key-based access in a separate SSH -session. The script header documents its parameters and safe defaults. +The script disables password SSH and X11 forwarding by default, then locks the +local `root` and `deploy` passwords after checking authorized keys. Before +setting `HARDEN_SSH=true`, which also disables root login and TCP forwarding, +verify key-based deploy access in a separate SSH session. - [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active. - [ ] Key-only deploy account works in a second session; root/password SSH is off. diff --git a/codebase/backend/deployment/RUNBOOK.ru.md b/codebase/backend/deployment/RUNBOOK.ru.md index b73e9b7..d1600ba 100644 --- a/codebase/backend/deployment/RUNBOOK.ru.md +++ b/codebase/backend/deployment/RUNBOOK.ru.md @@ -29,8 +29,10 @@ sudo deployment/scripts/setup-vm.sh ``` -Перед включением `HARDEN_SSH=true` обязательно проверьте вход по ключу в отдельной -SSH-сессии. Параметры запуска и безопасные значения по умолчанию описаны в начале скрипта. +Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после +проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением +`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding, +обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии. - [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска. - [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены. diff --git a/codebase/backend/deployment/scripts/setup-vm.sh b/codebase/backend/deployment/scripts/setup-vm.sh index 979c118..dd5ac71 100644 --- a/codebase/backend/deployment/scripts/setup-vm.sh +++ b/codebase/backend/deployment/scripts/setup-vm.sh @@ -20,11 +20,14 @@ # PUBLIC_DOCKER_PORTS=80,443 # COPY_SSH_KEYS=true # HARDEN_SSH=false +# LOCK_ACCOUNT_PASSWORDS=true +# HSTS_MAX_AGE_SECONDS=31536000 # RESET_UFW=false # SKIP_APT_UPGRADE=false # -# HARDEN_SSH=true разрешено использовать только после проверки входа по ключу -# в отдельной SSH-сессии. По умолчанию парольный вход не отключается. +# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются +# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно +# запрещает прямой root-вход и SSH TCP forwarding. set -Eeuo pipefail IFS=$'\n\t' @@ -38,6 +41,8 @@ EXTERNAL_IF="${EXTERNAL_IF:-}" PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}" COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}" HARDEN_SSH="${HARDEN_SSH:-false}" +LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}" +HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}" RESET_UFW="${RESET_UFW:-false}" SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}" LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}" @@ -73,6 +78,10 @@ validate_parameters() { [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом" ((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона" [[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом" + [[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \ + || die "HSTS_MAX_AGE_SECONDS должен быть целым числом" + ((HSTS_MAX_AGE_SECONDS >= 31536000)) \ + || die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000" [[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \ || die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443" } @@ -138,6 +147,9 @@ create_deploy_user() { local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys" if [[ -n "$source_user" && "$source_user" != "root" ]]; then source_keys="/home/${source_user}/.ssh/authorized_keys" + elif [[ -s /root/.ssh/authorized_keys ]]; then + source_user="root" + source_keys="/root/.ssh/authorized_keys" fi if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then @@ -150,6 +162,21 @@ create_deploy_user() { fi } +configure_account_passwords() { + step "Блокировка локальных паролей привилегированных учетных записей" + if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then + log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены" + return + fi + + [[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ + || die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст" + + passwd --lock root + passwd --lock "$DEPLOY_USER" + log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен" +} + configure_layout() { step "Каталоги HAN Chat" install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR" @@ -361,29 +388,51 @@ EOF } configure_ssh() { - step "Проверка SSH hardening" - if [[ "$HARDEN_SSH" != "true" ]]; then - log "HARDEN_SSH=false: парольный вход не изменен" - return - fi - [[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ - || die "Нельзя включить HARDEN_SSH: authorized_keys пользователя пуст" + step "Настройка SSH" + [[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ + || die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys" - cat >/etc/ssh/sshd_config.d/99-han-chat.conf </etc/ssh/sshd_config.d/00-han-chat.conf <>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF' +PermitRootLogin no +AllowTcpForwarding no +EOF + log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены" + else + log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены" + fi + + rm -f /etc/ssh/sshd_config.d/99-han-chat.conf sshd -t || die "Проверка конфигурации sshd не пройдена" systemctl reload ssh - log "SSH hardening включен" +} + +configure_application_security() { + step "Безопасные HTTP-заголовки приложения" + local env_file="${DEPLOY_DIR}/.env" + + if [[ -f "$env_file" ]]; then + if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then + sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file" + else + printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file" + fi + chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file" + chmod 600 "$env_file" + log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}" + else + log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию" + fi } install_ssl_timer_if_possible() { @@ -443,6 +492,9 @@ summary() { Пользователь развертывания: ${DEPLOY_USER} Каталог Compose: ${DEPLOY_DIR} Открытые порты: ${SSH_PORT}, 80, 443 +Парольный SSH/X11: отключены +Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS} +HSTS max-age: ${HSTS_MAX_AGE_SECONDS} Лог настройки: ${LOG_FILE} Следующие действия: @@ -475,6 +527,7 @@ main() { update_system configure_time create_deploy_user + configure_account_passwords configure_layout configure_swap configure_sysctl @@ -484,6 +537,7 @@ main() { configure_unattended_upgrades configure_docker_firewall configure_ssh + configure_application_security install_ssl_timer_if_possible verify summary diff --git a/codebase/backend/deployment/scripts/ssl-renew.sh b/codebase/backend/deployment/scripts/ssl-renew.sh index 4e6207b..d92df6e 100644 --- a/codebase/backend/deployment/scripts/ssl-renew.sh +++ b/codebase/backend/deployment/scripts/ssl-renew.sh @@ -6,8 +6,23 @@ lock=/tmp/han-chat-cert-renew.lock exec 9>"$lock" flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; } -docker compose --profile certbot run --rm certbot renew \ +compose() { + docker compose --env-file .env "$@" +} + +nginx_container="$(compose ps --status running --quiet nginx)" +if [ -z "$nginx_container" ]; then + echo '{"event":"tls.renew.failed","reason":"nginx_not_running"}' >&2 + exit 1 +fi + +compose --profile certbot run --rm certbot renew \ --webroot -w /var/www/certbot --quiet -docker compose exec -T nginx nginx -t -c /tmp/nginx.conf -docker compose kill -s HUP nginx +compose exec -T nginx nginx -t -c /tmp/nginx.conf + +# Сигнал отправляется PID 1 контейнера. Нельзя использовать `nginx -s reload`: +# он ищет дефолтный /var/run/nginx.pid, тогда как рабочий PID — /tmp/nginx.pid. +compose kill --signal HUP nginx + +compose ps --status running --quiet nginx | awk 'NF {found=1} END {exit !found}' echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}" diff --git a/codebase/backend/nginx/docker-compose.yml b/codebase/backend/nginx/docker-compose.yml index c31b6c5..b9514c1 100644 --- a/codebase/backend/nginx/docker-compose.yml +++ b/codebase/backend/nginx/docker-compose.yml @@ -9,7 +9,7 @@ services: NGINX_TLS_ENABLED: ${NGINX_TLS_ENABLED:-true} NGINX_TLS_CERTIFICATE: ${NGINX_TLS_CERTIFICATE} NGINX_TLS_CERTIFICATE_KEY: ${NGINX_TLS_CERTIFICATE_KEY} - NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0} + NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-31536000} NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m} NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m} NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-60r/m} diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index afd469e..d4e0857 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -75,6 +75,29 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx) self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx) + def test_vm_and_nginx_security_defaults(self) -> None: + setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8") + env_example = (ROOT / ".env.example").read_text(encoding="utf-8") + compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") + ssl_renew = ( + ROOT / "deployment/scripts/ssl-renew.sh" + ).read_text(encoding="utf-8") + + self.assertIn("LOCK_ACCOUNT_PASSWORDS=true", setup) + self.assertIn('passwd --lock root', setup) + self.assertIn('passwd --lock "$DEPLOY_USER"', setup) + self.assertIn("X11Forwarding no", setup) + self.assertIn("PasswordAuthentication no", setup) + self.assertIn("NGINX_HSTS_MAX_AGE=31536000", env_example) + self.assertIn("NGINX_HSTS_MAX_AGE:-31536000", compose) + self.assertIn("compose kill --signal HUP nginx", ssl_renew) + executable_ssl_renew = "\n".join( + line + for line in ssl_renew.splitlines() + if not line.lstrip().startswith("#") + ) + self.assertNotIn("nginx -s reload", executable_ssl_renew) + def test_nginx_internal_denies_precede_spa(self) -> None: site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8") compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") diff --git a/modules/module-05-message-safety.md b/modules/module-05-message-safety.md index 383bd84..f9bb917 100644 --- a/modules/module-05-message-safety.md +++ b/modules/module-05-message-safety.md @@ -1,99 +1,266 @@ -# module-05. Проектная спецификация заглушки `message-safety` +# module-05. Проектная спецификация `message-safety` -> Статус: целевая спецификация тестовой заглушки MVP, строго реализующей правила данного задания. -> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-04-redis.md`](module-04-redis.md). +> Статус: целевая production-спецификация MVP. +> Канонические источники: [`README.md`](../architectory/README.md), [`arch-00-glossary.md`](../architectory/arch-00-glossary.md), [`arch-01-system-architecture.md`](../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../architectory/arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](../architectory/arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](../architectory/arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](../architectory/arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md). -## 1. Назначение и ограничение +## 1. Назначение и приоритет -Сервис — internal stub для проверки orchestration `api-backend`, а не реальный moderation/antivirus engine. Он доступен только в Docker network и реализует канонические пути arch-02: +`message-safety` — внутренний сервис, который до отправки сообщения в Bitrix24 проверяет пользовательский текст, содержащиеся в нём ссылки и файлы из S3-quarantine. -- `POST /internal/safety/v1/messages/check`; -- `GET /internal/safety/v1/messages/tasks/{task_id}`; -- `GET /health/live`; -- `GET /health/ready`. +Сервис закрывает угрозы, поступающие через пользовательское сообщение: -Сервис не публикуется через nginx, не получает JWT пользователя, не перемещает S3 objects, не отправляет сообщения в Bitrix и не хранит бизнес-историю. +- управляющие и prompt-injection конструкции, направленные на оператора или последующую автоматическую обработку; +- опасные URL-схемы, URL с credentials и ссылки на private/link-local/metadata адреса; +- HTML/script-like payloads, способные стать активным содержимым при небезопасном отображении; +- подмену типа файла, несоответствие заявленного MIME фактическому формату и checksum; +- вредоносные файлы, обнаруживаемые антивирусными сигнатурами. -## 2. Главное отличие тестовой заглушки +Спецификация детализирует архитектуру, но не меняет её. При конфликте приоритет имеют `arch-00`…`arch-05`. Канонические domain outcomes: -По базовой архитектуре final deny у Message Safety обычно `403`. Для этой заглушки пользователь явно задал особый task-контракт: `GET task` независимо возвращает примерно с равной вероятностью `203`, `200` или **`400`**. +- `200 allow`; +- `403 deny`; +- `203 pending` с последующим sticky `200` или `403`. -Здесь `400` на валидном `GET task` — **финальный отрицательный verdict/error заглушки**, а не malformed HTTP request. `api-backend` обязан трактовать его как terminal safety rejection и отображать публично как `422 message_blocked`, выставляя `safety_status=blocked`, `delivery_status=rejected`, без вызова Bitrix. Клиенту raw internal `400` не проксируется. +Test-only правила по первому символу, случайные verdict и terminal `400 stub_final_error` в production-контракт не входят. -Это намеренное test-only расширение текущей таблицы arch-02 (`200/203/403`). Перед использованием не как заглушки arch-02 и contract tests должны быть обновлены либо `400` должен быть заменён на канонический `403`. Существующие arch-файлы в рамках этой задачи не изменяются. +## 2. Границы ответственности -## 3. Технологический профиль +### 2.1. Сервис отвечает за -- Python 3.12+, FastAPI, Pydantic v2, Uvicorn. -- Redis asyncio client, DB2. -- OpenTelemetry, JSON logging. -- pytest/anyio, HTTPX ASGI client, real Redis integration tests. -- Без PostgreSQL и S3 для этой stub-реализации; их будущая интеграция находится вне scope. +- строгую валидацию internal DTO; +- нормализацию и rule-based проверку текста; +- извлечение и проверку ссылок; +- валидацию file metadata и фактического формата; +- чтение файла из S3-quarantine по read-only credentials; +- вычисление authoritative SHA-256; +- антивирусную проверку файла через ClamAV; +- выбор sync/async режима; +- создание и исполнение async safety tasks; +- sticky final verdict, verdict cache и audit в схеме `message_safety`; +- task coordination/cache/rate limits в Redis DB2; +- internal API, health, метрики, трассировку и безопасные JSON-логи. -## 4. Приоритет правил +### 2.2. Сервис не отвечает за -Перед классификацией текст нормализуется. Правила применяются строго в порядке: +- JWT пользователя, согласия и авторизацию доступа пользователя к диалогу; +- edge/API rate limits; +- загрузку файла и выдачу presigned URL; +- запись пользовательского сообщения и статусов в `han_app`; +- copy/promote файла из quarantine в S3-data и удаление объекта; +- доставку в Bitrix24, realtime и пользовательский текст ошибки; +- анализ входящих сообщений оператора; +- ML-модерацию смысла, токсичности или правдивости текста. -1. validation/auth: invalid DTO или service token обрабатываются до бизнес-правил; -2. нормализация; -3. если первый Unicode code point нормализованного текста — кириллическая `ф` или `Ф`, вернуть `403 deny`; -4. иначе если первый code point — десятичная цифра, создать task и вернуть `203 pending`; -5. любой иной текст, включая пустой после допустимой нормализации, вернуть `200 allow`. +Этими операциями владеет `api-backend` или соответствующий архитектурный модуль. -Таким образом, после нормализации строка не может одновременно начинаться и с `ф/Ф`, и с цифры. Rule `ф/Ф` записан раньше для явности. Для file-only request без текста default — `200 allow`; заглушка не сканирует файл. +## 3. Threat model MVP -## 5. Нормализация +### 3.1. Текст и ссылки -Детерминированный pipeline: - -1. требовать JSON UTF-8; -2. заменить `CRLF/CR` на `LF`; -3. Unicode normalization `NFKC`; -4. удалить leading Unicode whitespace (`lstrip`); -5. не менять регистр всей строки и не удалять punctuation; -6. ограничить текст max length до значения internal DTO (ориентир 10 000 code points). - -Примеры: - -| Вход | После нормализации | Результат | +| Угроза | Контроль | Результат | |---|---|---| -| `"Файл"` | `"Файл"` | 403 | -| `" фраза"` | `"фраза"` | 403 | -| `"\u00a07 дней"` | `"7 дней"` | 203 + task | -| `"+7..."` | `"+7..."` | 200 | -| `"документ"` | `"документ"` | 200 | -| `"abc"` | `"abc"` | 200 | -| `""`/whitespace | `""` | 200 | +| Prompt/control injection | Версионированные Unicode-aware rules | `403 deny` | +| Попытка выдать текст за system/developer instruction | Нормализация + rule pack | `403 deny` | +| Script/active-content payload | Правила для script, event-handler и опасных embedding-конструкций | `403 deny` | +| Опасная URL-схема | Разрешены только `http` и `https` для распознанных web URL | `403 deny` | +| URL с userinfo/credentials | Запрет `user:password@host` | `403 deny` | +| SSRF-ссылка | DNS/IP classification, запрет private, loopback, link-local, multicast, unspecified и metadata endpoints | `403 deny` | +| Обход Unicode/whitespace | NFKC, CRLF→LF, Unicode whitespace handling | Проверка нормализованного текста | +| ReDoS/DoS правилами | Линейные/ограниченные regex, лимиты текста, URL и времени | `400` или dependency error | -«Цифра» означает Unicode category `Nd` после NFKC, не только ASCII `[0-9]`. +Rules не заменяют безопасный rendering. Frontend и Bitrix integration обязаны экранировать текст; safety является дополнительным барьером, а не HTML sanitizer. -## 6. Authentication и common headers +### 3.2. Файлы -Каждый `/internal/safety/v1/*` требует: +| Угроза | Контроль | Результат | +|---|---|---| +| Недопустимый размер/MIME | Сверка DTO с allow-list и лимитами | `403 deny` | +| Подмена MIME | Magic-byte/content sniffing, сверка declared MIME | `403 deny` | +| Подмена содержимого после complete | Полный SHA-256 против DTO checksum | `403 deny` | +| Malware | ClamAV scan актуальными сигнатурами | `403 deny` | +| Архивная бомба/ресурсное истощение | Лимиты размера, stream scan, ClamAV limits/timeouts | deny при policy hit; error при сбое | +| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` | +| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict | + +MVP принимает только типы из `chat.attachments.allowed_extensions` и `chat.attachments.allowed_mime_types`, при `chat.attachments.max_size_mb`. Расширение проверяет `api-backend` до вызова safety; `message-safety` независимо проверяет MIME и фактический формат байтов. Internal DTO не содержит имени файла, поэтому сервис не выводит расширение из object key. + +### 3.3. Вне threat model MVP + +- zero-day malware, отсутствующий в сигнатурах и эвристиках выбранного AV; +- OCR изображений и semantic analysis PDF; +- password-protected/encrypted containers: в MVP они запрещаются, если содержимое нельзя полностью проверить; +- DLP/поиск персональных данных, токсичности и запрещённой тематики; +- переход по пользовательской ссылке и анализ удалённой страницы. + +## 4. Общий pipeline + +```mermaid +flowchart TD + postCheck[POST_check] --> auth[Auth_and_DTO] + auth --> kind{content_kind} + kind -->|text| normalizeText[Normalize_text] + normalizeText --> textRules[Text_rules] + textRules --> linkRules[Link_pipeline] + linkRules --> syncVerdict[200_or_403] + kind -->|file| metadata[Metadata_validation] + metadata --> cache{SHA256_cache} + cache -->|hit| cached[Sticky_200_or_403] + cache -->|miss| task[203_and_task] + task --> worker[File_worker] + worker --> objectRead[S3_stream_and_SHA256] + objectRead --> formatCheck[Format_validation] + formatCheck --> avScan[ClamAV_scan] + avScan --> finalVerdict[Persist_sticky_verdict] + finalVerdict --> taskGet[GET_task_200_or_403] +``` + +Приоритет: + +1. service authentication, body/content-type/size и DTO validation; +2. idempotency/fingerprint conflict; +3. нормализация; +4. обязательные проверки для соответствующего `content_kind`; +5. любой deny имеет приоритет над allow; +6. инфраструктурная ошибка не превращается ни в allow, ни в domain deny. + +## 5. Текстовый pipeline + +### 5.1. Нормализация + +Pipeline детерминирован: + +1. принять только JSON UTF-8; +2. заменить `CRLF`/`CR` на `LF`; +3. Unicode normalization `NFKC`; +4. удалить leading Unicode whitespace; +5. сохранить исходный регистр и punctuation для rules; +6. ограничить текст internal DTO до 10 000 Unicode code points; +7. вычислить SHA-256 нормализованного текста для correlation/cache без хранения текста. + +`content_kind=text` требует поле `text`; пустой текст отклоняется upstream `api-backend`. `content_kind=file` допускает пустой `text`; текстовые rules тогда не запускаются. + +### 5.2. Rule engine + +Rules поставляются как статический read-only bundle приложения. Динамический код, regex или rule definitions из запроса запрещены. + +Каждое правило содержит: + +- стабильный `rule_id`; +- `reason_code`; +- severity; +- scope (`text`, `url`, `file_metadata`); +- action (`deny`); +- `rules_version`; +- тестовые positive/negative cases. + +Начальный rule pack MVP: + +- `text.prompt_instruction_override` — конструкции вида «игнорируй предыдущие инструкции» и эквиваленты на поддерживаемых языках; +- `text.prompt_role_impersonation` — попытка обозначить пользовательский фрагмент как system/developer/tool instruction; +- `text.prompt_secret_extraction` — запрос раскрыть system prompt, credentials, tokens или внутренние инструкции; +- `text.active_script` — `