diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6c1a4..e9f9fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version - **The signature covers `Content-Type`, `MIME-Version` and `Message-ID`** when the message has them (audit M6), and a header line too long to be delivered unbroken is named in the log as a body line is (M8). +- **A session made under a longer `PANEL_SESSION_IDLE_DAYS` is cut to the + current window** at its next request (audit A10), not kept until its old + expiry. - **The TLS private key no longer has to be readable by Postfix** (audit I1): the guide gave it to group 10998 for the panel's backup, and `postfix` is in that group, so every Postfix daemon - an smtpd talking to @@ -103,6 +106,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Documentation +- security.md says what the code does where it said more (audit A6, A7, + W7): a wrong method answers `405` before the role is looked at; the setup + token is made anew at the first request after it expired; two GETs make + the server's own DKIM key on first view. The README's local trial says + that the compose file is the hardened deployment. + - **The upstream's "Required" TLS checks no certificate**, and the guide and Help now say so (audit M7): it guards against a listener, not against one who answers in the upstream's place. diff --git a/README.md b/README.md index 634b3ee..a51329b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,11 @@ docker run --rm -d --name selfpost-try \ ghcr.io/mixeme/selfpost:2.14.3 ``` +This trial runs with Docker's default capabilities and no +`no-new-privileges`; the compose file of a real deployment +(`deploy/docker-compose.yml`) drops every capability but the few it names, +sets `no-new-privileges` and a process ceiling. + **Get the setup URL** (pick one): ```sh diff --git a/docs/security-audit-2026-10-17.md b/docs/security-audit-2026-10-17.md index 76fd4ff..3687323 100644 --- a/docs/security-audit-2026-10-17.md +++ b/docs/security-audit-2026-10-17.md @@ -850,8 +850,11 @@ the argument. - [x] **M7.** guide: upstream `encrypt` checks no certificate. — **Sonnet**, 0.25 h. *Done 2026-10-17:* guide and Help. (Offering `secure` is hours and a `domain`-user-facing choice; propose it on the roadmap, not here.) -- [ ] **A6, A7, A10, W7, M6, M8** and the README note — half an hour each, - or the wording change named under each. — **Sonnet**, 2 h. +- [x] **A6, A7, A10, W7, M6, M8** and the README note — half an hour each, + or the wording change named under each. — **Sonnet**, 2 h. *Done 2026-10-17:* A10 and + M6, M8 in code (a session from a longer window is cut at the next check; + three headers signed; a long header line logged); A6, A7, W7 by wording + in security.md - each is what the code should do; the README note. - [ ] **Doc drift sweep** — every line under "Doc and comment drift" above. — **Sonnet**, 1 h. - **Accept, with the argument:** *the setuid/setgid binaries of the base diff --git a/docs/security.md b/docs/security.md index 850dcb3..4930502 100644 --- a/docs/security.md +++ b/docs/security.md @@ -87,8 +87,12 @@ The panel is exposed to the internet — the items below are **not optional**. - A one-time secret link `/setup/`, **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. +- Token lifetime — **10 minutes**; a restart with setup unfinished makes a + new one, and so does the first `/setup/` request after it expired - any + request, a stranger's `GET /setup/x` included, which then logs the new + token in the container log, where only the operator reads it (audit + 2026-10-17, A7: nothing reaches the requester; the wording said "after + expiry"). - Rate limiting on `/setup/` per client address (an IPv6 one per /64), separate from login. - Token comparison is **constant-time** (`subtle.ConstantTimeCompare`). @@ -220,7 +224,10 @@ assigned outbound domains and inbound domains **separately**, each list with - A path a user may not reach, a domain or application that does not exist, and one that belongs to another tenant answer the **same 404**, so the panel does not confirm what exists. A group with nothing assigned is absent from - the user's menu. + the user's menu. A route asked with a method it does not take answers + `405` with `Allow`, from the router, before the role is looked at: the + routes are the public source's, so that says nothing the code does not + (audit 2026-10-17, A6). - `/server/` is guarded once, by `globalOnly` where its routes are registered (a route outside the subtree cannot be added through that helper), and every handler checks again. Outbound and inbound handlers check per object @@ -244,8 +251,11 @@ assigned outbound domains and inbound domains **separately**, each list with ### State-changing requests -- Every state change is a `POST`; a `GET` never changes state (the delete - confirmation pages only render a form). +- Every state change is a `POST`; a `GET` changes no state a user chose + (the delete confirmation pages only render a form). Two make what has to + exist when it does not: Server › Settings and Health generate the server's + own DKIM key on their first view, and the first `/setup/` request after + the token expired makes a new one (audit 2026-10-17, W7, A7). - Every non-read request first passes the origin check (`originAllowed`, [internal/web/security.go](../internal/web/security.go)): `Sec-Fetch-Site` must be `same-origin`, or `Origin` must name the panel's own diff --git a/internal/web/auth/auth_test.go b/internal/web/auth/auth_test.go index 40bd8e3..3d4f0f7 100644 --- a/internal/web/auth/auth_test.go +++ b/internal/web/auth/auth_test.go @@ -379,3 +379,22 @@ func TestRequireAuthDoesNotRenewASessionWhoseUserIsGone(t *testing.T) { t.Errorf("expiry of a missing user's session moved to %v", row.ExpiresAt) } } + +// A session made under a longer idle window is cut to the current one at its +// next renewal check, not kept until its old expiry (security audit +// 2026-10-17, A10). +func TestSessionMadeUnderALongerWindowIsCut(t *testing.T) { + s := newTestSessionStore(t) // seven days + token := mustCreate(t, s, "admin") + s.idle = 24 * time.Hour // PANEL_SESSION_IDLE_DAYS lowered to one + if !s.Touch(token) { + t.Fatal("the session from the longer window was not cut") + } + row, _, err := s.store.LookupSession(hashToken(token)) + if err != nil || row.ExpiresAt.After(time.Now().Add(24*time.Hour+time.Minute)) { + t.Errorf("expires %v (%v), want within a day", row.ExpiresAt, err) + } + if s.Touch(token) { + t.Error("a session within the window was renewed again at once") + } +} diff --git a/internal/web/auth/session.go b/internal/web/auth/session.go index 625441f..8de670e 100644 --- a/internal/web/auth/session.go +++ b/internal/web/auth/session.go @@ -98,8 +98,19 @@ func (s *sessionStore) Touch(token string) bool { if !found { return false } - lastRenewal := row.ExpiresAt.Add(-s.idle) now := time.Now() + // A session made under a longer PANEL_SESSION_IDLE_DAYS expires later + // than today's window allows: it is cut to the window at once, rather + // than keep the old expiry until it passes (security audit 2026-10-17, + // A10). + if row.ExpiresAt.After(now.Add(s.idle)) { + if err := s.store.RenewSession(hash, now.Add(s.idle)); err != nil { + logf("panel: session: shorten failed: %v", err) + return false + } + return true + } + lastRenewal := row.ExpiresAt.Add(-s.idle) if now.Sub(lastRenewal) < renewThreshold { return false }