docs: decide CSRF item A.2 in favour of the origin check
Records variant (b): the panel will check Origin / Sec-Fetch-Site in the same middleware as the security headers, and will not carry CSRF tokens. The coverage table above the decision already says what that buys; what the item was missing is what it does not buy, so both are now written down — the accepted risk (a client sending neither header still gets through, which is exactly the old-browser row) and the two escalation paths with their price, tightening the policy to reject those requests, or session-bound tokens. Phase 14.A grows the implementation rules: which requests are checked, the three-way decision, and the fact that only the host is compared because the panel sits behind a proxy and never sees its own external scheme. The rule depends on r.Host being the external name — all four shipped proxy fragments preserve it (checked), but a proxy that rewrites Host would turn every POST into a 403, so the rejection has to log both sides of the comparison and the container test has to run through a real proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,11 @@
|
|||||||
- **Порча и тихий отказ:** `POST /domains/{id}/delete` (домен вместе с DKIM-ключом, опубликованная TXT-запись становится мусором), `POST /applications/{aid}/delete`, `POST /applications/{aid}/password` (ротация рвёт отправку живому приложению; новый пароль атакующий не увидит), `POST /domains/{id}/ratelimit` с лимитом в 1 письмо (деградация, которую заметят не сразу).
|
- **Порча и тихий отказ:** `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}` (нужен сам секретный токен).
|
- **Не проходит:** кража секретов через `POST /backup` и `POST /domains/{id}/export` (ответ кросс-origin не прочитать), захват учётки через `POST /account` (требует текущий пароль), login-CSRF (аккаунт один, для логина нужен его же пароль) и `POST /setup/{token}` (нужен сам секретный токен).
|
||||||
|
|
||||||
**Вопрос:** рекомендация — **(б)** в составе Фазы 14.A: единственный реалистичный вектор здесь — строка 2, и она закрывается пятнадцатью строками в уже планируемом мидлваре. **(в)** — если нужна гарантия независимо от браузера. **(а)** остаётся честным выбором, если соседние поддомены считаются доверенными.
|
**Решено: вариант (б)** — проверка `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=/`). Префикс `__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.**
|
||||||
|
|
||||||
@@ -73,14 +77,19 @@
|
|||||||
|
|
||||||
**Статус:** запланирована, не начата.
|
**Статус:** запланирована, не начата.
|
||||||
|
|
||||||
**Цель:** довести до кода два уже принятых, но пока не реализованных решения из
|
**Цель:** довести до кода принятые решения из раздела
|
||||||
раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) —
|
[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — пункты
|
||||||
пункты A.1 и A.4 (оставшиеся A.2/A.3 — открытые вопросы без решения).
|
A.1 (заголовки), A.2 (проверка origin) и A.4 (документация). Пункт A.3
|
||||||
|
(`__Host-`) остаётся открытым вопросом без решения.
|
||||||
|
|
||||||
### A. Security-заголовки ответа (пункт A.1)
|
### A. Security-заголовки ответа и проверка origin (пункты A.1 и A.2)
|
||||||
|
|
||||||
Добавить в панель единый middleware, оборачивающий все ответы (кроме, возможно,
|
Оба решения — один и тот же мидлварь, обёрнутый вокруг всего `mux` (а не только
|
||||||
уже застриманных HTMX-фрагментов, где это не мешает), выставляющий:
|
вокруг `authed`, чтобы `POST /login` и `POST /setup/{token}` тоже попали под
|
||||||
|
проверку).
|
||||||
|
|
||||||
|
**A.1 — заголовки.** Выставлять на всех ответах (кроме, возможно, уже
|
||||||
|
застриманных HTMX-фрагментов, где это не мешает):
|
||||||
|
|
||||||
- `Strict-Transport-Security` (только когда `PANEL_COOKIE_SECURE`/TLS включён — по аналогии с `__Host-`/`Secure`-логикой, HSTS на голом HTTP в dev-режиме бессмысленен и может быть вреден);
|
- `Strict-Transport-Security` (только когда `PANEL_COOKIE_SECURE`/TLS включён — по аналогии с `__Host-`/`Secure`-логикой, HSTS на голом HTTP в dev-режиме бессмысленен и может быть вреден);
|
||||||
- `X-Content-Type-Options: nosniff`;
|
- `X-Content-Type-Options: nosniff`;
|
||||||
@@ -88,9 +97,24 @@
|
|||||||
- `Referrer-Policy: same-origin` (или `no-referrer` — выбрать более строгий, поведение панели не зависит от referrer);
|
- `Referrer-Policy: same-origin` (или `no-referrer` — выбрать более строгий, поведение панели не зависит от referrer);
|
||||||
- `Content-Security-Policy` — минимальная политика под текущий фронтенд (inline-стили/скрипты, HTMX): нужно свериться с шаблонами ([internal/web/templates](../internal/web/templates)) на предмет `<script>`/`style="..."`/`onclick` перед тем, как писать политику, чтобы не сломать текущий UI.
|
- `Content-Security-Policy` — минимальная политика под текущий фронтенд (inline-стили/скрипты, HTMX): нужно свериться с шаблонами ([internal/web/templates](../internal/web/templates)) на предмет `<script>`/`style="..."`/`onclick` перед тем, как писать политику, чтобы не сломать текущий UI.
|
||||||
|
|
||||||
**Готово, когда:** все ответы панели содержат перечисленные заголовки (кроме HSTS в dev/non-secure режиме); UI (включая HTMX-фрагменты и polling) продолжает работать без консольных ошибок CSP; `gofmt`/`vet`/`test` зелёные.
|
**A.2 — проверка origin.** Применяется только к небезопасным методам (панель
|
||||||
|
использует один `POST`; `GET`-поллинг HTMX не затрагивается):
|
||||||
|
|
||||||
**Риски:** слишком строгий CSP может тихо сломать inline-скрипты/стили в существующих шаблонах — проверить вручную в браузере (открыть каждую страницу, проверить консоль на CSP-violations) после реализации, а не полагаться только на юнит-тесты.
|
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 (небольшой, хорошо специфицированный мидлварь).
|
**Модель:** Sonnet (небольшой, хорошо специфицированный мидлварь).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX) и Фаза 13 (страница `/status`, DNS-проверки домена). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется.
|
- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX) и Фаза 13 (страница `/status`, DNS-проверки домена). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется.
|
||||||
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (разделы A-D — hardening сверх обязательного 7.6, надёжность, e2e в CI), **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
|
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (разделы A-D — hardening сверх обязательного 7.6, надёжность, e2e в CI), **Фаза 14** — security-заголовки и проверка origin (принят вариант «б» по CSRF: `Origin`/`Sec-Fetch-Site` вместо токенов) + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
|
||||||
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
|
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
|
||||||
|
|
||||||
## Рабочая петля (dev loop) — ВАЖНО
|
## Рабочая петля (dev loop) — ВАЖНО
|
||||||
|
|||||||
Reference in New Issue
Block a user