From c8abec376af9b3e487e1c1b7fd1f8d0b05305430 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sat, 1 Aug 2026 23:02:26 +0300 Subject: [PATCH] docs: document /data/setup-token and close phase 14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14.C needed no code: the setup link is already mirrored to /data/setup-token at 0600 and removed once setup completes. What was missing is the reason to prefer it — a deployment whose container logs ship to a central aggregator otherwise leaves a live bearer token in that pipeline for ten minutes, and in whatever retains it afterwards. The reverse-proxy section gains the one requirement 14.A introduces: pass the original Host header through. Everything else about security stays the proxy's non-problem, which is the point of emitting the headers from the panel. Phase 14 leaves the plan (the file describes only unfinished work), but its section A keeps what was deliberately left open: the accepted risk for clients sending neither Sec-Fetch-Site nor Origin, the decision not to add session-bound CSRF tokens and what would justify revisiting it, and the fact that XSS inside the panel's own origin is answered by html/template and the CSP rather than by either of those. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 ++++++++ README.md | 22 ++++++ docs/implementation-plan.md | 144 +++++++----------------------------- docs/progress.md | 10 +-- 4 files changed, 86 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a3542..920868c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +- panel: security headers on every response — `Content-Security-Policy`, + `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and + `Strict-Transport-Security` where the deployment is HTTPS-only. They are + emitted by the panel itself, so the reverse proxy still needs no security + configuration of its own. +- panel: state-changing requests are now checked against the panel's own + origin (`Sec-Fetch-Site`, falling back to `Origin` vs `Host`). This closes + cross-site request forgery from a *neighbouring host on the same domain* — + a CMS or a forgotten staging subdomain next to the panel — which the session + cookie's `SameSite=Lax` counts as same-site and therefore cannot stop. A + request that sends neither header is still let through, so genuinely ancient + browsers keep working. **The reverse proxy must pass the original `Host` + header through** (every shipped fragment already does); one that rewrites it + makes the panel refuse every form submission, and the log line names both + the `Origin` and the `Host` it compared. +- panel: the session cookie is now named `__Host-selfpost_session` wherever it + is `Secure` (the standard deployment), which makes the browser enforce that + no other host can set or overwrite it. **Upgrading signs the administrator + out once.** With `PANEL_COOKIE_SECURE=false` the old name is kept, because + the prefix is invalid without TLS. Signing out clears both names. +- panel: if a request arrives with two cookies of the session cookie's name — + what a neighbouring host does when it overwrites the session — the request + counts as signed out and the log says so, instead of the panel silently + picking the other host's value and looping back to the login form forever. +- panel: the layout's stylesheet moved to `/static/panel.css` and the + confirmation prompts on destructive buttons moved into `/static/panel.js`. + No visible change; the panel's CSP allows no inline script or style, and + this is what keeps that policy free of exemptions. +- docs: the first-run setup link is also written to `/data/setup-token` + (`0600`) — documented in the README as the way to read it without the token + passing through a container-log pipeline. - panel: new **Status** page — supervised processes, mail queue, TLS certificate expiry, milter sockets and the server's own hostname/reverse-DNS (FCrDNS) check — and it is now the panel's landing page. The local checks diff --git a/README.md b/README.md index 2922ba7..92941b8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,17 @@ open it to create the admin account. That username and password can be changed later from the panel's *Account* page (changing the password signs out every other session). +The same link is also written to `/data/setup-token` inside the container — +`./data/setup-token` on the host, mode `0600` — and deleted the moment setup +completes. If this host ships its container logs to a central aggregator, +prefer the file: the link is a bearer token valid for ten minutes, and reading +it this way keeps it out of the log pipeline (and out of whatever retains it +afterwards) entirely. + +```sh +docker compose exec selfpost cat /data/setup-token +``` + ## Reverse proxy (mandatory) SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse @@ -66,6 +77,17 @@ Apache is the recommended default because the certbot Apache plugin already writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no extra moving parts between "certificate issued" and "Postfix can read it." +**The proxy needs no security configuration of its own.** The panel emits its +own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, +`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part +that's easy to get wrong lives in the service rather than in a config file +somebody edits under pressure. There is exactly one thing the proxy must do: +**pass the original `Host` header through**. All four fragments above already +do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy +and Traefik by default). A proxy that rewrites `Host` instead makes the panel +reject every form submission as cross-origin — the log says so explicitly, +printing the `Origin` and `Host` it compared. + ## DNS setup Two different scopes — don't confuse them: diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 18a408d..b19ec80 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -3,7 +3,7 @@ **Статус:** выполненные фазы здесь не описываются — текущее состояние в [progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md) и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы -для согласования, Фаза 14 и опциональная линия 2.x.x. +для согласования и опциональная линия 2.x.x. **Основа:** [specification.md](specification.md) v1.0. @@ -20,47 +20,34 @@ ### A. Безопасность — hardening сверх обязательного 7.6 -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`, без токенов.** +Все четыре пункта раздела — security-заголовки, проверка origin, cookie +`__Host-` с обнаружением дублей и документация про `/data/setup-token` — +**реализованы (Фаза 14)**; что именно сделано, см. [CHANGELOG.md](../CHANGELOG.md) +и `git log`. Здесь остаётся только то, что закрыто **сознательно не было**, +чтобы это не потерялось: - Что есть сейчас: все мутации панели — `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` — как сейчас | (б) + проверка `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 его не отдаст):** - - - **`POST /domains/import` — самый тяжёлый случай.** `multipart/form-data` относится к «простым» content-type, preflight'а нет, а тело можно собрать в JS через `FormData` (подставить значение в `` нельзя, собрать тело руками — можно). Импорт принимает `DomainExport` с приватным DKIM-ключом и **рабочими** SASL-паролями ([internal/domain/transfer.go:19](../internal/domain/transfer.go)), т.е. атакующий заливает домен с **заранее известными ему** учётками и получает валидную отправляющую идентичность на чужом релее — рассылка с IP и репутации жертвы. Единственный сценарий, где слепая запись даёт не порчу, а **доступ**. - - **Порча и тихий отказ:** `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}` (нужен сам секретный токен). - - **Решено: вариант (б)** — проверка `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=/`) — все требования префикса выполнены, но как договорённость сервера, а не как гарант браузера. - - **Что это открывает.** Тот же противник, что в пункте 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.** +- **Принятый риск: `POST` без `Sec-Fetch-Site` и без `Origin` пропускается.** + Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или + webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. + Принято сознательно: панель однопользовательская, админ выбирает браузер + сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём + панель. Ужесточение — одна строка в `originAllowed` + ([internal/web/security.go](../internal/web/security.go)): вернуть `false` + вместо `true` в ветке «нет обоих заголовков». +- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin + закрывает соседний поддомен, но зависит от поведения браузера; токен — нет. + Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к + вопросу считать появление требования «устойчиво независимо от браузера». +- **XSS внутри самой панели** не закрывается ни проверкой origin, ни токенами: + код, исполняющийся в origin панели, отправит запрос сам. Против него + работают автоэкранирование `html/template` (7.6.7) и CSP — поэтому шаблоны + не должны содержать inline-скриптов и inline-стилей; это закреплено + тестом-стражем в [internal/web/templates_test.go](../internal/web/templates_test.go), + а не только договорённостью. +- **Требование к развёртыванию, появившееся вместе с проверкой origin:** + reverse-proxy обязан передавать исходный заголовок `Host`. Все четыре + поставляемых фрагмента это делают; чужой прокси, переписывающий `Host`, + превратит каждый `POST` в `403` (в лог пишутся обе сравниваемые стороны). ### B. Надёжность и эксплуатация @@ -79,81 +66,6 @@ --- -## Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A) - -**Статус:** запланирована, не начата. - -**Цель:** довести до кода принятые решения из раздела -[A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — все -четыре пункта: A.1 (заголовки), A.2 (проверка origin), A.3 (cookie) и -A.4 (документация). - -### A. Security-заголовки ответа и проверка origin (пункты A.1 и A.2) - -Оба решения — один и тот же мидлварь, обёрнутый вокруг всего `mux` (а не только -вокруг `authed`, чтобы `POST /login` и `POST /setup/{token}` тоже попали под -проверку). - -**A.1 — заголовки.** Выставлять на всех ответах (кроме, возможно, уже -застриманных HTMX-фрагментов, где это не мешает): - -- `Strict-Transport-Security` (только когда `PANEL_COOKIE_SECURE`/TLS включён — по аналогии с `__Host-`/`Secure`-логикой, HSTS на голом HTTP в dev-режиме бессмысленен и может быть вреден); -- `X-Content-Type-Options: nosniff`; -- `X-Frame-Options: DENY` (или `Content-Security-Policy: frame-ancestors 'none'` — эквивалент, дублировать не обязательно); -- `Referrer-Policy: same-origin` (или `no-referrer` — выбрать более строгий, поведение панели не зависит от referrer); -- `Content-Security-Policy` — минимальная политика под текущий фронтенд (inline-стили/скрипты, HTMX): нужно свериться с шаблонами ([internal/web/templates](../internal/web/templates)) на предмет `