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.
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ func main() {
|
|||||||
natsPort = flag.Int("nats-port", 4250, "embedded NATS listen port (when --nats-url empty)")
|
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")
|
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)")
|
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")
|
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")
|
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.
|
// Cluster (issue 0003a): empty --cluster-name keeps the server standalone.
|
||||||
@@ -329,6 +331,13 @@ func main() {
|
|||||||
Cluster: clustered,
|
Cluster: clustered,
|
||||||
Store: *storeBackend,
|
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
|
// 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
|
// share its nonce store across the cluster, or a request accepted on one node
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,16 @@ type Server struct {
|
|||||||
// posture a secure cluster requires (audit 0008 N1). It is set by the command;
|
// posture a secure cluster requires (audit 0008 N1). It is set by the command;
|
||||||
// the zero value (all false) reflects an unsecured dev node.
|
// the zero value (all false) reflects an unsecured dev node.
|
||||||
Posture Posture
|
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
|
// 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) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
now := time.Now()
|
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
|
// 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
|
// shed at the cheapest possible point. The health probe is exempt so liveness
|
||||||
// checks are never throttled.
|
// 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)))
|
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
|
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
||||||
// when the body exceeds its limit, so the middleware can map it to 413.
|
// when the body exceeds its limit, so the middleware can map it to 413.
|
||||||
func isBodyTooLarge(err error) bool {
|
func isBodyTooLarge(err error) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user