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

**Что здесь.** (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` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
  `.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
  незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
  шифрование по magic файла, а не по расширению.
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
  только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
  аргументом (список процессов читается любым процессом контейнера).
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
  offline и подбирается без ограничений по времени.

---

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

Принятый риск — решение с условием возврата, а не отложенная задача из
дорожной карты.

- **`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 с замороженным движком), остаётся
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
панель в таком клиенте ценой узкой остаточной поверхности.

**Пересмотр, если:** появится требование защиты, не зависящей от поведения
браузера, или панель станет многопользовательской.

## Как этот список пополняется

Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
Третьего варианта («посмотрели и ладно») нет.
