Я не security-инженер. Но за время работы над сайтом понял: безопасность — это не паранойя и не один большой аудит. Это гигиена, которую делаешь регулярно.
С чего всё началось
Сайт начинался как витрина. Потом появилась админка, вход, роли, статьи, Telegram, 2FA. Чем больше данных и доступа, тем меньше хочется надеяться на «ну меня же никто не знает».
Сейчас в проекте есть:
- вход по паролю и сессиям;
- реестр сессий и «Выйти везде»;
- 2FA через Telegram;
- TOTP и резервные коды;
- уведомления о входе;
- ограничение попыток;
- CSP, HSTS,
nosniff,referrer-policy; - блокировка
.env,*.db,.gitна уровне Caddy; - бэкапы без секретов.
Это не «идеальная защита». Но это уже не «сайт на доверии».
Секреты: .env, который попал в архив
Самая показательная история — даже не про код, а про бэкапы.
Долгое время архивы сайта делались «на всякий случай» и включали всё: код, базу, opt/caddy, etc/php. Вместе с этим в архив попадал .env — с ключами Telegram и другими доступами.
Потом выяснилось, что архивы лежат в git-репозитории. Не публичном, но всё равно. Если репозиторий когда-нибудь утечёт или кто-то получит доступ, ключи уйдут вместе с ним.
С бэкапа №117 .env исключён из архива. У более старых бэкапов (№116 и раньше) он внутри. Если репозиторий мог утечь — ключи Telegram стоит ротировать. Это неприятно, но честно.
Урок: бэкап — это тоже публикация. Если архивы уходят в git, относись к ним как к коду, который может стать публичным.
Сейчас .env живёт с правами 600, а Caddy отдаёт 404 на .env, *.db и .git. Но это защита от случайного запроса, а не от того, что файл уже лежит в архиве.
Dev и prod: где доверие ломается
21.10.2026 старый скрипт миграции скопировал базу с dev на prod. Формально — «синхронизация». Фактически — потеря контента: репорты, проекты, участники.
Прод восстановили из архива, dev-базу синхронизировали. Но это был хороший урок: код и контент — разные вещи. Код можно перезаписать. Контент — нет.
Теперь migrate-to-prod.sh по умолчанию переносит только код. Для базы — --with-db, с предварительным сравнением db-compare.php. Для контента — --with-content. Перед любым изменением — бэкап.
Урок: если скрипт может что-то перезаписать, он должен спросить. «Удобно» и «безопасно» — не всегда одно и то же.
Сессии: «Выйти везде» и реестр
Пароль — это вход. Но после входа нужно понимать, где ты вошёл.
В проекте есть таблица user_sessions: токен, IP, user-agent, время создания, последняя активность, флаг отзыва. Сессия привязана к токену, а не только к cookie.
Что это даёт:
- можно посмотреть историю входов;
- можно увидеть, какие устройства активны;
- можно нажать «Выйти везде» и отозвать все сессии;
- при смене пароля старые сессии завершаются;
- при подозрительном входе можно быстро закрыть доступ.
Ещё есть таймауты:
- idle: 30 минут для админа, 2 часа для участника;
- absolute: 12 часов для админа, 7 дней для участника.
То есть даже если человек не выходит, сессия не живёт вечно. Это неудобно ровно до первого инцидента. Потом — нормально.
Урок: сессия — это ресурс с сроком жизни. Если у неё нет срока, её срок определяет злоумышленник.
2FA: Telegram, потом TOTP
Сначала появилась 2FA через Telegram: при входе бот присылает подтверждение, пользователь нажимает «Подтвердить» или «Это не я».
Это удобно: Telegram уже привязан, ничего не надо ставить. Но есть зависимость: если Telegram недоступен или аккаунт потерян, вход усложняется. Поэтому появились резервные коды.
25.10.2026 добавил TOTP: обычный код из приложения-аутентификатора. Он работает без Telegram и без интернета (после настройки). В профиле можно:
- включить TOTP;
- посмотреть секрет и
otpauth://-ссылку; - подтвердить кодом;
- получить 8 резервных кодов;
- отключить или перегенерировать коды.
Резервные коды хранятся хэшами, а не в открытом виде. Каждый код одноразовый. Если TOTP включён, при входе он заменяет подтверждение через Telegram: не надо проходить два фактора сразу.
У админа есть сброс TOTP в карточке участника — на случай потерянного телефона.
Урок: второй фактор должен иметь план Б. Иначе он превращается в «второй способ потерять доступ».
Уведомления о входе
При входе приходит сообщение: время, IP, устройство и кнопка «Сменить пароль».
Это не защита сама по себе. Но это быстрый сигнал: «это был не я». Если человек видит вход, которого не было, он может сразу сменить пароль и выйти везде.
Важно не превращать уведомления в шум. Если писать «вход» на каждый запрос, их перестанут читать. Сейчас — только на вход и только при привязке.
Ограничение попыток
Пароль можно подбирать. Поэтому есть лимиты:
- 5 неудачных попыток с одного IP за 15 минут;
- 10 неудачных попыток по одному логину за 60 минут;
- отдельный лимит на репорты — 5 за 10 минут.
Лимит — это компромисс. Слишком строгий — блокирует живых людей. Слишком мягкий — не мешает подбору. Текущие значения не идеальны, но они хотя бы есть.
Урок: rate limit — не «серебряная пуля», а тормоз. Он не делает пароль надёжным, но делает перебор дороже.
CSP, SRI и внешние скрипты
Сайт использует несколько внешних библиотек: marked, DOMPurify, vanilla-tilt, model-viewer. Раньше они подключались с CDN без проверки целостности.
Теперь:
- версии закреплены;
- у скриптов есть
integrityиcrossorigin; html2canvasдля генератора обложек лежит локально;- CSP ограничивает источники скриптов.
Это не мешает CDN скомпрометировать файл, но если файл изменится, браузер его не выполнит.
Ещё один урок — SSRF. RSS-импорт принимал произвольный URL и делал запрос с сервера. Теоретически так можно было достучаться до внутренних адресов. Теперь запросы идут через safe_http_get: только http/https, блок private/loopback/link-local, DNS pinning, лимит размера и времени.
Урок: любой URL от пользователя — это запрос от твоего сервера. Относись к нему как к чужому коду.
Что делать, когда страшно
У меня есть короткий чек-лист на случай «кажется, что-то не так»:
- Не паниковать и не удалять логи. Сначала сохранить состояние.
- Проверить аудит и историю входов. Кто, когда, с какого IP.
- Сменить пароль и нажать «Выйти везде».
- Отозвать ключи и токены, если есть подозрение на утечку.
- Проверить бэкапы. Есть ли свежий, разворачивается ли.
- Зафиксировать, что произошло. Чтобы не повторить.
- Только потом чинить код.
Самое плохое в инциденте — не сам инцидент, а суета, в которой теряются логи и делаются необратимые действия.
Вместо вывода
Безопасность не видна, когда всё хорошо. Её замечают, когда что-то сломалось.
Я не пытаюсь сделать из сайта банк. Но базовые вещи — секреты вне архива, сессии с таймаутом, второй фактор, лимиты, бэкапы, проверка URL — обязательны. Они не «потом». Они — часть продукта.
И да, самое сложное — не включить 2FA. Самое сложное — не потерять доступ, когда телефон остался дома. Поэтому резервные коды и админ-сброс важны не меньше, чем сам второй фактор.
