Compare commits
3
Commits
7eb168f418
...
a4cfa11323
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4cfa11323 | ||
|
|
236cb07769 | ||
|
|
04993e0da3 |
+20
-1
@@ -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
@@ -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
|
||||
# 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
@@ -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").
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user