Files
selfpost/docs/security.md
T
mixeme 1f548dfc7a fix(logtail): keep mail.log in /data and reconcile stuck rows (v1.x closure phase 2)
Move the delivery log from the ephemeral /var/log to /data/log/mail.log so
the lines that resolve a queued send-log row survive a container recreate.
postlogd writes it as postfix, the panel reads it through the selfpost group
(dir 2750, file 0640, normalised every start); backups exclude log/.

Close the residual gap with a queue sweep: rows queued for over two minutes
whose id postqueue -p no longer lists are marked bounced. The sweep waits
until the tailer has read the log to its end and does nothing when the queue
cannot be listed, so a message in flight is never touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:35:27 +03:00

177 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Безопасность
**Что здесь.** (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/<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](../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
не хранит. Триггером сделать шифрование обязательным считать появление
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
дочитывается. Остаток — строки, delivery-строки которых потеряны
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
не может подтвердить, не должна показываться как подтверждённая, а вечный
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
читается. См. [architecture.md](architecture.md) § Log tailer.
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
`/data/log``2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
оставалась состоянием, а не диагностикой.
## 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) закрывает каждую находку одним из двух способов: правка до
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
Третьего варианта («посмотрели и ладно») нет.