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>
This commit is contained in:
2026-08-01 22:16:36 +03:00
parent 69d54b45d9
commit 3531a6694c
+23 -1
View File
@@ -21,7 +21,29 @@
### A. Безопасность — hardening сверх обязательного 7.6 ### 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.** 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), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен. 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` (подставить значение в `<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}` (нужен сам секретный токен).
**Варианты и цена:**
- **(а) Оставить как есть.** Ноль работы; сосед по домену и старый 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 включён). 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.** 4. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. **Решено:** базовый вариант объявления остаётся stdout (по ТЗ), код не меняется — файл `/data/setup-token` (0600) уже пишется ([internal/web/setup.go](../internal/web/setup.go)). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.**