Закрыли часть проблем с безопасностью + мелкие починки

This commit is contained in:
mi
2026-07-29 18:21:08 +03:00
parent bda3ff39d7
commit 049c45db5c
13 changed files with 716 additions and 339 deletions
+1 -1
View File
@@ -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
+4
View File
@@ -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 = (
+21 -4
View File
@@ -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",
@@ -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)
@@ -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 работает на нестандартном порту или имя внешнего интерфейса известно
заранее, передайте параметры:
+4 -2
View File
@@ -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.
+4 -2
View File
@@ -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 по паролю отключены.
+67 -13
View File
@@ -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 <<EOF
PermitRootLogin no
cat >/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowTcpForwarding no
MaxAuthTries 3
ClientAliveInterval 120
ClientAliveCountMax 2
Port ${SSH_PORT}
EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/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
@@ -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)\"}"
+1 -1
View File
@@ -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}
+23
View File
@@ -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")