Compare commits

...

3 Commits

Author SHA1 Message Date
mix a4cfa11323 docs: retarget the CHANGELOG pointer to the renamed security.md section
test / test (push) Has been cancelled
The 0.5.0 entry pointed at docs/security.md § "Резервная копия и экспорт
домена", a heading that no longer exists after the file was translated. The
name is updated to the current heading; the entry otherwise stands as written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:45:19 +03:00
mix 236cb07769 docs: translate security.md and the last Russian source comments
security.md is linked from the README documentation table and now from
SECURITY.md, so a reader following either link landed in a Russian document
while everything around it was English. Translated in full; the requirements,
the accepted risks, and the CSRF ADR are unchanged in substance.

The reviewing model is no longer named in the text — that the pre-release
review ran, and when, is what a reader needs; who ran it is process detail
kept in development.md.

extract-cert.sh keeps its spec 10.3 quotation, translated. In sasl.go the
quotation from the closed plan is dropped rather than translated: rendered in
English it restated the sentence it hung off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:39:53 +03:00
mix 04993e0da3 docs: add security policy
A public repository with no stated disclosure channel routes a finder into
opening a public issue, which discloses a relay flaw to everyone the moment it
is filed. SECURITY.md points at the repository private vulnerability reporting
instead, with public@mixeme.ru as fallback, and states scope so operator-side
configuration (blocked port 25, missing PTR, proxy TLS) does not arrive as a
report.

No response time is promised: a deadline that cannot be honoured by a single
maintainer is worse than none. Silence is explicitly not a request for a
continued embargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:32:09 +03:00
5 changed files with 263 additions and 148 deletions
+20 -1
View File
@@ -5,8 +5,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [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.
@@ -376,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
unrecognised status word, a capitalised one, and a cleanup line.
- 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: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
release commit (move to `docs/archive/`, retarget its references in README,
+81
View File
@@ -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.
+4 -4
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
# PEM"). Run this on the host, after Traefik has issued or renewed the
# certificate, and again on a schedule (cron/systemd timer) since acme.json
# is not itself watched by SelfPost/Postfix.
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
# step is required"). Run this on the host, after Traefik has issued or
# renewed the certificate, and again on a schedule (cron/systemd timer) since
# acme.json is not itself watched by SelfPost/Postfix.
#
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu
+157 -141
View File
@@ -1,177 +1,193 @@
# Безопасность
# Security
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
defence-in-depth — `--` перед логином в argv `saslpasswd2`
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски**
сознательные отступления сверх обязательного, чтобы решение не потерялось.
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
in full: no exploitable findings; one defence-in-depth change — `--` before the
login in the `saslpasswd2` argv
([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
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
cookie with duplicate detection — Phase 14) is done; the history is in
[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).
---
## Обязательные требования
## Mandatory requirements
Панель публична из интернета — пункты ниже **не опциональны**.
The panel is exposed to the internet — the items below are **not optional**.
### Первичная инициализация администратора
### First-run administrator setup
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
настройки — перегенерация и новый вывод в лог.
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
ready-made password hash.
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
unfinished, it is regenerated and logged again.
- Rate limiting on `/setup/<token>` per IP, separate from login.
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
- Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed).
- 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
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
перевыпуск.
- The panel **generates** the password on creation or reissue and shows it
**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 символов); клиентская не считается
защитой.
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
- `postfix reload` и любой `exec`**без** shell-интерполяции пользовательского
ввода; аргументы отдельными элементами.
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
- 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
domain before it is written.
- `postfix reload` and any `exec` run **without** shell interpolation of user
input; arguments are passed as separate elements.
- Writes to config files are escaped (no injection of Postfix directives).
### Аутентификация и сессии
### Authentication and sessions
- Rate limiting на логин (по IP, с блокировкой/задержкой).
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
(`PANEL_SESSION_IDLE_DAYS`).
- Rate limiting on login (per IP, with lockout or delay).
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
`SameSite`.
- 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` с автоэкранированием (очередь, лог, журнал, темы).
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
группу `selfpost` и минимальные права.
- Rendering goes through `html/template` with auto-escaping (queue, log,
journal, themes).
- 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`.
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
при rate-limit логина; пусто = XFF игнорируется.
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
`encrypt`).
- `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` и хеш пароля
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
текстом (иначе перенос без пересоздания кредов невозможен).
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
подменённый файл не открывается вместо тихого восстановления «хвоста».
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
шифрование по magic файла, а не по расширению.
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
аргументом (список процессов читается любым процессом контейнера).
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
offline и подбирается без ограничений по времени.
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
administrator's password hash; a domain export carries the DKIM key and
**working** application passwords in the clear (otherwise a transfer without
recreating credentials would be impossible).
- 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 —
a truncated or substituted file fails to open instead of silently restoring a
partial "tail". Format and wrapper:
[internal/secretfile](../internal/secretfile/secretfile.go).
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
bytes, not by extension.
- The password is never stored: without it the file cannot be recovered. In the
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
`-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` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
Принято сознательно: панель однопользовательская, админ выбирает браузер
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
панель. Ужесточение — одна строка в `originAllowed`
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
вместо `true` в ветке «нет обоих заголовков».
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
вопросу считать появление требования «устойчиво независимо от браузера».
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
панели, отправит запрос сам — против этого работают автоэкранирование
`html/template` и CSP, поэтому шаблоны не должны содержать
inline-скриптов и inline-стилей.
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
не хранит. Триггером сделать шифрование обязательным считать появление
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
дочитывается. Остаток — строки, delivery-строки которых потеряны
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
не может подтвердить, не должна показываться как подтверждённая, а вечный
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
читается. См. [architecture.md](architecture.md) § Log tailer.
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
`/data/log``2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
оставалась состоянием, а не диагностикой.
- **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
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
panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch.
- **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.
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`
auto-escaping and CSP do the work, which is why templates must contain no
inline scripts and no inline styles.
- **Encrypting backups and exports is an option, not the default.** With the
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
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
downloaded it" stops being one person).
- **A journal row left without delivery lines is closed as `bounced` rather
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
`/data/log/` and survives container recreation, and the log tailer keeps its
read position (`logtail_state`, migration `0003`), so the tail is read after a
start. What remains are rows whose delivery lines are lost for good (the log
rotated past 14 files while the panel was down, or was deleted): the
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
cannot confirm must not be shown as confirmed, and a permanent `queued` is
indistinguishable from "in flight right now". Reconciliation does not run
until the tailer has read the log to the end, and touches nothing if
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
tailer.
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
`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` в
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
сессии и встроенных в формы, нет.
**Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
(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.
**Почему не токены.** Панель однопользовательская (один администратор на
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
обороны, не CSRF-токен.
**Why not tokens.** The panel is single-user (one administrator per instance) —
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
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,
and it would still not protect against XSS inside the panel — code executing in
the panel's origin reads the token and sends the request itself. XSS is handled
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
not a CSRF token.
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
(по-настоящему старый браузер или webview с замороженным движком), остаётся
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
панель в таком клиенте ценой узкой остаточной поверхности.
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
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
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
Третьего варианта («посмотрели и ладно») нет.
The pre-release vulnerability review (history — CHANGELOG `[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").
+1 -2
View File
@@ -12,8 +12,7 @@ import (
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (architecture.md § Mail path). The panel is the
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
// created and removed with the standard saslpasswd2 tool ("эквивалент
// saslpasswd2", per the plan).
// created and removed with the standard saslpasswd2 tool.
type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts
realm string // SASL realm, so lookups match what Postfix's SASL uses