Compare commits

..
2 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 a1724ad72c release: 2.10.4: origin-strict - a change with neither header is refused
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. A POST with neither
Sec-Fetch-Site nor Origin is refused (the commit before this); the e2e
client sends Origin. go vet, go test (26 packages) and make e2e passed on
the test server for that tree. No migration, schema 12.

CHANGELOG [2.10.4]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 36; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:28 +03:00
mixandClaude Opus 5.5 503939c6cf origin-strict: a change with neither Sec-Fetch-Site nor Origin is refused
Agreed with the owner on 2026-10-13 (in place of csrf-tokens), taken up on
2026-10-16 ("Да"). originAllowed (internal/web/security.go) returns false
in the "neither header" branch: such a POST is answered 403 and logged
like a cross-origin one. It was let through until now as an accepted
risk, which left a browser that sends neither open to a request forged by
any site. A browser that follows Fetch § 3.2 never sends a POST without
Origin; one too old to (Firefox before 70) can no longer save anything.

What posts to the panel without a browser: the e2e suite's client, which
now sends Origin on every form (panelClient.postForm); cmd/panel's tests
already did. Nothing else posts to it (the CLI runs in the container).

Tests: security_test's case of no headers is refused; e2e posts with
neither header and gets 403 (testFormPostWithTheBrowsersOrigin). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: security.md - the accepted risk struck, the ADR's decision and
trade-off say refused, the origin-check bullet; architecture § Web,
guide § the proxy; CHANGELOG [Unreleased] § Security; roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:27 +03:00
12 changed files with 72 additions and 37 deletions
+14
View File
@@ -5,6 +5,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [2.10.4] - 2026-10-16
The origin check is strict. No migration; schema 12.
### Security
- **A change with no word of where it came from is refused**
(origin-strict): a `POST` that carries neither `Sec-Fetch-Site` nor
`Origin` is answered 403, as a cross-origin one is. It was let through
before, as an accepted risk, which left a browser that sends neither open
to a request forged by any site. Every current browser sends `Origin` with
a form; a browser too old to (Firefox before 70) can no longer save
anything in the panel.
## [2.10.3] - 2026-10-16 ## [2.10.3] - 2026-10-16
The backup asks for your password. No migration; schema 12. The backup asks for your password. No migration; schema 12.
+3 -3
View File
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
> local image and pulls nothing: > local image and pulls nothing:
> >
> ```sh > ```sh
> docker build -f build/Dockerfile --build-arg VERSION=2.10.3 \ > docker build -f build/Dockerfile --build-arg VERSION=2.10.4 \
> -t ghcr.io/mixeme/selfpost:2.10.3 . > -t ghcr.io/mixeme/selfpost:2.10.4 .
> ``` > ```
```sh ```sh
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.10.3 ghcr.io/mixeme/selfpost:2.10.4
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+1 -1
View File
@@ -23,7 +23,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:2.10.3 image: ghcr.io/mixeme/selfpost:2.10.4
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+2 -1
View File
@@ -994,7 +994,8 @@ send it (an older one, or a current one over plain HTTP at a name other than
`localhost`), `Origin` must name the panel's host - which a form of the panel `localhost`), `Origin` must name the panel's host - which a form of the panel
carries under its `Referrer-Policy: same-origin`, the page's own origin (under carries under its `Referrer-Policy: same-origin`, the page's own origin (under
`no-referrer` it would be `null` and refused); a request with neither header `no-referrer` it would be `null` and refused); a request with neither header
is let through (an accepted risk). A refusal logs the `Sec-Fetch-Site`, is refused too (origin-strict, `2.10.4`; it was let through before, as an
accepted risk). A refusal logs the `Sec-Fetch-Site`,
`Origin` and `Host` it compared. `Origin` and `Host` it compared.
There are no CSRF tokens — see [security.md](security.md). There are no CSRF tokens — see [security.md](security.md).
+2 -1
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ Release image](#release-image)).
**Planned version cuts** (from pin `2.10.3`; adjust if semver changes mid-track): **Planned version cuts** (from pin `2.10.4`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -146,6 +146,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 33 | long, non-repeating queue ids | `2.10.1` (PATCH) | | 33 | long, non-repeating queue ids | `2.10.1` (PATCH) |
| 34 | phone-desktop-mode | `2.10.2` (PATCH) | | 34 | phone-desktop-mode | `2.10.2` (PATCH) |
| 35 | backup-reauth | `2.10.3` (PATCH) | | 35 | backup-reauth | `2.10.3` (PATCH) |
| 36 | origin-strict | `2.10.4` (PATCH) |
Docs-only prep (checklists in plans, no product code) uses the same per-step Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships. commit rule but **no** version cut until the next product stage ships.
+7 -4
View File
@@ -204,14 +204,14 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `2.10.3`. **No 2.x image is deliberately never `:latest`. The current pin is `2.10.4`. **No 2.x image is
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
it from the checkout under the pinned name, and Compose uses the local image it from the checkout under the pinned name, and Compose uses the local image
instead of pulling: instead of pulling:
```sh ```sh
docker build -f build/Dockerfile --build-arg VERSION=2.10.3 \ docker build -f build/Dockerfile --build-arg VERSION=2.10.4 \
-t ghcr.io/mixeme/selfpost:2.10.3 . -t ghcr.io/mixeme/selfpost:2.10.4 .
``` ```
`VERSION` must equal the tag: it is the version the backup check compares `VERSION` must equal the tag: it is the version the backup check compares
@@ -295,6 +295,9 @@ browser that does not send `Sec-Fetch-Site` (Safari before 16.4, or any
browser on a panel served over plain HTTP at a name): there the panel compares browser on a panel served over plain HTTP at a name): there the panel compares
`Origin` with `Host` and refuses every change as cross-origin - the log says `Origin` with `Host` and refuses every change as cross-origin - the log says
so explicitly, printing the `Sec-Fetch-Site`, `Origin` and `Host` it compared. so explicitly, printing the `Sec-Fetch-Site`, `Origin` and `Host` it compared.
A request that carries neither header - a browser too old to send `Origin`,
or a script posting to the panel without one - is refused the same way
since 2.10.4.
In every case the proxy terminates HTTPS for the panel; the resulting In every case the proxy terminates HTTPS for the panel; the resulting
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem` — certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem` —
@@ -950,7 +953,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.: the panel exits with a message naming the tag to use, e.g.:
``` ```
backup: this backup was created by SelfPost 2.10.2 but this image is 2.10.3 — restore into the matching image (selfpost:2.10.2) backup: this backup was created by SelfPost 2.10.3 but this image is 2.10.4 — restore into the matching image (selfpost:2.10.3)
``` ```
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
+7 -1
View File
@@ -32,7 +32,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | agreed (2026-10-13) | — | — | | health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | agreed (2026-10-13) | — | — |
| test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — | | test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — |
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) | | inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
| origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | agreed (2026-10-13) | — | — | | origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | shipped `2.10.4` | — | — |
| backup-reauth | Ask for the account password before a backup or a domain export is downloaded | shipped `2.10.3` | — | — | | backup-reauth | Ask for the account password before a backup or a domain export is downloaded | shipped `2.10.3` | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | | panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
@@ -224,6 +224,12 @@ panel without a browser (the e2e stand, scripts) and make it send `Origin`.
it, the e2e stand passes, the accepted risk about header-less requests is it, the e2e stand passes, the accepted risk about header-less requests is
struck from [security.md](security.md) and its ADR states the strict rule. struck from [security.md](security.md) and its ADR states the strict rule.
**Done (2026-10-16):** `originAllowed` returns false in the "neither
header" branch; the unit test's case is refused, e2e posts with neither
header and gets 403, and the e2e suite's client sends `Origin` on every
form it posts. The accepted risk is struck from security.md and the ADR's
decision says refused; architecture and the guide say so.
**Dependencies / risks:** a browser that sends neither header can no longer **Dependencies / risks:** a browser that sends neither header can no longer
save anything in the panel - accepted, the panel's users pick their own save anything in the panel - accepted, the panel's users pick their own
browsers. Tokens stay a recorded alternative, not a task: the conditions under browsers. Tokens stay a recorded alternative, not a task: the conditions under
+9 -15
View File
@@ -204,6 +204,8 @@ assigned outbound domains and inbound domains **separately**, each list with
host (the panel's `Referrer-Policy` is `same-origin`, so a browser puts the host (the panel's `Referrer-Policy` is `same-origin`, so a browser puts the
panel's own origin on a form post of the panel); the session cookie is panel's own origin on a form post of the panel); the session cookie is
`SameSite` as well. `SameSite` as well.
- A request with neither header is refused as well (origin-strict,
`2.10.4`).
- There are **no CSRF tokens**. The decision, its price and the conditions - There are **no CSRF tokens**. The decision, its price and the conditions
for revisiting it are in the accepted risks and the ADR below. for revisiting it are in the accepted risks and the ADR below.
@@ -512,17 +514,6 @@ operator can act on it.
An accepted risk is a decision with a condition for revisiting it, not a An accepted risk is a decision with a condition for revisiting it, not a
deferred item from the roadmap. 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: every panel user (role `global` or `domain`) 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. That tightening
is agreed (owner, 2026-10-13) as
[origin-strict](roadmap.md#origin-strict); this bullet goes when it ships.
- **Session-bound CSRF tokens are not implemented.** The origin check closes the - **Session-bound CSRF tokens are not implemented.** The origin check closes the
neighbouring-subdomain case but depends on browser behaviour; a token does 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 not. The price is a hidden field in roughly two dozen forms. The trigger to
@@ -844,7 +835,8 @@ through the browser.
**Decision.** `originAllowed` in **Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site` [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 (when the browser sends it) or `Origin` (fallback) against the panel's host; a
request carrying neither header is **allowed through** rather than rejected. request carrying neither header is **refused** (403), since `2.10.4`
(origin-strict; until then it was let through as an accepted risk).
There are no session-bound tokens embedded in forms. The check applies the same There are no session-bound tokens embedded in forms. The check applies the same
way regardless of the requesting user's role. way regardless of the requesting user's role.
@@ -864,9 +856,11 @@ 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. 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 **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 genuinely old browser - Firefox before 70 sent no Origin with a same-origin
"Accepted risks" above. This is a deliberate choice not to break the panel in form - or a webview with a frozen engine) can no longer save anything in the
such a client, at the price of a narrow residual surface. panel: refused rather than left open to a request forged by any site. Until
`2.10.4` the choice was the other way round, as an accepted risk. A script
that posts to the panel sends `Origin` itself (the e2e suite's client does).
**Revisit if:** a requirement appears for protection that does not depend on **Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or `domain` accounts stop being trusted operators (for browser behaviour, or `domain` accounts stop being trusted operators (for
+8 -8
View File
@@ -146,14 +146,14 @@ func originAllowed(r *http.Request) bool {
// https. // https.
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if origin == "" { if origin == "" {
// Neither header. A client this old cannot be checked at all; it is // Neither header: nothing says where the request came from, so it is
// let through as the risk consciously accepted (single-admin panel, // refused (origin-strict, owner 2026-10-13; until 2.10.4 it was let
// the administrator picks the browser). Turning this return into // through as an accepted risk). By Fetch § 3.2 a browser that follows
// false is the whole of the stricter policy. By Fetch § 3.2 a browser // it never sends a POST without Origin; Firefox did for a same-origin
// that follows it never sends a POST without Origin; Firefox did for a // form before 70 (MDN browser-compat-data 6.0.0, http.headers.Origin),
// same-origin form before 70 (MDN browser-compat-data 6.0.0, // and such a browser can no longer save anything here. A script that
// http.headers.Origin). // posts to the panel sends Origin itself (test/e2e, panelClient).
return true return false
} }
u, err := url.Parse(origin) u, err := url.Parse(origin)
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
+1 -1
View File
@@ -42,7 +42,7 @@ func TestOriginCheck(t *testing.T) {
req *http.Request req *http.Request
wantRejected bool wantRejected bool
}{ }{
{"no headers at all is let through (accepted risk)", post("", ""), false}, {"no headers at all is refused (origin-strict)", post("", ""), true},
{"same-origin", post("same-origin", "https://panel.example.com"), false}, {"same-origin", post("same-origin", "https://panel.example.com"), false},
{"same-site, i.e. a neighbouring subdomain", post("same-site", ""), true}, {"same-site, i.e. a neighbouring subdomain", post("same-site", ""), true},
{"cross-site", post("cross-site", "https://evil.example.net"), true}, {"cross-site", post("cross-site", "https://evil.example.net"), true},
+8 -1
View File
@@ -162,7 +162,9 @@ func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
t.Fatal(err) t.Fatal(err)
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", origin) if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := sc.panel.http.Do(req) resp, err := sc.panel.http.Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -176,4 +178,9 @@ func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
if code := post("null"); code != http.StatusForbidden { if code := post("null"); code != http.StatusForbidden {
t.Fatalf("POST with Origin: null: status %d, want 403", code) t.Fatalf("POST with Origin: null: status %d, want 403", code)
} }
// Neither Sec-Fetch-Site nor Origin: nothing says where it came from, and
// it is refused (origin-strict, 2.10.4; allowed before as an accepted risk).
if code := post(""); code != http.StatusForbidden {
t.Fatalf("POST with neither header: status %d, want 403", code)
}
} }
+10 -1
View File
@@ -85,8 +85,17 @@ func readSetupToken(s *stack) (string, error) {
return strings.TrimPrefix(u.Path, "/setup/"), nil return strings.TrimPrefix(u.Path, "/setup/"), nil
} }
// postForm posts a form as the panel's own page would: with the Origin a
// browser sends, which the panel requires of every state-changing request
// (origin-strict; internal/web/security.go, originAllowed).
func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) { func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) {
resp, err := c.http.PostForm(panelBaseURL+path, form) req, err := http.NewRequest(http.MethodPost, panelBaseURL+path, strings.NewReader(form.Encode()))
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", panelBaseURL)
resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }