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>
This commit is contained in:
mix
2026-10-16 23:16:27 +03:00
co-authored by Claude Opus 5.5
parent af778445d4
commit 503939c6cf
9 changed files with 58 additions and 28 deletions
+8 -1
View File
@@ -162,7 +162,9 @@ func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
t.Fatal(err)
}
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)
if err != nil {
t.Fatal(err)
@@ -176,4 +178,9 @@ func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
if code := post("null"); code != http.StatusForbidden {
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
}
// 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) {
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 {
return nil, "", err
}