release: 1.3.0: 1.3.0-1.3.1: security pass, queue persist, full backup layout
[1.3.0] Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration. Added: - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex - docs: agreed roadmap item **queue-retries** — show this Postfix's retry Security: - The independent security review of the send-log authorization and Fixed: - test (e2e): send-log status scrapers follow the badge markup in - security (panel): the Deliveries list is scoped to a domain administrator's - mail (level-2 rate limit): the ceiling is no longer overshot by messages that - panel (sign-in): a session that cannot be written to the database no longer - panel (applications): deleting an application removes its SASL credentials … (2 more in CHANGELOG) Changed: - docs: operator and as-built docs aligned with the code after a full - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7) - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in - ci: the release image is published only for a **published** GitHub Release … (9 more in CHANGELOG) [1.3.1] Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration. Added: - Mail queue and a delivery's history show this Postfix's retry policy - docs: **Plan checklists** in [development.md](docs/development.md) — format, - docs: **Implementation checklists** with model routing in - docs: roadmap candidates **send-log-retention** (panel Settings for delivery - Postfix queue under `/data/postfix/queue` — deferred and active mail survive … (1 more in CHANGELOG) Changed: - **Breaking:** full-backup archive layout — paths are prefixed with `data/`; - docs: operator guide, architecture, security, and backup UI updated for - docs: HTML mockups for a full panel UI refresh Changed: - docs: panel UI mockups after review — Status restored to live readings - docs: panel UI design system and mockups rebuilt as separate pages - docs: panel UI mockups — add-domain sits in the list card (Domains and Fixed: - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped Squashed CHANGELOG sections: [1.3.0], [1.3.1]. Full detail: CHANGELOG.md (1.3.0, 1.3.1).
This commit is contained in:
+64
-28
@@ -5,7 +5,12 @@ 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** —
|
||||
([internal/app/sasl.go](../internal/app/sasl.go)). The 2026-08-14 review
|
||||
(code-review plan § P7, Fable; reviewer ≠ author) covered the P0–P1 diff of the
|
||||
2026-08-13 full-tree review against this document — send-log authorization for
|
||||
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
|
||||
session creation, and application-delete ordering: no findings, and nothing
|
||||
needed adding to the accepted risks. (2) **Accepted risks** —
|
||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||
lost.
|
||||
|
||||
@@ -34,8 +39,8 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
- 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.
|
||||
- The administrator password is bcrypt in SQLite only; no plaintext and no
|
||||
MD5.
|
||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||
|
||||
### Application SASL passwords
|
||||
@@ -81,10 +86,11 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
|
||||
### Backup and domain export
|
||||
|
||||
- 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 files are secrets: a full backup carries DKIM keys, `sasldb2`, the
|
||||
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
|
||||
private key from `certs/` when present; 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 —
|
||||
@@ -112,19 +118,34 @@ deferred item from the roadmap.
|
||||
- **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`
|
||||
deliberately: every panel user (global or domain-admin) is an operator who
|
||||
picks their own browser, not an untrusted party the panel needs to defend
|
||||
against, 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.
|
||||
revisit is a requirement for protection that holds regardless of the browser,
|
||||
or a domain-admin population the global administrator does not fully trust
|
||||
(see the ADR below). 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.
|
||||
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
|
||||
Delete, regenerate-password, and clear-rate-limit forms carry a
|
||||
`data-confirm` prompt handled entirely in
|
||||
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
|
||||
or blocked the form submits immediately, exactly as it did before the
|
||||
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
|
||||
not an authorization boundary — the same origin check and session/RBAC
|
||||
gate every one of these `POST`s whether or not JavaScript ran. Progressive
|
||||
enhancement means the panel must work with JavaScript off; a
|
||||
server-rendered confirmation step would need a second page (or a `?confirm=1`
|
||||
round trip) for every one of these forms, which is what
|
||||
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
|
||||
`domain_delete.html` already do for the two highest-blast-radius deletes.
|
||||
- **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
|
||||
@@ -158,24 +179,35 @@ deferred item from the roadmap.
|
||||
|
||||
**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.
|
||||
page apart from one initiated by a third-party site in a logged-in user's
|
||||
browser. The panel is multi-user since 1.2.0 (a global administrator plus
|
||||
zero or more domain-admin users, each scoped to their assigned domains), but
|
||||
that is an authorization boundary (who can see or change what), not a change
|
||||
to the CSRF threat: the attacker in scope here is still an external site
|
||||
riding a legitimate user's cookie, not one panel user attacking another
|
||||
through the browser.
|
||||
|
||||
**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.
|
||||
There are no session-bound tokens embedded in forms. The check applies the same
|
||||
way regardless of the requesting user's role.
|
||||
|
||||
**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.
|
||||
**Why not tokens.** Cross-user CSRF is not the threat model here: a
|
||||
domain-admin's browser sending a request still needs that domain-admin's own
|
||||
cookie, so a token would not add a boundary between roles that the
|
||||
authorization checks (`Principal.CanAccessDomain`,
|
||||
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
|
||||
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
|
||||
don't already enforce. The remaining case is an external site making a
|
||||
logged-in user's browser send a request, which the origin check covers 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.
|
||||
|
||||
**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
|
||||
@@ -183,7 +215,11 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s
|
||||
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.
|
||||
browser behaviour, or domain-admin accounts stop being trusted operators (for
|
||||
example, if a future release lets a global administrator invite domain-admins
|
||||
whose browsers/devices are not vetted) — at that point cross-role request
|
||||
forgery inside the panel would need its own analysis, separate from the
|
||||
external-site case this ADR covers.
|
||||
|
||||
## How this list grows
|
||||
|
||||
|
||||
Reference in New Issue
Block a user