Files
selfpost/docs/implementation-plan.md
T
mix 3531a6694c docs: spell out the CSRF risk in plan item A.2
The item said SameSite=Lax was "enough for modern browsers" but wrong "on a
downgrade to an old browser or unusual proxies", which named the least likely
scenario and missed the most likely one: SameSite is scoped to the registrable
domain, not the origin. The panel runs on a subdomain, so any page anywhere
under the operator's domain — the CMS on www, a stale CNAME, a neighbouring
service — is same-site and its POST carries the session cookie.

It also said nothing about what a successful CSRF would actually buy. Almost
everything is a blind write the attacker cannot read, except POST
/domains/import: multipart is a CORS-simple content type, and a domain export
carries a DKIM key and working SASL passwords, so an attacker uploads
credentials they already know and gains a sending identity on someone else's
relay. That single endpoint, not the destructive ones, is what sets the bar.

The options now carry their cost and their limits: an Origin/Sec-Fetch-Site
check in the phase 14.A middleware closes the subdomain case for ~15 lines,
while a naive double-submit token does not close it at all, since a same-site
neighbour can write the parent domain's cookie. Route facts, cookie
attributes, the export struct and the absence of any hx-post were checked
against the code rather than assumed.

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

154 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` **явно**, поэтому браузер не приложит её к кросс-сайтовому `POST` — базовый сценарий «злая страница сабмитит форму в панель» закрыт. Побочный плюс явного атрибута: cookie не попадает под послабление «Lax+POST» (двухминутное окно, в котором кросс-сайтовый POST всё же проходит), которое действует только для cookie **без** `SameSite`.
**Где `Lax` не спасает — по убыванию реалистичности:**
- **Сосед по registrable domain.** `SameSite` работает на уровне сайта (eTLD+1), а не origin. Панель живёт на поддомене (`selfpost.example.com`), и **любая** страница под `example.com` — сайт на CMS, стенд, забытый поддомен с висящим CNAME (subdomain takeover), чужой сервис на соседнем хосте — считается same-site: её `POST` уйдёт в панель **вместе с сессионной cookie**, `Lax` этого не заметит. Для типового деплоя SelfPost (панель — поддомен основного домена оператора, где часто живёт ещё и обычный сайт) это главный вектор, а не теоретический.
- **Клиент, не понимающий атрибут.** Нераспознанный атрибут cookie игнорируется целиком, т.е. поведение откатывается к `SameSite=None` — классический CSRF с любой страницы интернета. Речь про по-настоящему старые браузеры и встроенные webview с замороженным движком (in-app-браузеры, киоски, старый Electron). Для аудитории «один админ на своём сервере» узко, но не пусто.
- **XSS в самой панели** обнуляет и `Lax`, и токены (скрипт внутри origin прочитает токен и отправит запрос сам). Это не довод против токенов, а граница их пользы: токен закрывает ровно квадрант «есть плацдарм same-site, но нет XSS в панели».
**Что даёт успешный 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}` (нужен сам секретный токен).
**Варианты и цена:**
- **(а) Оставить как есть.** Ноль работы; сосед по домену и старый webview остаются открытыми.
- **(б) Проверка `Sec-Fetch-Site`/`Origin` в том же мидлваре, что и Фаза 14.A** (~15 строк, шаблоны не трогаются): отклонять `POST`, у которого `Sec-Fetch-Site` не `same-origin`, а при отсутствии заголовка сверять `Origin` с ожидаемым хостом. Это проверка **origin**, а не сайта, поэтому закрывает сценарий с соседним поддоменом на всех современных браузерах. Вопрос политики: как поступать, когда нет ни `Sec-Fetch-*`, ни `Origin` (ровно клиенты из сценария 2) — пропускать ради совместимости или резать.
- **(в) Токен.** Наивный double-submit (значение в cookie + скрытое поле, сравнить) от главного сценария **не защищает**: сосед по registrable domain может выставить cookie на родительский домен, т.е. сам записать туда известное ему значение и продублировать его в форме. Делать — только привязанный к сессии (синхронизатор или HMAC от идентификатора сессии на серверном ключе). Цена: мидлварь + скрытое поле в каждой POST-форме (в [шаблонах](../internal/web/templates) их около двух десятков); JS править не придётся — HTMX здесь делает только `hx-get`-поллинг, POST'ов через него нет.
**Вопрос:** рекомендация — **(б)** в составе Фазы 14.A (дёшево, закрывает самый вероятный сценарий, живёт в том же мидлваре, что security-заголовки); **(в)** — только если держим планку «устоять против плацдарма на соседнем поддомене в старом браузере». Вариант **(а)** остаётся честным, если соседние поддомены считаются доверенными.
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) до кодирования.