diff --git a/api/api.go b/api/api.go index 88f3bf2a..4926a324 100644 --- a/api/api.go +++ b/api/api.go @@ -3,9 +3,11 @@ package api import ( "context" "net/http" + "net/url" "os" "os/signal" "regexp" + "strings" "syscall" "time" @@ -16,6 +18,7 @@ import ( "github.com/imdario/mergo" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/mailer" + "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" "github.com/rs/cors" "github.com/sirupsen/logrus" @@ -219,16 +222,97 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati }) } - corsHandler := cors.New(cors.Options{ + corsOptions := cors.Options{ AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", audHeaderName, useCookieHeader}, AllowCredentials: true, - }) + } - api.handler = corsHandler.Handler(r) + // permissiveCors preserves the historical default: Access-Control-Allow-Origin: *. + // Used for instances that have not opted in to strict security. + permissiveCors := cors.New(corsOptions).Handler(r) + + // strictCors reflects only allowlisted origins. Used per-instance when + // Security.Strict is set. The wrapper below stashes the resolved config on + // the request context so this func does not look it up again. + strictOptions := corsOptions + strictOptions.AllowOriginVaryRequestFunc = func(req *http.Request, origin string) (bool, []string) { + cfg := getCORSConfig(req.Context()) + if cfg == nil { + return false, nil + } + return originAllowed(cfg, origin), nil + } + strictCors := cors.New(strictOptions).Handler(r) + + api.handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // rs/cors is a no-op when Origin is absent, so non-CORS requests can + // skip the per-instance resolve entirely. In multi-instance mode this + // is the difference between a JWS parse + DB lookup on every + // server-to-server request and no extra work at all. + if req.Header.Get("Origin") != "" { + if cfg := api.configForCORS(ctx, req); cfg != nil && cfg.Security.Strict { + strictCors.ServeHTTP(w, req.WithContext(withCORSConfig(req.Context(), cfg))) + return + } + } + permissiveCors.ServeHTTP(w, req) + }) return api } +// configForCORS resolves the per-instance config for a CORS request. The CORS +// wrapper runs before any per-request middleware including loadInstanceConfig, +// so in multi-instance mode we resolve the instance config ourselves from the +// JWS signature header. +func (a *API) configForCORS(baseCtx context.Context, r *http.Request) *conf.Configuration { + if !a.config.MultiInstanceMode { + if cfg, ok := baseCtx.Value(configKey).(*conf.Configuration); ok { + return cfg + } + return nil + } + sig := r.Header.Get(jwsSignatureHeaderName) + if sig == "" { + return nil + } + claims, err := a.parseOperatorJWS(sig) + if err != nil || claims.InstanceID == "" { + return nil + } + instanceID, err := uuid.FromString(claims.InstanceID) + if err != nil { + return nil + } + instance, err := models.GetInstance(a.db, instanceID) + if err != nil { + return nil + } + cfg, err := instance.Config() + if err != nil { + return nil + } + if claims.SiteURL != "" { + cfg.SiteURL = claims.SiteURL + } + return cfg +} + +func originAllowed(config *conf.Configuration, origin string) bool { + allowed := config.Security.AllowedCORSOrigins + if len(allowed) == 0 { + if su, err := url.Parse(config.SiteURL); err == nil && su.Scheme != "" && su.Host != "" { + allowed = []string{su.Scheme + "://" + su.Host} + } + } + for _, entry := range allowed { + if strings.EqualFold(entry, origin) { + return true + } + } + return false +} + // NewAPIFromConfigFile creates a new REST API using the provided configuration file. func NewAPIFromConfigFile(filename string, version string) (*API, *conf.Configuration, error) { globalConfig, err := conf.LoadGlobal(filename) diff --git a/api/api_test.go b/api/api_test.go index c878af03..1f28da4c 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -2,9 +2,12 @@ package api import ( "context" + "net/http" + "net/http/httptest" "testing" "github.com/gofrs/uuid" + jwt "github.com/golang-jwt/jwt/v4" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" @@ -92,3 +95,156 @@ func TestEmailEnabledByDefault(t *testing.T) { require.False(t, api.config.External.Email.Disabled) } + +// TestCORS_FlagOffPreservesWildcard guards the core safety property: when an +// instance has not opted in, the CORS response is byte-for-byte the historical +// default (Access-Control-Allow-Origin: *), not a reflected origin. Runs +// without a database because preflight handling never reaches the router. +func TestCORS_FlagOffPreservesWildcard(t *testing.T) { + config := &conf.Configuration{} + config.ApplyDefaults() + ctx, err := WithInstanceConfig(context.Background(), config, uuid.Nil) + require.NoError(t, err) + api := NewAPIWithVersion(ctx, &conf.GlobalConfiguration{}, nil, "test") + + req := httptest.NewRequest(http.MethodOptions, "/settings", nil) + req.Header.Set("Origin", "https://anything.example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + + require.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) +} + +// TestCORS_FlagOnRestrictsOrigin verifies that with Security.Strict the +// allowlist is enforced: a non-listed origin gets no Allow-Origin header, and +// the SiteURL origin is reflected. +func TestCORS_FlagOnRestrictsOrigin(t *testing.T) { + config := &conf.Configuration{SiteURL: "https://app.example.com"} + config.Security.Strict = true + config.ApplyDefaults() + ctx, err := WithInstanceConfig(context.Background(), config, uuid.Nil) + require.NoError(t, err) + api := NewAPIWithVersion(ctx, &conf.GlobalConfiguration{}, nil, "test") + + disallowed := httptest.NewRequest(http.MethodOptions, "/settings", nil) + disallowed.Header.Set("Origin", "https://evil.example.com") + disallowed.Header.Set("Access-Control-Request-Method", "GET") + wd := httptest.NewRecorder() + api.handler.ServeHTTP(wd, disallowed) + require.Empty(t, wd.Header().Get("Access-Control-Allow-Origin")) + + allowed := httptest.NewRequest(http.MethodOptions, "/settings", nil) + allowed.Header.Set("Origin", "https://app.example.com") + allowed.Header.Set("Access-Control-Request-Method", "GET") + wa := httptest.NewRecorder() + api.handler.ServeHTTP(wa, allowed) + require.Equal(t, "https://app.example.com", wa.Header().Get("Access-Control-Allow-Origin")) +} + +// TestCORS_MultiInstanceStrict exercises the multi-instance CORS path, where +// configForCORS resolves the instance config by parsing the x-nf-sign JWS +// header (the route middleware does not run for preflight requests). It +// asserts the per-instance allowlist is enforced. +func TestCORS_MultiInstanceStrict(t *testing.T) { + api, _, err := setupAPIForMultiinstanceTest() + require.NoError(t, err) + defer api.db.Close() + require.NoError(t, models.TruncateAll(api.db)) + + instanceID := uuid.Must(uuid.NewV4()) + require.NoError(t, api.db.Create(&models.Instance{ + ID: instanceID, + UUID: uuid.Must(uuid.NewV4()), + BaseConfig: &conf.Configuration{ + SiteURL: "https://app.example.com", + Security: conf.SecurityConfiguration{ + Strict: true, + AllowedCORSOrigins: []string{"https://app.example.com"}, + }, + }, + })) + + signature := func() string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, NetlifyMicroserviceClaims{ + InstanceID: instanceID.String(), + SiteURL: "https://app.example.com", + }) + signed, signErr := token.SignedString([]byte(api.config.OperatorToken)) + require.NoError(t, signErr) + return signed + }() + + preflight := func(origin string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodOptions, "/settings", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set(jwsSignatureHeaderName, signature) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + return w + } + + require.Equal(t, "https://app.example.com", preflight("https://app.example.com").Header().Get("Access-Control-Allow-Origin")) + require.Empty(t, preflight("https://evil.example.com").Header().Get("Access-Control-Allow-Origin")) + + // A preflight without the signature cannot be attributed to an instance, so + // it falls back to the permissive (wildcard) handler. + noSig := httptest.NewRequest(http.MethodOptions, "/settings", nil) + noSig.Header.Set("Origin", "https://evil.example.com") + noSig.Header.Set("Access-Control-Request-Method", "GET") + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, noSig) + require.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) +} + +func TestOriginAllowed(t *testing.T) { + cases := []struct { + name string + config *conf.Configuration + origin string + allowed bool + }{ + { + name: "matches configured allowlist", + config: &conf.Configuration{Security: conf.SecurityConfiguration{AllowedCORSOrigins: []string{"https://app.example.com"}}}, + origin: "https://app.example.com", + allowed: true, + }, + { + name: "rejects non-listed origin", + config: &conf.Configuration{Security: conf.SecurityConfiguration{AllowedCORSOrigins: []string{"https://app.example.com"}}}, + origin: "https://evil.com", + allowed: false, + }, + { + name: "case-insensitive match", + config: &conf.Configuration{Security: conf.SecurityConfiguration{AllowedCORSOrigins: []string{"https://APP.example.com"}}}, + origin: "https://app.example.com", + allowed: true, + }, + { + name: "empty allowlist falls back to SiteURL origin", + config: &conf.Configuration{SiteURL: "https://app.example.com/some/path"}, + origin: "https://app.example.com", + allowed: true, + }, + { + name: "empty allowlist rejects non-SiteURL origin", + config: &conf.Configuration{SiteURL: "https://app.example.com"}, + origin: "https://other.example.com", + allowed: false, + }, + { + name: "empty allowlist with invalid SiteURL rejects all", + config: &conf.Configuration{SiteURL: "not-a-url"}, + origin: "https://app.example.com", + allowed: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.allowed, originAllowed(tc.config, tc.origin)) + }) + } +} diff --git a/api/context.go b/api/context.go index b610d309..1432cd76 100644 --- a/api/context.go +++ b/api/context.go @@ -29,8 +29,23 @@ const ( externalReferrerKey = contextKey("external_referrer") functionHooksKey = contextKey("function_hooks") adminUserKey = contextKey("admin_user") + corsConfigKey = contextKey("cors_config") ) +// withCORSConfig stashes the instance config resolved by the CORS wrapper so +// the strict-origin check can reuse it without a second lookup. +func withCORSConfig(ctx context.Context, config *conf.Configuration) context.Context { + return context.WithValue(ctx, corsConfigKey, config) +} + +func getCORSConfig(ctx context.Context) *conf.Configuration { + obj := ctx.Value(corsConfigKey) + if obj == nil { + return nil + } + return obj.(*conf.Configuration) +} + // withToken adds the JWT token to the context. func withToken(ctx context.Context, token *jwt.Token) context.Context { return context.WithValue(ctx, tokenKey, token) diff --git a/api/middleware.go b/api/middleware.go index 7573cfbd..7bb11909 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -100,6 +100,19 @@ func (a *API) loadJWSSignatureHeader(w http.ResponseWriter, r *http.Request) (co return withSignature(ctx, signature), nil } +// parseOperatorJWS validates the operator JWS signature and returns the claims +// it carries. Shared by loadInstanceConfig (per-request middleware) and the +// CORS wrapper (which runs before middleware) so signing-method and operator +// token handling stay in one place. +func (a *API) parseOperatorJWS(signature string) (NetlifyMicroserviceClaims, error) { + claims := NetlifyMicroserviceClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err := p.ParseWithClaims(signature, &claims, func(*jwt.Token) (interface{}, error) { + return []byte(a.config.OperatorToken), nil + }) + return claims, err +} + func (a *API) loadInstanceConfig(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context() @@ -108,11 +121,7 @@ func (a *API) loadInstanceConfig(w http.ResponseWriter, r *http.Request) (contex return nil, badRequestError("Operator signature missing") } - claims := NetlifyMicroserviceClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} - _, err := p.ParseWithClaims(signature, &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(a.config.OperatorToken), nil - }) + claims, err := a.parseOperatorJWS(signature) if err != nil { return nil, badRequestError("Operator microservice signature is invalid: %v", err) } diff --git a/conf/configuration.go b/conf/configuration.go index 63fbdc17..ee2917c0 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -125,6 +125,10 @@ type MailerConfiguration struct { // follow-up changes. type SecurityConfiguration struct { Strict bool `json:"strict"` + + // AllowedCORSOrigins is the allowlist for CORS Origin when Strict. + // Empty list means only the SiteURL origin is accepted. + AllowedCORSOrigins []string `json:"allowed_cors_origins" envconfig:"ALLOWED_CORS_ORIGINS"` } // Configuration holds all the per-instance configuration. diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 5e15d29e..53b96d38 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -79,6 +79,15 @@ func TestSecurityConfigurationDefaults(t *testing.T) { require.NoError(t, err) assert.True(t, c.Security.Strict) }) + + t.Run("loads allowed CORS origins from env", func(t *testing.T) { + baseEnv() + os.Setenv("GOTRUE_SECURITY_STRICT", "true") + os.Setenv("GOTRUE_SECURITY_ALLOWED_CORS_ORIGINS", "https://app.example.com,https://preview.example.com") + c, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, []string{"https://app.example.com", "https://preview.example.com"}, c.Security.AllowedCORSOrigins) + }) } func TestNewInstancesSecureByDefault(t *testing.T) {