diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc3455b..9fb40f3 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)) на предмет `