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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user