Files
selfpost/docs/implementation-plan.md
T
mix c7c0c171f8 docs: restructure plan item A.2 around what each option closes
The options were buried in three prose bullets that mixed mechanism, cost and
coverage, so the one question that matters — which attacker each variant stops
— could not be read off the page. They are now a table: four scenarios by
three variants, with the cost and the caveats underneath and the scenario
prose moved below the table for whoever wants the detail.

No change of substance: same variants, same recommendation (the Origin /
Sec-Fetch-Site check in the phase 14.A middleware), same caveat that a naive
double-submit token leaves the subdomain row open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:20:57 +03:00

159 lines
31 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.
# План реализации: SelfPost
**Статус:** выполненные фазы здесь не описываются — текущее состояние в
[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md)
и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы
для согласования, Фаза 14 и опциональная линия 2.x.x.
**Основа:** [specification.md](specification.md) v1.0.
---
## Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0)
Базовый план 0→11 выполнен и **соответствует ТЗ** (все обязательные пункты 7.6
подтверждены аудитом Фазы 11). Ниже — то, что **выходит за букву ТЗ**, но
заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого
**не является дефектом соответствия**; это осознанные компромиссы и
потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в
2.x / оставляем как есть», принимается пользователем.
### A. Безопасность — hardening сверх обязательного 7.6
1. **Нет security-заголовков ответа** — панель не шлёт `Strict-Transport-Security`, `Content-Security-Policy`, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`. XSS уже закрыт автоэкранированием `html/template` (7.6.7), CSRF — `SameSite=Lax`, но заголовки — дешёвый второй эшелон (clickjacking, downgrade, sniffing). **Решено:** эмитить из панели (единый мидлварь, ~10 строк), не перекладывать на reverse-proxy. Общий принцип: всю сложность стараемся держать в сервисе, а конфигурация reverse-proxy должна оставаться максимально простой, чтобы её было сложно сломать неудачной правкой. **Реализация — см. Фазу 14.A.**
2. **CSRF — только `SameSite=Lax`, без токенов.**
Что есть сейчас: все мутации панели — `POST`, все `GET` — read-only (сверено по таблице маршрутов [internal/web/web.go](../internal/web/web.go): `GET /domains/{id}/delete` — только экран подтверждения, `/logout` отвечает `405` на всё, кроме `POST`), а cookie `selfpost_session` выставлена с `SameSite=Lax` **явно** (побочный плюс: не попадает под послабление «Lax+POST», действующее только для cookie **без** атрибута).
**Варианты и что каждый закрывает:**
| Сценарий | (а) только `Lax` — как сейчас | (б) + проверка `Origin`/`Sec-Fetch-Site` | (в) токен, привязанный к сессии |
|---|---|---|---|
| Чужой сайт (`evil.com`), современный браузер | **закрыт** | закрыт | закрыт |
| Соседний поддомен того же домена | **открыт** | **закрыт** | закрыт |
| Браузер/webview, не знающий `SameSite` | **открыт** | закрыт, только если резать `POST` без `Origin` | **закрыт** |
| XSS в самой панели | открыт | открыт | открыт |
- **(а)** — ноль работы. `SameSite` действует на уровне сайта (eTLD+1), а не origin, поэтому строка 2 остаётся открытой.
- **(б)** — ~15 строк в том же мидлваре, что security-заголовки (Фаза 14.A), шаблоны не трогаются: отклонять `POST`, у которого `Sec-Fetch-Site` не `same-origin`, а при отсутствии заголовка сверять `Origin` с ожидаемым хостом. Это проверка **origin**, а не сайта — потому и закрывает строку 2. Вопрос политики: `POST` без обоих заголовков (ровно клиенты из строки 3) — пропускать ради совместимости или резать.
- **(в)** — мидлварь + скрытое поле в каждой POST-форме (в [шаблонах](../internal/web/templates) их около двух десятков); JS править не нужно, HTMX здесь делает только `hx-get`-поллинг. Не зависит от браузера, поэтому закрывает и строку 3. Только **привязанный к сессии** (синхронизатор или HMAC от идентификатора сессии): наивный double-submit закрывает строки 1 и 3, но не 2 — сосед по registrable domain выставит cookie на родительский домен и продублирует своё же значение в форме.
Строку 4 не закрывает ни один вариант: XSS внутри origin прочитает токен и отправит запрос сам. Против неё работают автоэкранирование `html/template` (7.6.7) и CSP из Фазы 14.A.
**Строки таблицы подробнее.** *Соседний поддомен* — панель живёт на поддомене (`selfpost.example.com`), и любая страница под `example.com` (сайт на CMS, стенд, забытый поддомен с висящим CNAME) считается same-site: её `POST` уйдёт в панель вместе с сессионной cookie. Для типового деплоя SelfPost это главный вектор, а не теоретический. *Старый клиент* — нераспознанный атрибут cookie игнорируется целиком, т.е. поведение откатывается к `SameSite=None`; речь про по-настоящему старые браузеры и webview с замороженным движком, для аудитории «один админ на своём сервере» узко, но не пусто.
**Что даёт успешный CSRF (запись вслепую — ответ атакующему не виден, CORS его не отдаст):**
- **`POST /domains/import` — самый тяжёлый случай.** `multipart/form-data` относится к «простым» content-type, preflight'а нет, а тело можно собрать в JS через `FormData` (подставить значение в `<input type=file>` нельзя, собрать тело руками — можно). Импорт принимает `DomainExport` с приватным DKIM-ключом и **рабочими** SASL-паролями ([internal/domain/transfer.go:19](../internal/domain/transfer.go)), т.е. атакующий заливает домен с **заранее известными ему** учётками и получает валидную отправляющую идентичность на чужом релее — рассылка с IP и репутации жертвы. Единственный сценарий, где слепая запись даёт не порчу, а **доступ**.
- **Порча и тихий отказ:** `POST /domains/{id}/delete` (домен вместе с DKIM-ключом, опубликованная TXT-запись становится мусором), `POST /applications/{aid}/delete`, `POST /applications/{aid}/password` (ротация рвёт отправку живому приложению; новый пароль атакующий не увидит), `POST /domains/{id}/ratelimit` с лимитом в 1 письмо (деградация, которую заметят не сразу).
- **Не проходит:** кража секретов через `POST /backup` и `POST /domains/{id}/export` (ответ кросс-origin не прочитать), захват учётки через `POST /account` (требует текущий пароль), login-CSRF (аккаунт один, для логина нужен его же пароль) и `POST /setup/{token}` (нужен сам секретный токен).
**Вопрос:** рекомендация — **(б)** в составе Фазы 14.A: единственный реалистичный вектор здесь — строка 2, и она закрывается пятнадцатью строками в уже планируемом мидлваре. **(в)** — если нужна гарантия независимо от браузера. **(а)** остаётся честным выбором, если соседние поддомены считаются доверенными.
3. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false``__Host-` требует `Secure`, т.е. только когда secure включён).
4. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. **Решено:** базовый вариант объявления остаётся stdout (по ТЗ), код не меняется — файл `/data/setup-token` (0600) уже пишется ([internal/web/setup.go](../internal/web/setup.go)). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.**
### B. Надёжность и эксплуатация
5. **Сессии только в памяти** — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
6. **Окно потери строк мониторингового лога при ротации** (`copytruncate`, Фаза 10) — несколько строк `mail.log` могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство.
7. **Поведение при незаданном `SELFPOST_HOSTNAME`** — realm SASL и хост setup-ссылки падают в `localhost`. Для реального деплоя hostname обязателен. **Вопрос:** делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.
### C. CI и тесты
8. **Нет интеграционного/e2e-теста в CI.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.
### D. Указатель на объём 2.x
9. **Входящий релей и pluggable-антиспам** вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это **сознательно отложенный объём**, а не забытый.
10. **2FA и несколько администраторов** — вне объёма v1.x (ТЗ этого не требует: один админ). Кандидаты на 2.x, если понадобятся.
---
## Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)
**Статус:** запланирована, не начата.
**Цель:** довести до кода два уже принятых, но пока не реализованных решения из
раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) —
пункты A.1 и A.4 (оставшиеся A.2/A.3 — открытые вопросы без решения).
### A. Security-заголовки ответа (пункт A.1)
Добавить в панель единый middleware, оборачивающий все ответы (кроме, возможно,
уже застриманных HTMX-фрагментов, где это не мешает), выставляющий:
- `Strict-Transport-Security` (только когда `PANEL_COOKIE_SECURE`/TLS включён — по аналогии с `__Host-`/`Secure`-логикой, HSTS на голом HTTP в dev-режиме бессмысленен и может быть вреден);
- `X-Content-Type-Options: nosniff`;
- `X-Frame-Options: DENY` (или `Content-Security-Policy: frame-ancestors 'none'` — эквивалент, дублировать не обязательно);
- `Referrer-Policy: same-origin` (или `no-referrer` — выбрать более строгий, поведение панели не зависит от referrer);
- `Content-Security-Policy` — минимальная политика под текущий фронтенд (inline-стили/скрипты, HTMX): нужно свериться с шаблонами ([internal/web/templates](../internal/web/templates)) на предмет `<script>`/`style="..."`/`onclick` перед тем, как писать политику, чтобы не сломать текущий UI.
**Готово, когда:** все ответы панели содержат перечисленные заголовки (кроме HSTS в dev/non-secure режиме); UI (включая HTMX-фрагменты и polling) продолжает работать без консольных ошибок CSP; `gofmt`/`vet`/`test` зелёные.
**Риски:** слишком строгий CSP может тихо сломать inline-скрипты/стили в существующих шаблонах — проверить вручную в браузере (открыть каждую страницу, проверить консоль на CSP-violations) после реализации, а не полагаться только на юнит-тесты.
**Модель:** Sonnet (небольшой, хорошо специфицированный мидлварь).
### B. Документация про `/data/setup-token` (пункт A.4)
Код уже пишет setup-токен в `/data/setup-token` (0600) в дополнение к stdout
([internal/web/setup.go](../internal/web/setup.go)) — это не требует изменений.
Остаётся только документационная задача:
- В README (раздел про первый запуск/setup-ссылку) добавить абзац: по умолчанию ссылка печатается в stdout контейнера (spec 7.6.1), но токен также лежит в файле `/data/setup-token` внутри смонтированного `/data` — для тех, у кого логи контейнера уезжают в центральный агрегатор и не хочется, чтобы токен там оседал на 10 минут, безопаснее прочитать файл (`docker exec` / примонтированный volume) вместо просмотра логов.
**Готово, когда:** README содержит этот абзац рядом с описанием setup-ссылки.
**Риски:** нет — чисто документация, код не меняется.
**Модель:** Sonnet (документация).
**Зависимости:** нет.
---
## Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0)
Эти фазы **не входят** в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к **релизной линии 2.x.x** и добавлены в дорожную карту как согласуемые расширения. **Реализация — только после явного согласования (ТЗ 12.6):** ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением.
### Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
**Зачем это нужно (сценарии):**
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
**Граница объёма (критично — что это НЕ):**
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
- **ЭТО НЕ (остаётся out of scope, ТЗ 3):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
**Почему как опция/плагин:**
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
**Что делать:**
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell (ТЗ 7.6.24).
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, ТЗ 3 не нарушается (SelfPost не реализует антиспам).
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
**Безопасность (ТЗ 7.6 распространяется полностью):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования.