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:
2026-08-01 22:24:59 +03:00
parent 23ecc5d5af
commit 75e9fef037
2 changed files with 34 additions and 10 deletions
+33 -9
View File
@@ -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
View File
@@ -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) — ВАЖНО