Закрыли часть проблем с безопасностью + мелкие починки
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 работает на нестандартном порту или имя внешнего интерфейса известно
|
||||
заранее, передайте параметры:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 по паролю отключены.
|
||||
|
||||
@@ -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)\"}"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user