From ec8d34aaa1cc42a6cfd426223bd5c0b939e13c4a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 22:17:44 +0200 Subject: [PATCH] feat(membership): opt-in CORS allowlist for the browser-native client Add Server.AllowedOrigins and an applyCORS step at the top of ServeHTTP so a browser SPA (uniweb) can call the control plane cross-origin: an allow-listed Origin gets the Access-Control-Allow-* headers, and a preflight (OPTIONS) is answered 204 before the rate limiter and auth ever run. A disallowed or missing origin gets no headers (preflight 403), so the browser blocks the request. Wire it through membershipd's --cors-origins flag (comma list, reusing splitRoutes as a generic parser). Empty allowlist = CORS off, no headers emitted, behavior identical to before: native Go/Kotlin clients send no Origin and are unaffected. Opt-in per deployment (issue uniweb/0001, Phase 0). Tests: preflight allow/deny, header on the real response, CORS-off default, and no-Origin native client unaffected. --- cmd/membershipd/main.go | 9 +++ pkg/membership/cors_test.go | 150 ++++++++++++++++++++++++++++++++++++ pkg/membership/server.go | 67 ++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 pkg/membership/cors_test.go diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index 7637c0f9..a252e75d 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -56,6 +57,7 @@ func main() { natsPort = flag.Int("nats-port", 4250, "embedded NATS listen port (when --nats-url empty)") natsStore = flag.String("nats-store", "./local_files/jetstream", "embedded JetStream store dir") busAuth = flag.String("bus-auth", "off", "control-plane auth rollout: off|soft|enforce (feature flag bus-auth)") + corsOrigins = flag.String("cors-origins", "", "comma-separated CORS allowlist of browser origins permitted to call the control plane (e.g. http://localhost:5173,https://chat.example.com); empty = CORS off. Enables the browser-native uniweb client (issue uniweb/0001)") tlsCert = flag.String("tls-cert", "", "PATH to the NATS server certificate (deploy/tls/server.crt); enables TLS on the embedded data plane") tlsKey = flag.String("tls-key", "", "path to the NATS server private key (deploy/tls/server.key); required with --tls-cert") // Cluster (issue 0003a): empty --cluster-name keeps the server standalone. @@ -329,6 +331,13 @@ func main() { Cluster: clustered, Store: *storeBackend, } + // CORS allowlist for the browser-native client (uniweb). splitRoutes is reused + // as a generic comma-list parser (trim + drop empties). Empty flag => empty + // slice => CORS stays off, identical to the pre-flag behavior. + if origins := splitRoutes(*corsOrigins); len(origins) > 0 { + srv.AllowedOrigins = origins + log.Printf("CORS: allowing %d browser origin(s): %s", len(origins), strings.Join(origins, ", ")) + } // Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST // share its nonce store across the cluster, or a request accepted on one node diff --git a/pkg/membership/cors_test.go b/pkg/membership/cors_test.go new file mode 100644 index 00000000..a1786300 --- /dev/null +++ b/pkg/membership/cors_test.go @@ -0,0 +1,150 @@ +package membership_test + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/enmanuel/unibus/pkg/blobstore" + "github.com/enmanuel/unibus/pkg/membership" +) + +// newCORSServer builds a control-plane server with the given CORS allowlist over a +// throwaway store, and returns a live httptest server. /healthz is auth-exempt, so +// the CORS tests can exercise the cross-origin pipeline without signing requests. +func newCORSServer(t *testing.T, origins ...string) *httptest.Server { + t.Helper() + dir := t.TempDir() + store, err := membership.Open(filepath.Join(dir, "unibus.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { store.Close() }) + blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) + + srv := membership.NewServer(store, blobs, membership.AuthOff) + srv.AllowedOrigins = origins + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + return ts +} + +// TestCORSPreflightAllowedOrigin: a preflight (OPTIONS) from an allow-listed origin +// is answered 204 with the Access-Control headers, and never reaches auth. This is +// what lets the browser-native uniweb client call the control plane (issue +// uniweb/0001). +func TestCORSPreflightAllowedOrigin(t *testing.T) { + const origin = "http://localhost:5173" + ts := newCORSServer(t, origin) + + req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", "POST") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("preflight: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("Allow-Origin = %q, want %q", got, origin) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { + t.Fatalf("Allow-Methods missing on preflight") + } +} + +// TestCORSPreflightDisallowedOrigin: a preflight from an origin NOT in the allowlist +// gets 403 and no Access-Control headers, so the browser blocks the real request. +func TestCORSPreflightDisallowedOrigin(t *testing.T) { + ts := newCORSServer(t, "http://localhost:5173") + + req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil) + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("preflight: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("disallowed preflight status = %d, want 403", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin leaked for disallowed origin: %q", got) + } +} + +// TestCORSActualRequestCarriesHeader: a real GET from an allow-listed origin is +// served normally AND carries the Allow-Origin header so the browser accepts the +// response. +func TestCORSActualRequestCarriesHeader(t *testing.T) { + const origin = "http://localhost:5173" + ts := newCORSServer(t, origin) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil) + req.Header.Set("Origin", origin) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("Allow-Origin = %q, want %q", got, origin) + } +} + +// TestCORSDisabledByDefault: with an empty allowlist no Access-Control header is +// ever emitted (CORS off) and requests behave exactly as before. This guards the +// opt-in invariant: untouched deployments are unaffected. +func TestCORSDisabledByDefault(t *testing.T) { + ts := newCORSServer(t) // no origins + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil) + req.Header.Set("Origin", "http://localhost:5173") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin emitted with CORS off: %q", got) + } +} + +// TestCORSNativeClientUnaffected: a request with no Origin header (a native Go/Kotlin +// client) is processed normally and gets no CORS headers, even when an allowlist is +// configured. +func TestCORSNativeClientUnaffected(t *testing.T) { + ts := newCORSServer(t, "http://localhost:5173") + + resp, err := http.Get(ts.URL + "/healthz") // no Origin header + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin set for a no-Origin native client: %q", got) + } +} diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 198b0efb..08e82589 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -87,6 +87,16 @@ type Server struct { // posture a secure cluster requires (audit 0008 N1). It is set by the command; // the zero value (all false) reflects an unsecured dev node. Posture Posture + + // AllowedOrigins is the CORS allowlist of browser Origin headers permitted to + // call the control plane cross-origin. It exists so a browser-native client + // (uniweb) can talk to membershipd directly, the way the Go/Kotlin clients + // already do over a non-browser transport (issue uniweb/0001). Native clients + // send no Origin header and are unaffected. The zero value (empty) keeps CORS + // OFF — no Access-Control headers are emitted and the server behaves exactly as + // before — so this is opt-in per deployment. Entries are matched exactly (scheme + // + host + port); never use "*" with credentials. Set by the command from a flag. + AllowedOrigins []string } // Posture describes the security posture a membershipd node runs with. It is @@ -143,6 +153,15 @@ func (s *Server) UseReplicatedNonces(js jetstream.JetStream, replicas int) error func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { now := time.Now() + // CORS runs before everything else so a browser preflight never pays the rate + // limit or auth cost. When the request carries an allowed Origin we echo the + // Access-Control headers; a preflight (OPTIONS) is answered here and short- + // circuits the pipeline. With an empty allowlist this is a no-op, so non-browser + // clients and untouched deployments behave exactly as before (issue uniweb/0001). + if s.applyCORS(w, r) { + return // preflight handled + } + // Per-IP rate limit runs first, ahead of auth and body reads, so a flood is // shed at the cheapest possible point. The health probe is exempt so liveness // checks are never throttled. @@ -221,6 +240,54 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex))) } +// applyCORS handles cross-origin requests for the control plane. When the request +// carries an Origin in the allowlist it sets the Access-Control-Allow-* response +// headers so the browser accepts the eventual response; when the request is a CORS +// preflight (OPTIONS) it writes the preflight reply and returns true so ServeHTTP +// short-circuits before the rate limiter and auth ever run. It returns false for +// every non-preflight request — including same-origin and native clients that send +// no Origin header — leaving the normal pipeline to run unchanged. With an empty +// AllowedOrigins it never sets a header (CORS is off): the opt-in default. +func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) (preflight bool) { + origin := r.Header.Get("Origin") + allowed := origin != "" && s.originAllowed(origin) + if allowed { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + // Vary: Origin so a cache never serves an allow-listed response to another + // origin. Add (not Set) to preserve any Vary the handler may add later. + h.Add("Vary", "Origin") + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + h.Set("Access-Control-Max-Age", "600") + } + if r.Method == http.MethodOptions { + // Answer the preflight here so it never reaches the rate limiter or auth. An + // allowed origin gets 204 with the headers above; a disallowed or missing + // origin gets 403 with no Access-Control headers, so the browser blocks the + // real cross-origin request. + if allowed { + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusForbidden) + } + return true + } + return false +} + +// originAllowed reports whether origin is in the CORS allowlist. Matching is exact +// (scheme + host + port): a browser Origin is an opaque string, so an exact compare +// is both correct and the safest policy (no wildcard, no suffix tricks). +func (s *Server) originAllowed(origin string) bool { + for _, o := range s.AllowedOrigins { + if o == origin { + return true + } + } + return false +} + // isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader // when the body exceeds its limit, so the middleware can map it to 413. func isBodyTooLarge(err error) bool {