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>
15 KiB
Безопасность
Что здесь. (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(полный бэкап),.spde(экспорт домена); незашифрованные остаются.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/log—2750 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) закрывает каждую находку одним из двух способов: правка до тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше. Третьего варианта («посмотрели и ладно») нет.