docs: document /data/setup-token and close phase 14

14.C needed no code: the setup link is already mirrored to /data/setup-token
at 0600 and removed once setup completes. What was missing is the reason to
prefer it — a deployment whose container logs ship to a central aggregator
otherwise leaves a live bearer token in that pipeline for ten minutes, and in
whatever retains it afterwards.

The reverse-proxy section gains the one requirement 14.A introduces: pass the
original Host header through. Everything else about security stays the
proxy's non-problem, which is the point of emitting the headers from the
panel.

Phase 14 leaves the plan (the file describes only unfinished work), but its
section A keeps what was deliberately left open: the accepted risk for clients
sending neither Sec-Fetch-Site nor Origin, the decision not to add
session-bound CSRF tokens and what would justify revisiting it, and the fact
that XSS inside the panel's own origin is answered by html/template and the
CSP rather than by either of those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:02:26 +03:00
parent c2edc586ef
commit c8abec376a
4 changed files with 86 additions and 121 deletions
+28 -116
View File
@@ -3,7 +3,7 @@
**Статус:** выполненные фазы здесь не описываются — текущее состояние в
[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md)
и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы
для согласования, Фаза 14 и опциональная линия 2.x.x.
для согласования и опциональная линия 2.x.x.
**Основа:** [specification.md](specification.md) v1.0.
@@ -20,47 +20,34 @@
### 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`, без токенов.**
Все четыре пункта раздела — security-заголовки, проверка origin, cookie
`__Host-` с обнаружением дублей и документация про `/data/setup-token`
**реализованы (Фаза 14)**; что именно сделано, см. [CHANGELOG.md](../CHANGELOG.md)
и `git log`. Здесь остаётся только то, что закрыто **сознательно не было**,
чтобы это не потерялось:
Что есть сейчас: все мутации панели — `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` (подставить значение в `<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}` (нужен сам секретный токен).
**Решено: вариант (б)** — проверка `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](../internal/web/middleware.go)) возвращает первую — по 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](../internal/web/setup.go)). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.C.**
- **Принятый риск: `POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
Принято сознательно: панель однопользовательская, админ выбирает браузер
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
панель. Ужесточение — одна строка в `originAllowed`
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
вместо `true` в ветке «нет обоих заголовков».
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
вопросу считать появление требования «устойчиво независимо от браузера».
- **XSS внутри самой панели** не закрывается ни проверкой origin, ни токенами:
код, исполняющийся в origin панели, отправит запрос сам. Против него
работают автоэкранирование `html/template` (7.6.7) и CSP — поэтому шаблоны
не должны содержать inline-скриптов и inline-стилей; это закреплено
тестом-стражем в [internal/web/templates_test.go](../internal/web/templates_test.go),
а не только договорённостью.
- **Требование к развёртыванию, появившееся вместе с проверкой origin:**
reverse-proxy обязан передавать исходный заголовок `Host`. Все четыре
поставляемых фрагмента это делают; чужой прокси, переписывающий `Host`,
превратит каждый `POST` в `403` (в лог пишутся обе сравниваемые стороны).
### B. Надёжность и эксплуатация
@@ -79,81 +66,6 @@
---
## Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)
**Статус:** запланирована, не начата.
**Цель:** довести до кода принятые решения из раздела
[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — все
четыре пункта: 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](../internal/web/templates)) на предмет `<script>`/`style="..."`/`onclick` перед тем, как писать политику, чтобы не сломать текущий UI.
**A.2 — проверка origin.** Применяется только к небезопасным методам (панель
использует один `POST`; `GET`-поллинг HTMX не затрагивается):
1. Есть `Sec-Fetch-Site` → пропускать только `same-origin`; `same-site`, `cross-site` и `none``403`. Именно это и закрывает соседний поддомен: `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 (небольшой, хорошо специфицированный мидлварь).
### B. Cookie: префикс `__Host-` и обнаружение дублей (пункт A.3)
- **Имя cookie становится вычисляемым:** `__Host-selfpost_session` при `CookieSecure`, прежнее `selfpost_session` иначе. Условие обязательно: в dev по plain HTTP браузер отвергнет префиксную cookie целиком, и панель перестанет логинить. Сейчас имя — `const sessionCookie` ([internal/web/handlers_auth.go:13](../internal/web/handlers_auth.go)), пять мест использования, литерала нет ни в тестах, ни в шаблонах, ни в 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](../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), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением.