Files
selfpost/docs/implementation-plan.md
T
mix f9026c49cb docs: decide cookie item A.3 — __Host- prefix plus duplicate detection
Records both decisions and, more usefully, what the item was actually about.
The prefix was filed as a free nicety ("мелочь, но бесплатная"), which is why
it sat undecided: nothing said what it prevents. It prevents the same-site
neighbour from the CSRF item using its other lever — setting a Domain-scoped
cookie of the same name. The browser then sends two, r.Cookie returns the
older one, and the admin logs in successfully into an endless login loop. That
is denial of service rather than compromise (no valid token can be forged with
a single account), but it is close to undiagnosable from the panel's side, and
the origin check decided in A.2 does nothing about it — the request comes from
the admin's own origin.

Phase 14 gains section B: the cookie name becomes conditional on CookieSecure,
because a __Host- cookie over plain HTTP is rejected outright and would break
the dev mode silently; logout clears both names; and requireAuth switches to
r.Cookies() so a duplicate is refused and logged instead of silently picked.
The setup-token documentation moves to 14.C.

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

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

    Решено: вариант (б) — проверка Origin/Sec-Fetch-Site в том же мидлваре, что и security-заголовки. Токены (в) не делаем. Реализация — см. Фазу 14.A.

    Принятый риск (строка 3): POST без обоих заголовков пропускается, т.е. клиент, не посылающий ни Sec-Fetch-*, ни Origin — по-настоящему старый браузер или webview с замороженным движком — остаётся уязвим к CSRF с любого сайта. Принято сознательно: панель однопользовательская, админ выбирает браузер сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём панель.

    Что можно закрыть при необходимости, в порядке возрастания цены: (1) ужесточить политику — резать POST без обоих заголовков; закрывает строку 3, ценой полной неработоспособности панели в таких клиентах, изменение в одну строку внутри того же мидлваря; (2) вариант (в), токен, привязанный к сессии — закрывает строку 3 без потери совместимости, цена — скрытое поле в ~20 формах; триггером считать появление требования «устойчиво независимо от браузера». Строка 4 (XSS) обоими не закрывается ни при каком раскладе — против неё работают html/template и CSP.

  3. Cookie без префикса __Host-. Сейчас selfpost_session (Secure/HttpOnly/SameSite=Lax/Path=/) — все требования префикса выполнены, но как договорённость сервера, а не как гарант браузера.

    Что это открывает. Тот же противник, что в пункте 2 (плацдарм на соседнем поддомене), получает второй рычаг, не связанный с CSRF: evil.example.com ставит selfpost_session=мусор; Domain=example.com; Secure, браузер шлёт в панель обе cookie с одним именем, а r.Cookie() (internal/web/middleware.go:17) возвращает первую — по RFC 6265 это более ранняя по времени создания, т.е. чужая. Админ логинится, панель ставит свою host-only cookie, requireAuth снова читает чужую — вечный цикл логина. Это отказ в обслуживании, не компрометация: валидный токен подделать нельзя (аккаунт один, токен выдаётся только после аутентификации), session fixation неприменима. Но диагностика недружелюбная: логин отвечает «успех», две одноимённые записи в devtools легко не заметить, а чистка cookie самой панели не помогает — надо чистить родительский домен. Проверка origin из пункта 2 здесь не помогает: запрос делает сам админ со своего origin, отравлена только cookie.

    Решено: делаем оба — префикс __Host- и обнаружение дублей. Реализация — см. Фазу 14.B. Префикс атаку предотвращает (браузер не примет одноимённую cookie с Domain), проверка дублей делает её видимой в логе — в том числе в dev-режиме, где префикса нет.

    Чего это не даёт: ни защиты от XSS, ни конфиденциальности, ни замены пункту 2; сосед по домену по-прежнему может ставить cookie с другими именами. Закрывается ровно перезапись сессионной cookie. Если панель живёт на отдельном registrable domain, а не на поддомене рабочего, весь класс отсутствует и мера ничего не добавляет.

  4. Setup-ссылка печатается в stdout контейнера. По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Решено: базовый вариант объявления остаётся stdout (по ТЗ), код не меняется — файл /data/setup-token (0600) уже пишется (internal/web/setup.go). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. Реализация — см. Фазу 14.C.

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.2 (проверка origin), A.3 (cookie) и A.4 (документация).

A. Security-заголовки ответа и проверка origin (пункты A.1 и A.2)

Оба решения — один и тот же мидлварь, обёрнутый вокруг всего mux (а не только вокруг authed, чтобы POST /login и POST /setup/{token} тоже попали под проверку).

A.1 — заголовки. Выставлять на всех ответах (кроме, возможно, уже застриманных 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.

A.2 — проверка origin. Применяется только к небезопасным методам (панель использует один POST; GET-поллинг HTMX не затрагивается):

  1. Есть Sec-Fetch-Site → пропускать только same-origin; same-site, cross-site и none403. Именно это и закрывает соседний поддомен: SameSite считает его своим, Sec-Fetch-Site — нет.
  2. Заголовка нет, но есть Origin → сравнить его хост с r.Host, при несовпадении 403. Схему не сверять: панель за прокси говорит по HTTP и своей внешней схемы не знает, а в Origin придёт https://.
  3. Нет обоих → пропустить. Это принятый риск из пункта A.2; ужесточение — заменить этот случай на 403.

Зависимость от прокси (важно): правило 2 верно только пока r.Host — это внешнее имя. Все четыре поставляемых фрагмента его сохраняют (Apache — ProxyPreserveHost On, nginx — proxy_set_header Host $host, Caddy и Traefik — по умолчанию), но чужой прокси, переписывающий Host, превратит каждый POST в 403. Поэтому отказ обязан писать в лог обе стороны сравнения (Origin и Host) — иначе симптом выглядит как «панель перестала сохранять формы», и причина ищется часами.

Готово, когда: все ответы панели содержат перечисленные заголовки (кроме HSTS в dev/non-secure режиме); UI (включая HTMX-фрагменты и polling) продолжает работать без консольных ошибок CSP; POST с чужого origin получает 403 с диагностируемой строкой в логе, обычная работа панели (все формы, включая загрузку файла на /domains/import) не меняется; юнит-тест мидлваря покрывает матрицу «нет заголовков / same-origin / same-site / cross-site / Origin совпадает / не совпадает»; gofmt/vet/test зелёные.

Риски: слишком строгий CSP может тихо сломать inline-скрипты/стили в существующих шаблонах — проверить вручную в браузере (открыть каждую страницу, проверить консоль на CSP-violations) после реализации, а не полагаться только на юнит-тесты. Проверка origin рискует ровно одним: ошибка в сравнении хоста запирает админа из всех мутаций сразу — проверять в контейнере через реальный прокси, а не только юнит-тестом.

Модель: Sonnet (небольшой, хорошо специфицированный мидлварь).

  • Имя cookie становится вычисляемым: __Host-selfpost_session при CookieSecure, прежнее selfpost_session иначе. Условие обязательно: в dev по plain HTTP браузер отвергнет префиксную cookie целиком, и панель перестанет логинить. Сейчас имя — const sessionCookie (internal/web/handlers_auth.go:13), пять мест использования, литерала нет ни в тестах, ни в шаблонах, ни в JS (cookie HttpOnly), так что правка локальна для internal/web.
  • На logout гасить оба имени, иначе после апгрейда в браузере до конца сессии болтается старая cookie.
  • Обнаружение дублей в requireAuth: читать r.Cookies() вместо r.Cookie() (последний молча берёт первую подходящую) и, если одноимённых больше одной, считать запрос неаутентифицированным и писать строку в лог. Это единственное место, где перезапись cookie вообще становится видимой, и оно работает в dev-режиме, где префикса нет.

Готово, когда: в проде cookie называется __Host-selfpost_session, при PANEL_COOKIE_SECURE=false — прежним именем и панель работает по HTTP; юнит-тесты покрывают выбор имени по обеим веткам CookieSecure и отказ при двух одноимённых cookie; в CHANGELOG отмечено, что апгрейд разлогинивает админа один раз.

Риски: ошибка в условии (префиксное имя при CookieSecure=false) ломает dev-режим тихо — браузер просто отбрасывает Set-Cookie, логин выглядит как «пароль не подошёл». Поэтому тест именно на эту ветку, а не только на прод-вариант. Разлогин при апгрейде ничего не стоит: сессии и так в памяти и умирают при рестарте (пункт B.5).

Модель: Sonnet.

C. Документация про /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) до кодирования.