diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 5509a1a..82fff1e 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -23,13 +23,24 @@ 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`. + Что есть сейчас: все мутации панели — `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` не спасает — по убыванию реалистичности:** + **Варианты и что каждый закрывает:** - - **Сосед по 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 в панели». + | Сценарий | (а) только `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 его не отдаст):** @@ -37,13 +48,7 @@ - **Порча и тихий отказ:** `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-заголовки); **(в)** — только если держим планку «устоять против плацдарма на соседнем поддомене в старом браузере». Вариант **(а)** остаётся честным, если соседние поддомены считаются доверенными. + **Вопрос:** рекомендация — **(б)** в составе Фазы 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.**