release: 0.5.0: Pre-1.0 security review, domain-admin role, doc consolidation
A bounce could be recorded as a successful delivery. The log-tailer's Fixed: - A bounce could be recorded as a successful delivery. The log-tailer's - Log-tailer resumes where it stopped instead of jumping to end-of-file on - Level-2 rate limit no longer overshoots under concurrency: messages that Changed: - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg - Code comments no longer cite the archived specification. References like - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the - `docs/architecture.md` gained a *Code layers* section: a diagram of - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale … (1 more in CHANGELOG) Security: - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass - `saslpasswd2` argv: the application login is now passed after a `--` Added: - Optional password encryption for the two secret-bearing downloads (plan - Domain import accepts an encrypted export: the envelope is detected by its - `selfpost-backup` writes encrypted archives and reads them back: - `TestParseDelivery` covers the exotic mail.log shapes the review asked for - docs: README *Encrypting a backup or export*; `docs/security.md` § … (14 more in CHANGELOG) Removed: - docs: `security.md` — accepted risk «restore old backup revives session rows» Changed: - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release - docs: `architecture.md` — sessions (SQLite, idle renew, password change), - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. … (10 more in CHANGELOG) Full detail: CHANGELOG.md (0.5.0).
This commit is contained in:
+140
-16
@@ -1,19 +1,101 @@
|
||||
# Безопасность: принятые риски
|
||||
# Безопасность
|
||||
|
||||
**Что здесь.** Обязательные требования к безопасности — [ТЗ 7.6](specification.md);
|
||||
соответствие им проверено полным аудитом на v1.0 и здесь не пересказывается.
|
||||
Hardening сверх обязательного 7.6 (security-заголовки, проверка origin, cookie
|
||||
`__Host-` с обнаружением дублей — Фаза 14) тоже закрыт, история — в
|
||||
[CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот документ держит третью
|
||||
категорию: то, что закрыто **сознательно не было**, чтобы решение не потерялось
|
||||
и не переоткрывалось заново.
|
||||
**Что здесь.** (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) **Принятые риски** —
|
||||
сознательные отступления сверх обязательного, чтобы решение не потерялось.
|
||||
|
||||
Здесь, а не в [implementation-plan.md](implementation-plan.md), потому что план —
|
||||
только про несделанную работу, а принятый риск — не работа, а решение: у него нет
|
||||
состояния «в очереди», есть условие, при котором к нему возвращаются.
|
||||
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 с любого сайта.
|
||||
@@ -28,12 +110,54 @@ Hardening сверх обязательного 7.6 (security-заголовки
|
||||
вопросу считать появление требования «устойчиво независимо от браузера».
|
||||
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
|
||||
панели, отправит запрос сам — против этого работают автоэкранирование
|
||||
`html/template` (7.6.7) и CSP, поэтому шаблоны не должны содержать
|
||||
`html/template` и CSP, поэтому шаблоны не должны содержать
|
||||
inline-скриптов и inline-стилей.
|
||||
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
|
||||
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
|
||||
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
|
||||
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
|
||||
не хранит. Триггером сделать шифрование обязательным считать появление
|
||||
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
|
||||
- **Send-log может навсегда остаться `queued` после пересоздания контейнера.**
|
||||
Рестарт панели это больше не вызывает: log-tailer сохраняет позицию чтения
|
||||
(`logtail_state`, миграция `0003`) и после старта дочитывает хвост, написанный
|
||||
пока панель лежала. Но `mail.log` живёт не в `/data` и теряется вместе с
|
||||
контейнером — строки, чьи delivery-строки были только в нём, останутся
|
||||
`queued`. См. [architecture.md](architecture.md) § Log tailer.
|
||||
|
||||
## 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 с замороженным движком), остаётся
|
||||
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
|
||||
панель в таком клиенте ценой узкой остаточной поверхности.
|
||||
|
||||
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
|
||||
браузера, или панель станет многопользовательской.
|
||||
|
||||
## Как этот список пополняется
|
||||
|
||||
Предрелизная проверка на уязвимости (пункт **D.5** плана, модель Fable) закрывает
|
||||
каждую находку одним из двух способов: правка до тега — либо запись сюда, с
|
||||
обоснованием и условием возврата, как у двух пунктов выше. Третьего варианта
|
||||
(«посмотрели и ладно») нет.
|
||||
Предрелизная проверка на уязвимости ([implementation-plan.md](implementation-plan.md)
|
||||
§ D, модель Fable) закрывает каждую находку одним из двух способов: правка до
|
||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
||||
Третьего варианта («посмотрели и ладно») нет.
|
||||
|
||||
Reference in New Issue
Block a user