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