docs: document /data/setup-token and close phase 14

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:02:26 +03:00
parent c2edc586ef
commit c8abec376a
4 changed files with 86 additions and 121 deletions
+31
View File
@@ -5,6 +5,37 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [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 - panel: new **Status** page — supervised processes, mail queue, TLS
certificate expiry, milter sockets and the server's own hostname/reverse-DNS 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 (FCrDNS) check — and it is now the panel's landing page. The local checks
+22
View File
@@ -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 later from the panel's *Account* page (changing the password signs out every
other session). 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) ## Reverse proxy (mandatory)
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse 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 writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
extra moving parts between "certificate issued" and "Postfix can read it." 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 ## DNS setup
Two different scopes — don't confuse them: Two different scopes — don't confuse them:
+28 -116
View File
@@ -3,7 +3,7 @@
**Статус:** выполненные фазы здесь не описываются — текущее состояние в **Статус:** выполненные фазы здесь не описываются — текущее состояние в
[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md) [progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md)
и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы
для согласования, Фаза 14 и опциональная линия 2.x.x. для согласования и опциональная линия 2.x.x.
**Основа:** [specification.md](specification.md) v1.0. **Основа:** [specification.md](specification.md) v1.0.
@@ -20,47 +20,34 @@
### A. Безопасность — hardening сверх обязательного 7.6 ### 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.** Все четыре пункта раздела — security-заголовки, проверка origin, cookie
2. **CSRF — только `SameSite=Lax`, без токенов.** `__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 **без** атрибута). - **Принятый риск: `POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
**Варианты и что каждый закрывает:** webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
Принято сознательно: панель однопользовательская, админ выбирает браузер
| Сценарий | (а) только `Lax` — как сейчас | (б) + проверка `Origin`/`Sec-Fetch-Site` | (в) токен, привязанный к сессии | сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
|---|---|---|---| панель. Ужесточение — одна строка в `originAllowed`
| Чужой сайт (`evil.com`), современный браузер | **закрыт** | закрыт | закрыт | ([internal/web/security.go](../internal/web/security.go)): вернуть `false`
| Соседний поддомен того же домена | **открыт** | **закрыт** | закрыт | вместо `true` в ветке «нет обоих заголовков».
| Браузер/webview, не знающий `SameSite` | **открыт** | закрыт, только если резать `POST` без `Origin` | **закрыт** | - **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
| XSS в самой панели | открыт | открыт | открыт | закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
- **(а)** — ноль работы. `SameSite` действует на уровне сайта (eTLD+1), а не origin, поэтому строка 2 остаётся открытой. вопросу считать появление требования «устойчиво независимо от браузера».
- **(б)** — ~15 строк в том же мидлваре, что security-заголовки (Фаза 14.A), шаблоны не трогаются: отклонять `POST`, у которого `Sec-Fetch-Site` не `same-origin`, а при отсутствии заголовка сверять `Origin` с ожидаемым хостом. Это проверка **origin**, а не сайта — потому и закрывает строку 2. Вопрос политики: `POST` без обоих заголовков (ровно клиенты из строки 3) — пропускать ради совместимости или резать. - **XSS внутри самой панели** не закрывается ни проверкой origin, ни токенами:
- **(в)** — мидлварь + скрытое поле в каждой POST-форме (в [шаблонах](../internal/web/templates) их около двух десятков); JS править не нужно, HTMX здесь делает только `hx-get`-поллинг. Не зависит от браузера, поэтому закрывает и строку 3. Только **привязанный к сессии** (синхронизатор или HMAC от идентификатора сессии): наивный double-submit закрывает строки 1 и 3, но не 2 — сосед по registrable domain выставит cookie на родительский домен и продублирует своё же значение в форме. код, исполняющийся в origin панели, отправит запрос сам. Против него
работают автоэкранирование `html/template` (7.6.7) и CSP — поэтому шаблоны
Строку 4 не закрывает ни один вариант: XSS внутри origin прочитает токен и отправит запрос сам. Против неё работают автоэкранирование `html/template` (7.6.7) и CSP из Фазы 14.A. не должны содержать inline-скриптов и inline-стилей; это закреплено
тестом-стражем в [internal/web/templates_test.go](../internal/web/templates_test.go),
**Строки таблицы подробнее.** *Соседний поддомен* — панель живёт на поддомене (`selfpost.example.com`), и любая страница под `example.com` (сайт на CMS, стенд, забытый поддомен с висящим CNAME) считается same-site: её `POST` уйдёт в панель вместе с сессионной cookie. Для типового деплоя SelfPost это главный вектор, а не теоретический. *Старый клиент* — нераспознанный атрибут cookie игнорируется целиком, т.е. поведение откатывается к `SameSite=None`; речь про по-настоящему старые браузеры и webview с замороженным движком, для аудитории «один админ на своём сервере» узко, но не пусто. а не только договорённостью.
- **Требование к развёртыванию, появившееся вместе с проверкой origin:**
**Что даёт успешный CSRF (запись вслепую — ответ атакующему не виден, CORS его не отдаст):** reverse-proxy обязан передавать исходный заголовок `Host`. Все четыре
поставляемых фрагмента это делают; чужой прокси, переписывающий `Host`,
- **`POST /domains/import` — самый тяжёлый случай.** `multipart/form-data` относится к «простым» content-type, preflight'а нет, а тело можно собрать в JS через `FormData` (подставить значение в `<input type=file>` нельзя, собрать тело руками — можно). Импорт принимает `DomainExport` с приватным DKIM-ключом и **рабочими** SASL-паролями ([internal/domain/transfer.go:19](../internal/domain/transfer.go)), т.е. атакующий заливает домен с **заранее известными ему** учётками и получает валидную отправляющую идентичность на чужом релее — рассылка с IP и репутации жертвы. Единственный сценарий, где слепая запись даёт не порчу, а **доступ**. превратит каждый `POST` в `403` (в лог пишутся обе сравниваемые стороны).
- **Порча и тихий отказ:** `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.**
### B. Надёжность и эксплуатация ### 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)) на предмет `<script>`/`style="..."`/`onclick` перед тем, как писать политику, чтобы не сломать текущий UI.
**A.2 — проверка origin.** Применяется только к небезопасным методам (панель
использует один `POST`; `GET`-поллинг HTMX не затрагивается):
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 (небольшой, хорошо специфицированный мидлварь).
### 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)) — это не требует изменений.
Остаётся только документационная задача:
- В README (раздел про первый запуск/setup-ссылку) добавить абзац: по умолчанию ссылка печатается в stdout контейнера (spec 7.6.1), но токен также лежит в файле `/data/setup-token` внутри смонтированного `/data` — для тех, у кого логи контейнера уезжают в центральный агрегатор и не хочется, чтобы токен там оседал на 10 минут, безопаснее прочитать файл (`docker exec` / примонтированный volume) вместо просмотра логов.
**Готово, когда:** README содержит этот абзац рядом с описанием setup-ссылки.
**Риски:** нет — чисто документация, код не меняется.
**Модель:** Sonnet (документация).
**Зависимости:** нет.
---
## Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0) ## Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0)
Эти фазы **не входят** в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к **релизной линии 2.x.x** и добавлены в дорожную карту как согласуемые расширения. **Реализация — только после явного согласования (ТЗ 12.6):** ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением. Эти фазы **не входят** в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к **релизной линии 2.x.x** и добавлены в дорожную карту как согласуемые расширения. **Реализация — только после явного согласования (ТЗ 12.6):** ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением.
+5 -5
View File
@@ -8,7 +8,7 @@
## Как возобновить после сброса контекста ## Как возобновить после сброса контекста
1. Прочитать этот файл (текущее состояние, что дальше). 1. Прочитать этот файл (текущее состояние, что дальше).
2. Открыть `implementation-plan.md` — там нерешённые вопросы, Фаза 14 и опциональная линия 2.x.x (Фаза O1+). 2. Открыть `implementation-plan.md` — там нерешённые вопросы, принятые риски и опциональная линия 2.x.x (Фаза O1+).
3. При необходимости — детали в `specification.md`. 3. При необходимости — детали в `specification.md`.
4. Продолжить с пункта «Следующий шаг». 4. Продолжить с пункта «Следующий шаг».
@@ -18,7 +18,7 @@
## Коммиты ## Коммиты
Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude <модель> <noreply@anthropic.com>` — с той моделью, которая этот шаг делала (на момент Фазы 14 — `Claude Opus 5`).
На каждом таком шаге — запись в [CHANGELOG.md](../CHANGELOG.md) под `[Unreleased]` (формат Keep a Changelog). При явном решении зарезать версию — секция `[Unreleased]` переименовывается в `[X.Y.Z] - дата`, заводится новая пустая `[Unreleased]`. Тег/пуш образа — только по явному запросу (см. workflow release.yml). На каждом таком шаге — запись в [CHANGELOG.md](../CHANGELOG.md) под `[Unreleased]` (формат Keep a Changelog). При явном решении зарезать версию — секция `[Unreleased]` переименовывается в `[X.Y.Z] - дата`, заводится новая пустая `[Unreleased]`. Тег/пуш образа — только по явному запросу (см. workflow release.yml).
@@ -34,9 +34,9 @@
## Текущее состояние ## Текущее состояние
- **Выполнено и принято:** базовый линейный план 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-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется.
- **Дальше — то, что перечислено в `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, требует согласования). - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы разделов B–D (надёжность и эксплуатация, e2e в CI, указатель на объём 2.x) и принятые риски раздела A (`POST` без `Sec-Fetch-Site`/`Origin` пропускается, токенов нет); опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
- **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). - **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
## Рабочая петля (dev loop) — ВАЖНО ## Рабочая петля (dev loop) — ВАЖНО