Files
selfpost/docs/implementation-plan.md
T
mix c938ed20f8 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

31 KiB
Raw Blame History

План реализации: SelfPost

Статус: выполненные фазы здесь не описываются — текущее состояние в progress.md, история сделанного в CHANGELOG.md и git log. Ниже остаётся только то, что ещё не сделано: открытые вопросы для согласования, Фаза 14 и опциональная линия 2.x.x.

Основа: 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: 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), т.е. атакующий заливает домен с заранее известными ему учётками и получает валидную отправляющую идентичность на чужом релее — рассылка с 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-форме (в шаблонах их около двух десятков); 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). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. Реализация — см. Фазу 14.B.

B. Надёжность и эксплуатация

  1. Сессии только в памяти — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
  2. Окно потери строк мониторингового лога при ротации (copytruncate, Фаза 10) — несколько строк mail.log могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство.
  3. Поведение при незаданном SELFPOST_HOSTNAME — realm SASL и хост setup-ссылки падают в localhost. Для реального деплоя hostname обязателен. Вопрос: делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.

C. CI и тесты

  1. Нет интеграционного/e2e-теста в CI. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.

D. Указатель на объём 2.x

  1. Входящий релей и pluggable-антиспам вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это сознательно отложенный объём, а не забытый.
  2. 2FA и несколько администраторов — вне объёма v1.x (ТЗ этого не требует: один админ). Кандидаты на 2.x, если понадобятся.

Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)

Статус: запланирована, не начата.

Цель: довести до кода два уже принятых, но пока не реализованных решения из раздела A. Безопасность — пункты 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) на предмет <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) — это не требует изменений. Остаётся только документационная задача:

  • В 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) до кодирования.