Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package api
import (
"context"
"net/http"
"net/url"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"

Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
156 changes: 156 additions & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
})
}
}
15 changes: 15 additions & 0 deletions api/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions conf/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading