Compare commits
8 Commits
v1.0.0
..
a4cfa11323
| Author | SHA1 | Date | |
|---|---|---|---|
| a4cfa11323 | |||
| 236cb07769 | |||
| 04993e0da3 | |||
| 7eb168f418 | |||
| a1b6209470 | |||
| 44683a4996 | |||
| 870012514a | |||
| 012802d83d |
+28
-1
@@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
|
||||||
|
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
|
||||||
|
fixes, and what is in and out of scope for a relay. No response time is
|
||||||
|
promised. Without it a finder's default move is a public issue, which
|
||||||
|
discloses a relay flaw to everyone the moment it is filed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [docs/security.md](docs/security.md) is now in English, matching the rest of
|
||||||
|
the published docs — it is linked from the README table and from
|
||||||
|
`SECURITY.md`, so a reader following either landed in Russian. Content is
|
||||||
|
unchanged: same requirements, same accepted risks, same ADR. The reviewing
|
||||||
|
model is no longer named in the text; the fact that a pre-release review ran,
|
||||||
|
and its date, stay.
|
||||||
|
- The two remaining Russian source comments are in English:
|
||||||
|
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
|
||||||
|
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
|
||||||
|
rather than translated — rendered in English it restated the sentence it was
|
||||||
|
attached to.
|
||||||
|
- The panel's **Account** entry is now called **Settings** — nav link, page
|
||||||
|
heading, and browser title. The route stays `/account`, so existing links
|
||||||
|
and bookmarks are unaffected.
|
||||||
|
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
|
||||||
|
the current account rather than as a stray word above the Settings link.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
||||||
@@ -368,7 +395,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
||||||
unrecognised status word, a capitalised one, and a cleanup line.
|
unrecognised status word, a capitalised one, and a cleanup line.
|
||||||
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
||||||
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
|
*Backup and domain export* + accepted risk (encryption is opt-in);
|
||||||
`docs/architecture.md` persistence § envelope summary.
|
`docs/architecture.md` persistence § envelope summary.
|
||||||
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
||||||
release commit (move to `docs/archive/`, retarget its references in README,
|
release commit (move to `docs/archive/`, retarget its references in README,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
||||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||||
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
|
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — internal, Russian |
|
||||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||||
|
1.x line** only; there is no backporting to earlier minors. Upgrade before
|
||||||
|
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest 1.x | yes |
|
||||||
|
| earlier 1.x | no — upgrade first |
|
||||||
|
| 0.x | no (pre-release) |
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||||
|
the *Report a vulnerability* button under the repository's
|
||||||
|
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
|
||||||
|
it, mail `public@mixeme.ru` instead.
|
||||||
|
|
||||||
|
Useful in a report: the image tag, the reverse proxy in front of the panel, the
|
||||||
|
steps to reproduce, and what an attacker gains. A relevant excerpt of
|
||||||
|
`mail.log` or the panel's system log helps; strip recipient addresses first.
|
||||||
|
|
||||||
|
**No response time is promised.** SelfPost is maintained by one person, and a
|
||||||
|
deadline that cannot be honoured is worse than none. Reports are read and
|
||||||
|
answered as soon as the maintainer is able; a fix ships in a patch release,
|
||||||
|
with the timeline agreed in the thread.
|
||||||
|
|
||||||
|
Disclosure is coordinated by request, not by demand: please hold public details
|
||||||
|
until a patch is out. If you get no reply, that is not a request for a
|
||||||
|
continued embargo — disclose at your own discretion. Reporters are credited in
|
||||||
|
the CHANGELOG unless they ask not to be.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
The relay's job is to accept authenticated mail from an application and hand it
|
||||||
|
to the internet as the operator's domain, and nothing else. Breaking that is in
|
||||||
|
scope:
|
||||||
|
|
||||||
|
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
|
||||||
|
a domain the sending application is not bound to
|
||||||
|
- **SASL bypass** — sending without valid credentials, or credential recovery
|
||||||
|
from anything the container exposes
|
||||||
|
- **Cross-domain access** — an application or a panel session reaching a domain
|
||||||
|
it was not granted
|
||||||
|
- **Secret disclosure** — DKIM private keys, the admin password hash, session
|
||||||
|
tokens, or backup encryption material leaking to an unauthorised party
|
||||||
|
- **Panel authentication and session flaws** — login bypass, session fixation,
|
||||||
|
CSRF on state-changing routes, privilege escalation
|
||||||
|
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
|
||||||
|
per-domain and per-application limits
|
||||||
|
- **Container escape** or privilege escalation from the panel's unprivileged
|
||||||
|
user to root
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
These are the operator's responsibility or accepted trade-offs, documented in
|
||||||
|
[docs/security.md](docs/security.md) and the
|
||||||
|
[operator guide](docs/guide.md):
|
||||||
|
|
||||||
|
- Host configuration the operator controls: a blocked port 25, a missing or
|
||||||
|
wrong PTR record, DNS records not published, a self-signed or expired
|
||||||
|
certificate on the reverse proxy
|
||||||
|
- Anything requiring the attacker to already have root on the host or write
|
||||||
|
access to the `./data` bind mount
|
||||||
|
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
|
||||||
|
never terminates HTTPS itself
|
||||||
|
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||||
|
a policy decision of that provider, not a defect
|
||||||
|
- Denial of service through sheer volume against a single-tenant relay
|
||||||
|
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||||
|
those upstream; if SelfPost's configuration makes an upstream issue
|
||||||
|
exploitable when it otherwise would not be, that *is* in scope
|
||||||
|
|
||||||
|
## Reports we cannot act on
|
||||||
|
|
||||||
|
Automated scanner output with no demonstrated impact, and reports whose only
|
||||||
|
content is a version number compared against a CVE list, are closed without
|
||||||
|
investigation.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||||
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
|
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
|
||||||
# PEM"). Run this on the host, after Traefik has issued or renewed the
|
# step is required"). Run this on the host, after Traefik has issued or
|
||||||
# certificate, and again on a schedule (cron/systemd timer) since acme.json
|
# renewed the certificate, and again on a schedule (cron/systemd timer) since
|
||||||
# is not itself watched by SelfPost/Postfix.
|
# acme.json is not itself watched by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+10
-6
@@ -1,8 +1,9 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, document, and ship changes. Open
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||||
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,10 +12,12 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
|||||||
After `/clear` or a fresh chat:
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
1. Read this file (process, docs rules, model routing).
|
1. Read this file (process, docs rules, model routing).
|
||||||
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
|
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||||
[security.md](security.md); as-built — [architecture.md](architecture.md).
|
plan file for the active item. Accepted risks — [security.md](security.md);
|
||||||
|
as-built — [architecture.md](architecture.md).
|
||||||
3. Skim [product.md](product.md) if scope is in doubt.
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
4. Continue from the next unchecked step in the active plan.
|
4. Continue from the next unchecked step in the **active** plan file (not the
|
||||||
|
roadmap index).
|
||||||
|
|
||||||
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
not duplicated here.
|
not duplicated here.
|
||||||
@@ -273,7 +276,8 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
|
| Internal roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
|
| Active design plans | [plans/](plans/) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
### User-facing deliverables
|
### User-facing deliverables
|
||||||
|
|||||||
+1
-1
@@ -223,7 +223,7 @@ service healthy and will mail be accepted?"
|
|||||||
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
||||||
the domain-import form (`POST /domains/import`). See
|
the domain-import form (`POST /domains/import`). See
|
||||||
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
||||||
- **Account** (`/account`) — change the administrator username and/or password.
|
- **Settings** (`/account`) — change the administrator username and/or password.
|
||||||
Application SASL logins are separate and are not changed here.
|
Application SASL logins are separate and are not changed here.
|
||||||
|
|
||||||
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# План: domain-admin (роль администратора домена)
|
||||||
|
|
||||||
|
**Статус:** согласовано
|
||||||
|
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа
|
||||||
|
в глобального.
|
||||||
|
**Порядок:** рекомендуется после [web-split](web-split.md), до
|
||||||
|
[inbound-relay](inbound-relay.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль
|
||||||
|
([web.go](../../internal/web/web.go) — обёртка
|
||||||
|
`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта
|
||||||
|
входа.
|
||||||
|
|
||||||
|
Роль выдаёт доступ к **явно назначенным доменам** (одному или нескольким);
|
||||||
|
перечень доменов определяет **глобальный администратор**. Для каждого домена из
|
||||||
|
списка:
|
||||||
|
|
||||||
|
- приложения этого домена (создание, режим отправителя, перегенерация пароля,
|
||||||
|
удаление, свой L2-лимит);
|
||||||
|
- DKIM/DNS-статус домена;
|
||||||
|
- журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть
|
||||||
|
([sendLogData](../../internal/web/handlers_monitor.go)).
|
||||||
|
|
||||||
|
Вне роли остаётся то, что глобально по своей природе:
|
||||||
|
|
||||||
|
- добавление и удаление доменов;
|
||||||
|
- создание domain-admin пользователей и назначение им доменов;
|
||||||
|
- `/reload`;
|
||||||
|
- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены
|
||||||
|
сразу);
|
||||||
|
- очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
||||||
|
|
||||||
|
## Почему расширение v1.0
|
||||||
|
|
||||||
|
[product.md](../product.md) относит «несколько пользователей панели, роли» к
|
||||||
|
out of scope (один администратор). Появление второго субъекта — сознательное
|
||||||
|
расширение границ проекта, как и inbound-relay.
|
||||||
|
|
||||||
|
Цена — уровня фазы, а не патча:
|
||||||
|
|
||||||
|
- таблица пользователей и их привязка к доменам;
|
||||||
|
- роль в сессии;
|
||||||
|
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}`
|
||||||
|
не сверяются ни с чем, кроме существования);
|
||||||
|
- пересмотр первичного setup'а и смены пароля под нескольких пользователей;
|
||||||
|
- учёт нового субъекта в бэкапе и экспорте домена.
|
||||||
|
|
||||||
|
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» —
|
||||||
|
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до
|
||||||
|
одной конкретной роли, потому что нужна не вторая копия всевластного админа, а
|
||||||
|
ограниченный доступ владельца одного или нескольких доменов — перечень задаёт
|
||||||
|
глобальный администратор.)*
|
||||||
|
|
||||||
|
## Готово, когда
|
||||||
|
|
||||||
|
- Глобальный администратор и domain-admin с разными правами работают через
|
||||||
|
панель; domain-admin не может выйти за пределы **назначенных** доменов;
|
||||||
|
- текущий единственный админ мигрирует в глобального без потери доступа;
|
||||||
|
- бэкап/восстановление учитывает пользователей и привязки;
|
||||||
|
- `build`/`vet`/`test`/образ зелёные.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому
|
||||||
|
домену;
|
||||||
|
- breaking setup/бэкап — тогда semver major, не 1.x.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# План: inbound-relay (входящий релей)
|
||||||
|
|
||||||
|
**Статус:** согласовано
|
||||||
|
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x** — требует уточнения по
|
||||||
|
итогам реализации (не фиксировать major заранее).
|
||||||
|
**Модель:** Opus (инфра/безопасность, риск open relay).
|
||||||
|
**Порядок:** рекомендуется после [web-split](web-split.md) и
|
||||||
|
[domain-admin](domain-admin.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать
|
||||||
|
её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как
|
||||||
|
выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность
|
||||||
|
атаки базового исходящего релея.
|
||||||
|
|
||||||
|
## Зачем это нужно (сценарии)
|
||||||
|
|
||||||
|
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно
|
||||||
|
недоступен, и передать её, когда он вернётся.
|
||||||
|
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер,
|
||||||
|
который по каким-то причинам **сам не может принимать почту из интернета**
|
||||||
|
(нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на
|
||||||
|
входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает
|
||||||
|
публичным входным узлом для домена (MX указывает на него) и пересылает почту
|
||||||
|
на этот внутренний/недоступный извне сервер.
|
||||||
|
|
||||||
|
## Граница объёма (критично — что это НЕ)
|
||||||
|
|
||||||
|
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward)
|
||||||
|
на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`).
|
||||||
|
Postfix здесь — чистый пересыльщик, без локальной доставки.
|
||||||
|
- **ЭТО НЕ (out of scope, [product.md](../product.md)):** локальная доставка в
|
||||||
|
почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost
|
||||||
|
также **не реализует и не тянет в свой образ** движок антиспама/антивируса
|
||||||
|
(rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не**
|
||||||
|
перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет
|
||||||
|
точку подключения внешнего фильтра.
|
||||||
|
|
||||||
|
## Почему как опция/плагин
|
||||||
|
|
||||||
|
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter,
|
||||||
|
spam-ingress). Поэтому по умолчанию **выключено** флагом env
|
||||||
|
`INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
||||||
|
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели,
|
||||||
|
отдельная ветка генерации конфига. При выключенном флаге входной listener,
|
||||||
|
таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
||||||
|
|
||||||
|
## Что делать
|
||||||
|
|
||||||
|
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать
|
||||||
|
входной сервис и его конфиг из состояния панели тем же путём, что остальной
|
||||||
|
конфиг (`postfix-config.sh`).
|
||||||
|
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25
|
||||||
|
используется только на исходящую доставку). Отдельный от 465/587: на 25 **не**
|
||||||
|
предлагается SASL и **не** разрешается отправка наружу — только приём для
|
||||||
|
`relay_domains`.
|
||||||
|
- **Анти-open-relay для входящей (обязательно):**
|
||||||
|
`smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd
|
||||||
|
принимают почту **только** для доменов из `relay_domains` и **только** для
|
||||||
|
известных получателей (`relay_recipient_maps`); всё прочее —
|
||||||
|
`reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём
|
||||||
|
«для кого угодно» невозможны.
|
||||||
|
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown
|
||||||
|
recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
||||||
|
- **Панель управляет:** список входящих доменов; для каждого — upstream
|
||||||
|
destination (`host:port`, транспорт), опциональный список валидных
|
||||||
|
получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта
|
||||||
|
(whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе
|
||||||
|
4), `os/exec` без shell ([security.md](../security.md)).
|
||||||
|
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не
|
||||||
|
подписываем). journal-milter опционально переиспользовать для журнала входящих
|
||||||
|
(доп. работа) либо на первом этапе оставить входящий без него; поведение
|
||||||
|
fail-open сохраняется.
|
||||||
|
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и
|
||||||
|
`message_size_limit` на входном smtpd.
|
||||||
|
|
||||||
|
## Антиспам (важная, но опциональная возможность)
|
||||||
|
|
||||||
|
Это ценная опция, но она **не обязательна**: часть операторов вполне устроит
|
||||||
|
**слепая пересылка без фильтрации** — например, когда backend сам умеет
|
||||||
|
фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик.
|
||||||
|
Поэтому антиспам-хук по умолчанию **выключен** (пустой
|
||||||
|
`INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него.
|
||||||
|
|
||||||
|
Важно другое — где фильтрация возможна технически: при «слепом» relay целевой
|
||||||
|
backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя,
|
||||||
|
поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация
|
||||||
|
проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF
|
||||||
|
домена-отправителя). **Единственная точка, где ещё виден настоящий client IP —
|
||||||
|
входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть
|
||||||
|
*подключаема именно здесь*, а не переложена на backend, который эту информацию
|
||||||
|
уже потерял.
|
||||||
|
|
||||||
|
Дизайн подключения:
|
||||||
|
|
||||||
|
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.),
|
||||||
|
который оператор запускает **только если нужна эта опция** (тот же принцип,
|
||||||
|
что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его
|
||||||
|
**не содержит и не запускает** — образ и принцип «один контейнер, три
|
||||||
|
процесса» неизменны, [product.md](../product.md) out of scope не нарушается
|
||||||
|
(SelfPost не реализует антиспам).
|
||||||
|
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd.
|
||||||
|
Адрес движка задаётся env (например,
|
||||||
|
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и
|
||||||
|
добавляется в `smtpd_milters` **только входного** тракта (не на 465/587).
|
||||||
|
Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит
|
||||||
|
истинный origin. `milter_default_action` для этого milter'а — конфигурируемый
|
||||||
|
(fail-open vs tempfail); дефолт определить при реализации.
|
||||||
|
- **Нативный backstop без зависимостей:** на том же входном хопе доступны
|
||||||
|
средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки
|
||||||
|
HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение
|
||||||
|
аутентификации для downstream через ARC/`Received` там, где часть фильтрации
|
||||||
|
всё же остаётся на backend.
|
||||||
|
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара
|
||||||
|
(как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со
|
||||||
|
стеком только при включённой опции.
|
||||||
|
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный
|
||||||
|
бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей
|
||||||
|
конфигурацией — опционально, пометить.
|
||||||
|
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на
|
||||||
|
сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS
|
||||||
|
README.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
[security.md](../security.md): валидация ввода на сервере, экранирование записи
|
||||||
|
в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
||||||
|
|
||||||
|
## Готово, когда
|
||||||
|
|
||||||
|
При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого
|
||||||
|
домена пересылается на заданный upstream; почта для ненастроенных
|
||||||
|
доменов/получателей отклоняется (не open relay, не backscatter); при заданном
|
||||||
|
`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим
|
||||||
|
origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при
|
||||||
|
`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый
|
||||||
|
исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- open relay/backscatter — снимается `relay_domains` + `relay_recipient_maps` +
|
||||||
|
`reject_unauth_destination`;
|
||||||
|
- потеря origin IP для фильтрации на backend'е при пересылке — снимается
|
||||||
|
milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё
|
||||||
|
виден;
|
||||||
|
- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено);
|
||||||
|
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) —
|
||||||
|
возможен major `2.x`; решение после реализации.
|
||||||
|
|
||||||
|
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа
|
||||||
|
SelfPost, поднимается оператором при включении опции.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Готовый исходящий тракт (уже реализован). Согласование получено — см. статус
|
||||||
|
выше.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# План: web-split (разбиение `internal/web`)
|
||||||
|
|
||||||
|
**Статус:** согласовано
|
||||||
|
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и
|
||||||
|
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех
|
||||||
|
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и
|
||||||
|
рендер шаблонов.
|
||||||
|
|
||||||
|
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
||||||
|
панели.
|
||||||
|
|
||||||
|
## Почему сейчас
|
||||||
|
|
||||||
|
На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`,
|
||||||
|
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а
|
||||||
|
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то
|
||||||
|
есть расширение внутреннего API ради косметики.
|
||||||
|
|
||||||
|
Смысл появляется, когда пакет начнёт расти: **domain-admin** и **inbound-relay**
|
||||||
|
добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий
|
||||||
|
релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле
|
||||||
|
делать перед этим ростом, чем после.
|
||||||
|
|
||||||
|
## Рекомендуемый порядок
|
||||||
|
|
||||||
|
**web-split → domain-admin → inbound-relay** (см. [roadmap](../roadmap.md)).
|
||||||
|
|
||||||
|
1. **web-split** — заложить структуру пакета (в т.ч. место под `web/auth`), пока
|
||||||
|
нет сквозных правок от роли и новых inbound-хендлеров.
|
||||||
|
2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную
|
||||||
|
схему пакета.
|
||||||
|
3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный
|
||||||
|
пакет, чем рефакторить вместе с двумя предыдущими фичами.
|
||||||
|
|
||||||
|
Порядок рекомендация, не блокер.
|
||||||
|
|
||||||
|
## Готово, когда
|
||||||
|
|
||||||
|
Решение принято осознанно в момент старта работ — либо пакет разрезан по
|
||||||
|
выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки:
|
||||||
|
`build`/`vet`/`test` зелёные, поведение панели неизменно.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- Преждевременное разбиение — лишний внутренний API и churn без выгоды;
|
||||||
|
- откладывание до после роста — сложнее рефакторинг в перемешку с фичами.
|
||||||
+5
-2
@@ -53,8 +53,11 @@ Explicitly excluded to prevent scope creep:
|
|||||||
- A custom MTA — Postfix is used as-is
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
|
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
||||||
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
|
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
||||||
|
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
||||||
|
implementation. Items still marked *candidate* in the roadmap require explicit
|
||||||
|
approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+67
-100
@@ -1,133 +1,100 @@
|
|||||||
# Дорожная карта: SelfPost 2.x.x
|
# Дорожная карта: открытая работа (1.x+)
|
||||||
|
|
||||||
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
|
**Статус:** внутренний трекер расширений границ v1.0 после явного согласования
|
||||||
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
|
([product.md](product.md), [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||||
только после явного согласования ([product.md](product.md),
|
Детальный дизайн — в [plans/](plans/). Пункты со статусом `кандидат` требуют OK
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
|
до кодирования.
|
||||||
[product.md](product.md) явно исключает часть этого объёма (приём входящей
|
|
||||||
почты; несколько пользователей/роли), поэтому включение — сознательное
|
|
||||||
расширение границ проекта, а не доработка по своей инициативе. Присутствие
|
|
||||||
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
|
||||||
решением.
|
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
**Версионирование:** по умолчанию SemVer MINOR в линии **1.x+** (`1.1.0`…), если
|
||||||
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
|
дефолты и миграции совместимы с `1.0.0`. Major `2.x` — только при явном breaking.
|
||||||
[CHANGELOG.md](../CHANGELOG.md).
|
|
||||||
|
**Процесс:** [development.md](development.md). История закрытых фаз — в `git log`
|
||||||
|
и [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## Индекс
|
||||||
|
|
||||||
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
|
| ID | Тема | Статус | План |
|
||||||
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
|
|---|---|---|---|
|
||||||
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
|
| web-split | Разбиение `internal/web` | **согласовано** | [plans/web-split.md](plans/web-split.md) |
|
||||||
|
| domain-admin | Роль администратора домена | **согласовано** | [plans/domain-admin.md](plans/domain-admin.md) |
|
||||||
|
| inbound-relay | Входящий релей (backup-MX / пересылка) | **согласовано** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
|
| contributing | `CONTRIBUTING.md` | кандидат | — |
|
||||||
|
|
||||||
| Тема | Итог |
|
**Рекомендуемый порядок** (не обязателен): **web-split → domain-admin →
|
||||||
|---|---|
|
inbound-relay** — сначала разрез пакета, затем сквозная авторизация роли, затем
|
||||||
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
|
новый вертикальный срез входящего релея. Отклонение допустимо; жёстких фаз нет.
|
||||||
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
|
|
||||||
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
|
|
||||||
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
|
|
||||||
|
|
||||||
Открытая работа дальше — только секции 2.x ниже.
|
После `/clear` — пункт со статусом `согласовано` или `в работе`, затем чеклист в
|
||||||
|
linked plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
|
## inbound-relay
|
||||||
|
|
||||||
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
|
**Цель:** опциональный приём почты на порт 25 для явно настроенных доменов и
|
||||||
|
пересылка на upstream (backup-MX / relay-forwarder). По умолчанию выключено
|
||||||
|
(`INBOUND_RELAY_ENABLE=false`); исходящий тракт без флага не меняется.
|
||||||
|
|
||||||
**Зачем это нужно (сценарии):**
|
**Граница:** расширение v1.0 — [product.md](product.md) исключает приём входящей
|
||||||
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
|
почты и mailbox'ы. Это relay/forward, не IMAP/POP3/webmail; антиспам-движок — вне
|
||||||
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
|
образа, только точка подключения.
|
||||||
|
|
||||||
**Граница объёма (критично — что это НЕ):**
|
**Готово, когда:** см. критерии в [plans/inbound-relay.md](plans/inbound-relay.md).
|
||||||
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
|
|
||||||
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
|
|
||||||
|
|
||||||
**Почему как опция/плагин:**
|
**Зависимости / риски:** готовый исходящий тракт; open relay/backscatter;
|
||||||
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
расширение поверхности атаки (порт 25 на приём). Модель: Opus.
|
||||||
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
**Порядок:** рекомендуется после [web-split](plans/web-split.md) и
|
||||||
|
[domain-admin](plans/domain-admin.md).
|
||||||
**Что делать:**
|
**Версия:** целевой bump `1.x`; возможен `2.x` — уточнить по итогам реализации.
|
||||||
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
|
|
||||||
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
|
|
||||||
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
|
|
||||||
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
|
||||||
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)).
|
|
||||||
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
|
|
||||||
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
|
|
||||||
|
|
||||||
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
|
|
||||||
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам).
|
|
||||||
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
|
|
||||||
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
|
|
||||||
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
|
|
||||||
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
|
|
||||||
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
|
|
||||||
|
|
||||||
**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
|
||||||
|
|
||||||
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
|
||||||
|
|
||||||
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
|
|
||||||
|
|
||||||
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Роль администратора домена — кандидат на 2.x
|
## domain-admin
|
||||||
|
|
||||||
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
**Цель:** роль с доступом к одному или нескольким назначенным доменам
|
||||||
|
(перечень задаёт глобальный администратор) — приложения, DKIM/DNS, журнал
|
||||||
|
отправки по каждому из них; без глобальных операций (добавление доменов, полный
|
||||||
|
бэкап, очередь, `mail.log`).
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
|
**Граница:** расширение v1.0 — [product.md](product.md) фиксирует одного
|
||||||
панели, роли» к out of scope (один администратор), поэтому появление второго
|
администратора. Не вторая копия всевластного админа, а ограниченный доступ к
|
||||||
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
|
назначенным доменам (одному или нескольким).
|
||||||
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
|
|
||||||
|
|
||||||
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
|
**Готово, когда:** см. [plans/domain-admin.md](plans/domain-admin.md).
|
||||||
|
|
||||||
|
**Зависимости / риски:** таблица пользователей, роль в сессии, авторизация в
|
||||||
|
каждом хендлере, setup/бэкап. **Порядок:** рекомендуется после
|
||||||
|
[web-split](plans/web-split.md), до [inbound-relay](plans/inbound-relay.md).
|
||||||
|
**Версия:** `1.x` MINOR при совместимой миграции текущего админа в глобального.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `CONTRIBUTING.md` — кандидат на 2.x
|
## web-split
|
||||||
|
|
||||||
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
**Цель:** осознанно разрезать `internal/web` (или зафиксировать плоский пакет)
|
||||||
моделей по типу работы, протокол коммитов, требование
|
перед ростом от inbound-relay и domain-admin.
|
||||||
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
|
|
||||||
[development.md](development.md) (английский процесс) и в этом файле (открытая
|
|
||||||
работа, русский).
|
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
**Граница:** внутренний рефакторинг; поведение панели для оператора не меняется.
|
||||||
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
|
||||||
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
|
||||||
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
|
||||||
проект вовне: английская документация процесса ([development.md](development.md),
|
|
||||||
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
|
|
||||||
русском) и первый внешний интерес после публикации релиза.
|
|
||||||
|
|
||||||
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
**Готово, когда:** пакет разрезан по выбранной схеме или зафиксировано, что
|
||||||
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
остаётся плоским — см. [plans/web-split.md](plans/web-split.md).
|
||||||
дублирует его, а ссылается.
|
|
||||||
|
**Зависимости / риски:** экспорт пакетно-приватного API. **Порядок:** рекомендуется
|
||||||
|
**первым** среди согласованных фич (до domain-admin и inbound-relay).
|
||||||
|
**Версия:** `1.x`, сам по себе breaking не тянет.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
|
## contributing
|
||||||
|
|
||||||
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
|
**Цель:** `CONTRIBUTING.md` в корне — dev loop, проверки перед PR, протокол
|
||||||
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
|
коммитов; [development.md](development.md) ссылается, не дублирует.
|
||||||
лежат хендлеры всех разделов панели, сессии,
|
|
||||||
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
|
|
||||||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
|
||||||
панели.
|
|
||||||
|
|
||||||
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
|
**Граница:** документация процесса; уместна при внешнем потоке PR.
|
||||||
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
|
|
||||||
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
|
|
||||||
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
|
|
||||||
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
|
|
||||||
2.x выше добавляют в него код — роль администратора домена приносит
|
|
||||||
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
|
|
||||||
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
|
|
||||||
|
|
||||||
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
|
**Готово, когда:** файл в корне; development.md не дублирует его.
|
||||||
разрезан, либо зафиксировано, что он остаётся плоским.
|
|
||||||
|
**Зависимости / риски:** пока один разработчик и нет PR — низкий приоритет.
|
||||||
|
**Версия:** без значения для semver.
|
||||||
|
|||||||
+157
-141
@@ -1,177 +1,193 @@
|
|||||||
# Безопасность
|
# Security
|
||||||
|
|
||||||
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
|
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
|
||||||
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
|
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
|
||||||
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
|
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
||||||
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
|
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
||||||
defence-in-depth — `--` перед логином в argv `saslpasswd2`
|
login in the `saslpasswd2` argv
|
||||||
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** —
|
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** —
|
||||||
сознательные отступления сверх обязательного, чтобы решение не потерялось.
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
|
lost.
|
||||||
|
|
||||||
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
|
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
|
||||||
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
|
cookie with duplicate detection — Phase 14) is done; the history is in
|
||||||
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
|
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
|
||||||
|
|
||||||
Продуктовые границы: [product.md](product.md). Устройство as-built:
|
Product boundaries: [product.md](product.md). As-built design:
|
||||||
[architecture.md](architecture.md).
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Обязательные требования
|
## Mandatory requirements
|
||||||
|
|
||||||
Панель публична из интернета — пункты ниже **не опциональны**.
|
The panel is exposed to the internet — the items below are **not optional**.
|
||||||
|
|
||||||
### Первичная инициализация администратора
|
### First-run administrator setup
|
||||||
|
|
||||||
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
|
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
|
||||||
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
|
ready-made password hash.
|
||||||
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
|
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
|
||||||
настройки — перегенерация и новый вывод в лог.
|
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
|
||||||
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
|
unfinished, it is regenerated and logged again.
|
||||||
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
|
- Rate limiting on `/setup/<token>` per IP, separate from login.
|
||||||
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
|
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
|
||||||
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
|
- Failed attempts do **not** invalidate the token early (protects setup from
|
||||||
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
|
being DoS-ed).
|
||||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
|
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
||||||
|
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext
|
||||||
|
and no MD5.
|
||||||
|
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||||
|
|
||||||
### SASL-пароли приложений
|
### Application SASL passwords
|
||||||
|
|
||||||
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
|
- The panel **generates** the password on creation or reissue and shows it
|
||||||
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
|
**once**.
|
||||||
перевыпуск.
|
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
|
||||||
|
panel); a lost password can only be reissued.
|
||||||
|
|
||||||
### Ввод и конфигурация
|
### Input and configuration
|
||||||
|
|
||||||
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
|
- Server-side validation of addresses and domains (character whitelist);
|
||||||
защитой.
|
client-side validation does not count as protection.
|
||||||
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
|
- In address-list mode every address is checked to belong to the application's
|
||||||
- `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского
|
domain before it is written.
|
||||||
ввода; аргументы отдельными элементами.
|
- `postfix reload` and any `exec` run **without** shell interpolation of user
|
||||||
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
|
input; arguments are passed as separate elements.
|
||||||
|
- Writes to config files are escaped (no injection of Postfix directives).
|
||||||
|
|
||||||
### Аутентификация и сессии
|
### Authentication and sessions
|
||||||
|
|
||||||
- Rate limiting на логин (по IP, с блокировкой/задержкой).
|
- Rate limiting on login (per IP, with lockout or delay).
|
||||||
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
|
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
|
||||||
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
|
`SameSite`.
|
||||||
(`PANEL_SESSION_IDLE_DAYS`).
|
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||||
|
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
|
||||||
|
|
||||||
### Вывод и процесс
|
### Output and process
|
||||||
|
|
||||||
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
|
- Rendering goes through `html/template` with auto-escaping (queue, log,
|
||||||
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
|
journal, themes).
|
||||||
группу `selfpost` и минимальные права.
|
- The panel process is **not root** (`user=panel` in supervisord); path access
|
||||||
|
is granted through the `selfpost` group with minimal permissions.
|
||||||
|
|
||||||
### Почтовый тракт (связанное с безопасностью)
|
### Mail path (security-relevant)
|
||||||
|
|
||||||
- **Не open relay** — только SASL; `reject_unauth_destination`;
|
- **Not an open relay** — SASL only; `reject_unauth_destination`;
|
||||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
|
`encrypt`).
|
||||||
при rate-limit логина; пусто = XFF игнорируется.
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
|
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
|
||||||
|
|
||||||
### Резервная копия и экспорт домена
|
### Backup and domain export
|
||||||
|
|
||||||
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
|
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
|
||||||
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
|
administrator's password hash; a domain export carries the DKIM key and
|
||||||
текстом (иначе перенос без пересоздания кредов невозможен).
|
**working** application passwords in the clear (otherwise a transfer without
|
||||||
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
|
recreating credentials would be impossible).
|
||||||
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
|
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||||
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
|
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||||
подменённый файл не открывается вместо тихого восстановления «хвоста».
|
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||||
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
|
a truncated or substituted file fails to open instead of silently restoring a
|
||||||
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
|
partial "tail". Format and wrapper:
|
||||||
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
|
[internal/secretfile](../internal/secretfile/secretfile.go).
|
||||||
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
|
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
|
||||||
шифрование по magic файла, а не по расширению.
|
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
|
||||||
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
|
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
|
||||||
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
|
bytes, not by extension.
|
||||||
аргументом (список процессов читается любым процессом контейнера).
|
- The password is never stored: without it the file cannot be recovered. In the
|
||||||
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
|
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
|
||||||
offline и подбирается без ограничений по времени.
|
`-password-file`, never as an argument (the process list is readable by any
|
||||||
|
process in the container).
|
||||||
|
- Minimum password length matches the administrator password (12): the file
|
||||||
|
sits offline and can be attacked without a time limit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Принятые риски
|
## Accepted risks
|
||||||
|
|
||||||
Принятый риск — решение с условием возврата, а не отложенная задача из
|
An accepted risk is a decision with a condition for revisiting it, not a
|
||||||
дорожной карты.
|
deferred item from the roadmap.
|
||||||
|
|
||||||
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
||||||
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
A client that sends neither — a genuinely old browser, or a webview with a
|
||||||
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
|
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||||
Принято сознательно: панель однопользовательская, админ выбирает браузер
|
deliberately: the panel is single-user, the administrator picks the browser,
|
||||||
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
|
and a strict mode would not "protect" such a client, it would simply break the
|
||||||
панель. Ужесточение — одна строка в `originAllowed`
|
panel in it. Tightening is one line in `originAllowed`
|
||||||
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
|
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||||
вместо `true` в ветке «нет обоих заголовков».
|
instead of `true` in the "neither header present" branch.
|
||||||
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
|
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||||
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
|
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||||
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
|
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||||
вопросу считать появление требования «устойчиво независимо от браузера».
|
revisit is a requirement for protection that holds regardless of the browser.
|
||||||
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
|
A token would not save the panel from XSS inside it either: code executing in
|
||||||
панели, отправит запрос сам — против этого работают автоэкранирование
|
the panel's origin sends the request itself — against that, `html/template`
|
||||||
`html/template` и CSP, поэтому шаблоны не должны содержать
|
auto-escaping and CSP do the work, which is why templates must contain no
|
||||||
inline-скриптов и inline-стилей.
|
inline scripts and no inline styles.
|
||||||
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
|
- **Encrypting backups and exports is an option, not the default.** With the
|
||||||
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
|
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||||
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
|
operator with nowhere to keep a password would lose the ability to take a
|
||||||
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
|
backup at all, and a permanently undecryptable archive is worse than an
|
||||||
не хранит. Триггером сделать шифрование обязательным считать появление
|
unencrypted one: SelfPost does not store the password. The trigger to make
|
||||||
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
|
encryption mandatory is a second administrator (at which point "who
|
||||||
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
|
downloaded it" stops being one person).
|
||||||
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
|
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||||
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
|
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||||
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
|
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||||
дочитывается. Остаток — строки, delivery-строки которых потеряны
|
read position (`logtail_state`, migration `0003`), so the tail is read after a
|
||||||
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
|
start. What remains are rows whose delivery lines are lost for good (the log
|
||||||
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
|
rotated past 14 files while the panel was down, or was deleted): the
|
||||||
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
|
reconciliation against `postqueue -p` sees the message is not in the queue and
|
||||||
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
|
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
|
||||||
не может подтвердить, не должна показываться как подтверждённая, а вечный
|
the status is a false negative. Accepted deliberately: a delivery the panel
|
||||||
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
|
cannot confirm must not be shown as confirmed, and a permanent `queued` is
|
||||||
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
|
indistinguishable from "in flight right now". Reconciliation does not run
|
||||||
читается. См. [architecture.md](architecture.md) § Log tailer.
|
until the tailer has read the log to the end, and touches nothing if
|
||||||
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
|
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
|
||||||
`/data/log` — `2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
|
tailer.
|
||||||
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
|
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
|
||||||
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
|
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
|
||||||
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
|
`postfix`) writes, the panel reads through the shared `selfpost` group, and
|
||||||
оставалась состоянием, а не диагностикой.
|
the file is inaccessible to others. The log holds envelope addresses and
|
||||||
|
client IPs, but neither message bodies nor headers; it is excluded from
|
||||||
|
backups (`log/` is skipped) so that a dump stays state rather than
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
## ADR: CSRF через проверку Origin, без токенов
|
## ADR: CSRF via origin checking, without tokens
|
||||||
|
|
||||||
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
|
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
||||||
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
|
CSRF surface. What is needed is a way to tell a request from the panel's own
|
||||||
инициированного сторонним сайтом в браузере залогиненного админа.
|
page apart from one initiated by a third-party site in the logged-in
|
||||||
|
administrator's browser.
|
||||||
|
|
||||||
**Решение.** `originAllowed` в
|
**Decision.** `originAllowed` in
|
||||||
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
|
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
||||||
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
|
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
||||||
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
|
request carrying neither header is **allowed through** rather than rejected.
|
||||||
сессии и встроенных в формы, нет.
|
There are no session-bound tokens embedded in forms.
|
||||||
|
|
||||||
**Почему не токены.** Панель однопользовательская (один администратор на
|
**Why not tokens.** The panel is single-user (one administrator per instance) —
|
||||||
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
|
the threat model does not include cross-user CSRF inside the panel itself, only
|
||||||
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
|
an external site making the administrator's browser send a request. The origin
|
||||||
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
|
check covers that without touching a single template: a token would need a
|
||||||
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
|
hidden field in roughly two dozen forms and synchronisation with every new form,
|
||||||
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
|
and it would still not protect against XSS inside the panel — code executing in
|
||||||
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
|
the panel's origin reads the token and sends the request itself. XSS is handled
|
||||||
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
|
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
|
||||||
обороны, не CSRF-токен.
|
not a CSRF token.
|
||||||
|
|
||||||
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
|
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
||||||
(по-настоящему старый браузер или webview с замороженным движком), остаётся
|
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
||||||
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
|
"Accepted risks" above. This is a deliberate choice not to break the panel in
|
||||||
панель в таком клиенте ценой узкой остаточной поверхности.
|
such a client, at the price of a narrow residual surface.
|
||||||
|
|
||||||
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
|
**Revisit if:** a requirement appears for protection that does not depend on
|
||||||
браузера, или панель станет многопользовательской.
|
browser behaviour, or the panel becomes multi-user.
|
||||||
|
|
||||||
## Как этот список пополняется
|
## How this list grows
|
||||||
|
|
||||||
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
|
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
|
||||||
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
|
closes every finding in one of two ways: a fix before the tag, or an entry here
|
||||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
with its rationale and its condition for revisiting, like the items above.
|
||||||
Третьего варианта («посмотрели и ладно») нет.
|
There is no third option ("we looked at it and moved on").
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||||
// for application credentials (architecture.md § Mail path). The panel is the
|
// for application credentials (architecture.md § Mail path). The panel is the
|
||||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||||
// created and removed with the standard saslpasswd2 tool ("эквивалент
|
// created and removed with the standard saslpasswd2 tool.
|
||||||
// saslpasswd2", per the plan).
|
|
||||||
type SASLDB struct {
|
type SASLDB struct {
|
||||||
path string // sasldb2 file, under /data so it survives restarts
|
path string // sasldb2 file, under /data so it survives restarts
|
||||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
// field after a rejected submission; the password fields are never repopulated.
|
// field after a rejected submission; the password fields are never repopulated.
|
||||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
||||||
s.render(w, status, "account", map[string]any{
|
s.render(w, status, "account", map[string]any{
|
||||||
"Title": "SelfPost — account",
|
"Title": "SelfPost — settings",
|
||||||
"User": currentUser(r),
|
"User": currentUser(r),
|
||||||
"Active": "account",
|
"Active": "account",
|
||||||
"FormUsername": formUsername,
|
"FormUsername": formUsername,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Account</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{{template "sections" .}}
|
{{template "sections" .}}
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="muted">{{.User}}</span>
|
<span class="muted">User: {{.User}}</span>
|
||||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
|
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user