# План реализации: 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` (подставить значение в `` нельзя, собрать тело руками — можно). Импорт принимает `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)) на предмет `