# Безопасность **Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D, модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка defence-in-depth — `--` перед логином в argv `saslpasswd2` ([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** — сознательные отступления сверх обязательного, чтобы решение не потерялось. Hardening сверх обязательного (security-заголовки, проверка origin, cookie `__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в [CHANGELOG.md](../CHANGELOG.md) и `git log`. Продуктовые границы: [product.md](product.md). Устройство as-built: [architecture.md](architecture.md). --- ## Обязательные требования Панель публична из интернета — пункты ниже **не опциональны**. ### Первичная инициализация администратора - Одноразовая secret-ссылка `/setup/`, **не** env с готовым хэшем пароля. - Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`. - Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой настройки — перегенерация и новый вывод в лог. - Rate limiting на `/setup/` по IP, отдельно от логина. - Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`). - Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки). - После создания администратора — токен навсегда недействителен, `/setup/*` → 404. - Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5. - `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**. ### SASL-пароли приложений - Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**. - В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только перевыпуск. ### Ввод и конфигурация - Серверная валидация email/доменов (whitelist символов); клиентская не считается защитой. - Режим «список адресов» — каждый адрес принадлежит домену приложения до записи. - `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского ввода; аргументы отдельными элементами. - Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix). ### Аутентификация и сессии - Rate limiting на логин (по IP, с блокировкой/задержкой). - Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`. - Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle (`PANEL_SESSION_IDLE_DAYS`). ### Вывод и процесс - Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы). - Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через группу `selfpost` и минимальные права. ### Почтовый тракт (связанное с безопасностью) - **Не open relay** — только SASL; `reject_unauth_destination`; `smtpd_sender_login_maps` + `reject_sender_login_mismatch`. - TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`). - `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For` при rate-limit логина; пусто = XFF игнорируется. ### Резервная копия и экспорт домена - Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым текстом (иначе перенос без пересоздания кредов невозможен). - Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый аутентифицирован заголовком, номером и флагом конца потока — обрезанный или подменённый файл не открывается вместо тихого восстановления «хвоста». Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go). - Расширения: `.spbk` (полный бэкап), `.spde` (экспорт домена); незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет шифрование по magic файла, а не по расширению. - Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI — только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда аргументом (список процессов читается любым процессом контейнера). - Минимальная длина пароля — как у пароля администратора (12): файл лежит offline и подбирается без ограничений по времени. --- ## Принятые риски Здесь, а не в [implementation-plan.md](implementation-plan.md): план — про несделанную работу, принятый риск — решение с условием возврата. - **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.** Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. Принято сознательно: панель однопользовательская, админ выбирает браузер сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём панель. Ужесточение — одна строка в `originAllowed` ([internal/web/security.go](../internal/web/security.go)): вернуть `false` вместо `true` в ветке «нет обоих заголовков». - **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin закрывает соседний поддомен, но зависит от поведения браузера; токен — нет. Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к вопросу считать появление требования «устойчиво независимо от браузера». От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin панели, отправит запрос сам — против этого работают автоэкранирование `html/template` и CSP, поэтому шаблоны не должны содержать inline-скриптов и inline-стилей. - **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята — файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места для хранения пароля, потерял бы возможность сделать бэкап вообще, а безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost не хранит. Триггером сделать шифрование обязательным считать появление второго администратора (тогда «кто скачал» перестаёт быть одним человеком). - **Send-log может навсегда остаться `queued` после рестарта панели или пересоздания контейнера.** Log-tailer стартует с конца `mail.log`; файл не в `/data` и теряется при recreate. Rename-ротация это не лечит. См. [architecture.md](architecture.md) § Log tailer — known gaps. ## ADR: CSRF через проверку Origin, без токенов **Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF- поверхность. Нужен способ отличить запрос со страницы панели от запроса, инициированного сторонним сайтом в браузере залогиненного админа. **Решение.** `originAllowed` в [internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site` (если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к сессии и встроенных в формы, нет. **Почему не токены.** Панель однопользовательская (один администратор на инстанс) — модель угроз не включает межпользовательский CSRF внутри самой панели, только внешний сайт, заставляющий браузер админа отправить запрос. Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал бы скрытого поля примерно в двух десятках форм и синхронизации при каждой новой форме, а от XSS внутри панели токен всё равно не защищает — код, исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия обороны, не CSRF-токен. **Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin` (по-настоящему старый браузер или webview с замороженным движком), остаётся уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать панель в таком клиенте ценой узкой остаточной поверхности. **Пересмотр, если:** появится требование защиты, не зависящей от поведения браузера, или панель станет многопользовательской. ## Как этот список пополняется Предрелизная проверка на уязвимости ([implementation-plan.md](implementation-plan.md) § D, модель Fable) закрывает каждую находку одним из двух способов: правка до тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше. Третьего варианта («посмотрели и ладно») нет.