Files
selfpost/docs/security.md
T
mixeme e0b754bafe
test / test (push) Has been cancelled
docs: spell out .spbk and .spde extensions
SelfPost backup / SelfPost domain export — in the guide, security notes,
architecture, and Backup/Export panel copy.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:49:43 +03:00

15 KiB
Raw Blame History

Безопасность

Что здесь. (1) Обязательные требования — чеклист, который v1.0 обязан выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D, модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка defence-in-depth — -- перед логином в argv saslpasswd2 (internal/app/sasl.go). (2) Принятые риски — сознательные отступления сверх обязательного, чтобы решение не потерялось.

Hardening сверх обязательного (security-заголовки, проверка origin, cookie __Host- с обнаружением дублей — Фаза 14) закрыт; история — в CHANGELOG.md и git log.

Продуктовые границы: product.md. Устройство as-built: architecture.md.


Обязательные требования

Панель публична из интернета — пункты ниже не опциональны.

Первичная инициализация администратора

  • Одноразовая secret-ссылка /setup/<token>, не env с готовым хэшем пароля.
  • Токен ≥128 бит (crypto/rand); дублируется в /data/setup-token.
  • Срок жизни токена — 10 минут; после истечения или рестарта без завершённой настройки — перегенерация и новый вывод в лог.
  • Rate limiting на /setup/<token> по 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.
  • Расширения: .spbk (SelfPost backup — полный бэкап), .spde (SelfPost domain export — экспорт домена); незашифрованные остаются .tar.gz / .json. Импорт домена определяет шифрование по magic файла, а не по расширению.
  • Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI — только через SELFPOST_BACKUP_PASSWORD или -password-file, никогда аргументом (список процессов читается любым процессом контейнера).
  • Минимальная длина пароля — как у пароля администратора (12): файл лежит offline и подбирается без ограничений по времени.

Принятые риски

Здесь, а не в implementation-plan.md: план — про несделанную работу, принятый риск — решение с условием возврата.

  • POST без Sec-Fetch-Site и без Origin пропускается. Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. Принято сознательно: панель однопользовательская, админ выбирает браузер сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём панель. Ужесточение — одна строка в originAllowed (internal/web/security.go): вернуть false вместо true в ветке «нет обоих заголовков».
  • CSRF-токены, привязанные к сессии, не делаются. Проверка origin закрывает соседний поддомен, но зависит от поведения браузера; токен — нет. Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к вопросу считать появление требования «устойчиво независимо от браузера». От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin панели, отправит запрос сам — против этого работают автоэкранирование html/template и CSP, поэтому шаблоны не должны содержать inline-скриптов и inline-стилей.
  • Шифрование бэкапа и экспорта — опция, а не умолчание. Галочка снята — файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места для хранения пароля, потерял бы возможность сделать бэкап вообще, а безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost не хранит. Триггером сделать шифрование обязательным считать появление второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
  • Строка журнала, оставшаяся без delivery-строк, закрывается как bounced, а не как есть. Риск «вечный queued» снят: mail.log переехал в /data/log/ и переживает пересоздание контейнера, а log-tailer сохраняет позицию чтения (logtail_state, миграция 0003), так что после старта хвост дочитывается. Остаток — строки, delivery-строки которых потеряны безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был удалён): сверка с postqueue -p видит, что письма в очереди нет, и через 2 минуты grace ставит bounced. Если письмо на самом деле ушло, статус окажется ложно-отрицательным. Принято сознательно: доставка, которую панель не может подтвердить, не должна показываться как подтверждённая, а вечный queued не отличим от «висит прямо сейчас». Сверка не срабатывает, пока tailer не дочитал лог до конца, и не трогает ничего, если postqueue не читается. См. architecture.md § Log tailer.
  • Доступ к mail.log из-под непривилегированной панели. Каталог /data/log2750 postfix:selfpost, файл — 0640: пишет postlogd (пользователь postfix), читает панель по общей группе selfpost, миру файл недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не заголовки писем; в бэкап он не попадает (log/ исключён), чтобы выгрузка оставалась состоянием, а не диагностикой.

ADR: CSRF через проверку Origin, без токенов

Контекст. Панель — формы (POST) с cookie-сессией; классическая CSRF- поверхность. Нужен способ отличить запрос со страницы панели от запроса, инициированного сторонним сайтом в браузере залогиненного админа.

Решение. originAllowed в 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 § D, модель Fable) закрывает каждую находку одним из двух способов: правка до тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше. Третьего варианта («посмотрели и ладно») нет.