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