fix(admin): accept same-origin first-run setup requests (#1280)

* fix(admin): accept same-origin first-run setup requests

A freshly generated config.yaml leaves allowed_origins commented out, so the
list is empty. The setup handler's CSRF guard assumed "no Origin header means
same-origin", but browsers send Origin on same-origin POSTs too — Chrome and
Edge always, Firefox since 70. The admin panel's own setup call is one of those
POSTs, so every new install hit "cross-origin setup request blocked" and could
never create an owner account.

The guard now accepts a request whose Origin names the same host:port as the
request's own Host header, falling back to the allowlist otherwise. That is what
the original comment intended. CSRF protection is unaffected: a cross-site
attacker cannot set Origin, the browser does, and a foreign origin still needs
an explicit allowlist entry.

Scheme is not compared. Nothing in this server derives the external scheme (no
r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would
reject legitimate requests behind a TLS-terminating proxy.

Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin
cases, plus two handler-level tests pinning both halves — same-origin succeeds
against an empty allowlist, a foreign origin still 403s and creates no user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(identity): implement identity keypair caching and error handling

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-30 21:50:13 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 4959e2fa40
commit 675ed230f3
5 changed files with 309 additions and 9 deletions
+32 -3
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -57,10 +58,10 @@ func handleSetupStatus(database *db.DB) http.HandlerFunc {
func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// CSRF protection: reject cross-origin requests (BUG-097).
// If Origin is present and doesn't match allowed origins, deny.
// No Origin header = same-origin or non-browser client (allow).
// A request is accepted when it is same-origin, or when its Origin is
// explicitly allowlisted. Absent Origin = non-browser client (allow).
if origin := r.Header.Get("Origin"); origin != "" {
if !isSetupOriginAllowed(origin, allowedOrigins) {
if !isSameOrigin(origin, r.Host) && !isSetupOriginAllowed(origin, allowedOrigins) {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "cross-origin setup request blocked")
return
}
@@ -165,6 +166,34 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st
}
}
// isSameOrigin reports whether a browser-supplied Origin names this same
// server, by comparing its host:port against the request's Host header.
//
// Browsers send Origin on same-origin POSTs too (Chrome and Edge always,
// Firefox since 70), so the admin panel's own first-run setup call arrives
// carrying one. Without this check it is measured against allowed_origins,
// which is empty in a freshly generated config — so setup failed with
// "cross-origin setup request blocked" on every new install.
//
// Scheme is deliberately not compared. Nothing in this server derives the
// external scheme (there is no r.TLS or X-Forwarded-Proto handling anywhere),
// so a TLS-terminating proxy in front would make a scheme check reject
// legitimate requests. Matching host:port is enough: forging it requires
// already serving content on this exact host and port, at which point the
// origin is not the attacker's to borrow. Cross-site attackers cannot set
// Origin at all — the browser does.
func isSameOrigin(origin, host string) bool {
if host == "" {
return false
}
u, err := url.Parse(origin)
// Require a scheme so a schemeless "//host:port" cannot pass as same-origin.
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
return strings.EqualFold(u.Host, host)
}
// isSetupOriginAllowed checks if the given origin is permitted by the
// configured allowed_origins list. Wildcard "*" allows any origin.
// An empty list denies all cross-origin requests (safe default).
+57
View File
@@ -1,10 +1,12 @@
package admin_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/owncord/server/admin"
@@ -194,3 +196,58 @@ func TestSetup_ConcurrentRace(t *testing.T) {
t.Errorf("user count = %d, want 1", count)
}
}
// A freshly generated config leaves allowed_origins empty, and browsers send an
// Origin header on same-origin POSTs. Before isSameOrigin existed, that pairing
// made first-run setup fail on every new install with "cross-origin setup
// request blocked". These two tests pin both halves: same-origin gets through
// on an empty allowlist, and a foreign origin still does not.
func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database))
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest("POST", "/setup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// httptest.NewRequest sets Host to example.com; the browser would send the
// matching Origin for a page served from this same server.
req.Header.Set("Origin", "https://"+req.Host)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup with same-origin Origin = %d, want 201; body: %s", rr.Code, rr.Body.String())
}
}
func TestSetup_ForeignOriginStillBlocked(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database))
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest("POST", "/setup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("POST /setup from a foreign origin = %d, want 403", rr.Code)
}
count, err := database.UserCount(context.Background())
if err != nil {
t.Fatalf("UserCount: %v", err)
}
if count != 0 {
t.Fatalf("owner account created from a foreign origin: user count = %d, want 0", count)
}
}
+88
View File
@@ -90,3 +90,91 @@ func TestIsSetupOriginAllowed(t *testing.T) {
})
}
}
// isSameOrigin is what lets the admin panel's own setup call through on a
// default config, where allowed_origins is empty. It must not become a hole:
// only an Origin naming this exact host:port may pass.
func TestIsSameOrigin(t *testing.T) {
tests := []struct {
name string
origin string
host string
want bool
}{
{
name: "admin panel on the default port is same-origin",
origin: "https://localhost:8443",
host: "localhost:8443",
want: true,
},
{
name: "host comparison is case-insensitive",
origin: "https://LocalHost:8443",
host: "localhost:8443",
want: true,
},
{
name: "plain http against a proxied host still matches",
origin: "http://chat.example",
host: "chat.example",
want: true,
},
{
name: "a different host is not same-origin",
origin: "https://evil.example",
host: "localhost:8443",
want: false,
},
{
name: "a different port is not same-origin",
origin: "https://localhost:9999",
host: "localhost:8443",
want: false,
},
{
name: "loopback by IP does not match loopback by name",
origin: "https://127.0.0.1:8443",
host: "localhost:8443",
want: false,
},
{
name: "a suffix of the host is not same-origin",
origin: "https://evil-localhost:8443",
host: "localhost:8443",
want: false,
},
{
name: "schemeless origin cannot pass as same-origin",
origin: "//localhost:8443",
host: "localhost:8443",
want: false,
},
{
name: "opaque origin (sandboxed iframe) is denied",
origin: "null",
host: "localhost:8443",
want: false,
},
{
name: "empty origin is denied",
origin: "",
host: "localhost:8443",
want: false,
},
{
name: "empty host denies rather than matching an empty origin host",
origin: "https://localhost:8443",
host: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSameOrigin(tt.origin, tt.host); got != tt.want {
t.Errorf("isSameOrigin(%q, %q) = %v, want %v",
tt.origin, tt.host, got, tt.want)
}
})
}
}