docs: decide cookie item A.3 — __Host- prefix plus duplicate detection
Records both decisions and, more usefully, what the item was actually about.
The prefix was filed as a free nicety ("мелочь, но бесплатная"), which is why
it sat undecided: nothing said what it prevents. It prevents the same-site
neighbour from the CSRF item using its other lever — setting a Domain-scoped
cookie of the same name. The browser then sends two, r.Cookie returns the
older one, and the admin logs in successfully into an endless login loop. That
is denial of service rather than compromise (no valid token can be forged with
a single account), but it is close to undiagnosable from the panel's side, and
the origin check decided in A.2 does nothing about it — the request comes from
the admin's own origin.
Phase 14 gains section B: the cookie name becomes conditional on CookieSecure,
because a __Host- cookie over plain HTTP is rejected outright and would break
the dev mode silently; logout clears both names; and requireAuth switches to
r.Cookies() so a duplicate is refused and logged instead of silently picked.
The setup-token documentation moves to 14.C.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,14 @@
|
|||||||
**Принятый риск (строка 3):** `POST` без обоих заголовков пропускается, т.е. клиент, не посылающий ни `Sec-Fetch-*`, ни `Origin` — по-настоящему старый браузер или webview с замороженным движком — остаётся уязвим к CSRF с любого сайта. Принято сознательно: панель однопользовательская, админ выбирает браузер сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём панель.
|
**Принятый риск (строка 3):** `POST` без обоих заголовков пропускается, т.е. клиент, не посылающий ни `Sec-Fetch-*`, ни `Origin` — по-настоящему старый браузер или webview с замороженным движком — остаётся уязвим к CSRF с любого сайта. Принято сознательно: панель однопользовательская, админ выбирает браузер сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём панель.
|
||||||
|
|
||||||
**Что можно закрыть при необходимости, в порядке возрастания цены:** (1) ужесточить политику — резать `POST` без обоих заголовков; закрывает строку 3, ценой полной неработоспособности панели в таких клиентах, изменение в одну строку внутри того же мидлваря; (2) вариант (в), токен, привязанный к сессии — закрывает строку 3 без потери совместимости, цена — скрытое поле в ~20 формах; триггером считать появление требования «устойчиво независимо от браузера». Строка 4 (XSS) обоими не закрывается ни при каком раскладе — против неё работают `html/template` и CSP.
|
**Что можно закрыть при необходимости, в порядке возрастания цены:** (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=/`) — все требования префикса выполнены, но как договорённость сервера, а не как гарант браузера.
|
||||||
4. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. **Решено:** базовый вариант объявления остаётся stdout (по ТЗ), код не меняется — файл `/data/setup-token` (0600) уже пишется ([internal/web/setup.go](../internal/web/setup.go)). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.**
|
|
||||||
|
**Что это открывает.** Тот же противник, что в пункте 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.**
|
||||||
|
|
||||||
### B. Надёжность и эксплуатация
|
### B. Надёжность и эксплуатация
|
||||||
|
|
||||||
@@ -78,9 +84,9 @@
|
|||||||
**Статус:** запланирована, не начата.
|
**Статус:** запланирована, не начата.
|
||||||
|
|
||||||
**Цель:** довести до кода принятые решения из раздела
|
**Цель:** довести до кода принятые решения из раздела
|
||||||
[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — пункты
|
[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — все
|
||||||
A.1 (заголовки), A.2 (проверка origin) и A.4 (документация). Пункт A.3
|
четыре пункта: A.1 (заголовки), A.2 (проверка origin), A.3 (cookie) и
|
||||||
(`__Host-`) остаётся открытым вопросом без решения.
|
A.4 (документация).
|
||||||
|
|
||||||
### A. Security-заголовки ответа и проверка origin (пункты A.1 и A.2)
|
### A. Security-заголовки ответа и проверка origin (пункты A.1 и A.2)
|
||||||
|
|
||||||
@@ -118,7 +124,19 @@ A.1 (заголовки), A.2 (проверка origin) и A.4 (документ
|
|||||||
|
|
||||||
**Модель:** Sonnet (небольшой, хорошо специфицированный мидлварь).
|
**Модель:** Sonnet (небольшой, хорошо специфицированный мидлварь).
|
||||||
|
|
||||||
### B. Документация про `/data/setup-token` (пункт A.4)
|
### 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
|
Код уже пишет setup-токен в `/data/setup-token` (0600) в дополнение к stdout
|
||||||
([internal/web/setup.go](../internal/web/setup.go)) — это не требует изменений.
|
([internal/web/setup.go](../internal/web/setup.go)) — это не требует изменений.
|
||||||
|
|||||||
+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-заголовки и проверка origin (принят вариант «б» по CSRF: `Origin`/`Sec-Fetch-Site` вместо токенов) + документация про `/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` вместо токенов), cookie `__Host-` + отказ при дублях, документация про `/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