diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index beb981b..18a408d 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -53,8 +53,14 @@ **Принятый риск (строка 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 включён). -4. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. **Решено:** базовый вариант объявления остаётся stdout (по ТЗ), код не меняется — файл `/data/setup-token` (0600) уже пишется ([internal/web/setup.go](../internal/web/setup.go)). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.** +3. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`) — все требования префикса выполнены, но как договорённость сервера, а не как гарант браузера. + + **Что это открывает.** Тот же противник, что в пункте 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. Надёжность и эксплуатация @@ -78,9 +84,9 @@ **Статус:** запланирована, не начата. **Цель:** довести до кода принятые решения из раздела -[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — пункты -A.1 (заголовки), A.2 (проверка origin) и A.4 (документация). Пункт A.3 -(`__Host-`) остаётся открытым вопросом без решения. +[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — все +четыре пункта: A.1 (заголовки), A.2 (проверка origin), A.3 (cookie) и +A.4 (документация). ### A. Security-заголовки ответа и проверка origin (пункты A.1 и A.2) @@ -118,7 +124,19 @@ A.1 (заголовки), A.2 (проверка origin) и A.4 (документ **Модель:** 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 ([internal/web/setup.go](../internal/web/setup.go)) — это не требует изменений. diff --git a/docs/progress.md b/docs/progress.md index 7417259..687cb2d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -35,7 +35,7 @@ ## Текущее состояние - **Выполнено и принято:** базовый линейный план 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). ## Рабочая петля (dev loop) — ВАЖНО