Repository: supabase/gotrue
Pull request #632 (2022-08-24 08:37:50)

fix: skip capcha if admin jwt in header 
## What kind of change does this PR introduce?
* Allows a user to skip captcha validation if an admin JWT is present in the header
* Allows one to bypass captcha validation on a trusted client (e.g. supabase's user management dashboard)
* Add test for bypassing captcha with admin jwt


Patch:
diff --git a/api/auth.go b/api/auth.go
index cd16f8fb..6c42066c 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -55,10 +55,6 @@ func (a *API) requireAdmin(ctx context.Context, w http.ResponseWriter, r *http.R
 
 func (a *API) extractBearerToken(w http.ResponseWriter, r *http.Request) (string, error) {
 	authHeader := r.Header.Get("Authorization")
-	if authHeader == "" {
-		return "", unauthorizedError("This endpoint requires a Bearer token")
-	}
-
 	matches := bearerRegexp.FindStringSubmatch(authHeader)
 	if len(matches) != 2 {
 		return "", unauthorizedError("This endpoint requires a Bearer token")
diff --git a/api/middleware.go b/api/middleware.go
index c4c925c3..a7122dee 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -17,10 +17,6 @@ import (
 	jwt "github.com/golang-jwt/jwt"
 )
 
-const (
-	jwsSignatureHeaderName = "x-nf-sign"
-)
-
 type FunctionHooks map[string][]string
 
 type NetlifyMicroserviceClaims struct {
@@ -52,15 +48,6 @@ func (f *FunctionHooks) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-func (a *API) loadJWSSignatureHeader(w http.ResponseWriter, r *http.Request) (context.Context, error) {
-	ctx := r.Context()
-	signature := r.Header.Get(jwsSignatureHeaderName)
-	if signature == "" {
-		return nil, badRequestError("Operator microservice headers missing")
-	}
-	return withSignature(ctx, signature), nil
-}
-
 func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler {
 	return func(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 		c := req.Context()
@@ -114,13 +101,12 @@ func (a *API) limitEmailSentHandler() middlewareHandler {
 }
 
 func (a *API) requireAdminCredentials(w http.ResponseWriter, req *http.Request) (context.Context, error) {
-	ctx := req.Context()
 	t, err := a.extractBearerToken(w, req)
 	if err != nil || t == "" {
 		return nil, err
 	}
 
-	ctx, err = a.parseJWTClaims(t, req, w)
+	ctx, err := a.parseJWTClaims(t, req, w)
 	if err != nil {
 		return nil, err
 	}
@@ -145,6 +131,10 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	if !config.Security.Captcha.Enabled {
 		return ctx, nil
 	}
+	if _, err := a.requireAdminCredentials(w, req); err == nil {
+		// skip captcha validation if authorization header contains an admin role
+		return ctx, nil
+	}
 	if config.Security.Captcha.Provider != "hcaptcha" {
 		logrus.WithField("provider", config.Security.Captcha.Provider).Warn("Unsupported captcha provider")
 		return nil, internalServerError("server misconfigured")
@@ -164,8 +154,8 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	} else if verificationResult == security.UserRequestFailed {
 		return nil, badRequestError("request disallowed")
 	}
-	if verificationResult == security.SuccessfullyVerified {
-		return ctx, nil
+	if verificationResult != security.SuccessfullyVerified {
+		return nil, internalServerError("")
 	}
-	return nil, internalServerError("")
+	return ctx, nil
 }


Test patch:
diff --git a/api/admin_test.go b/api/admin_test.go
index bb708a2b..548bd974 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -42,25 +42,12 @@ func TestAdmin(t *testing.T) {
 func (ts *AdminTestSuite) SetupTest() {
 	models.TruncateAll(ts.API.db)
 	ts.Config.External.Email.Enabled = true
-	ts.token = ts.makeSuperAdmin("")
-}
-
-func (ts *AdminTestSuite) makeSuperAdmin(email string) string {
-	u, err := models.NewUser("9123456", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"})
-	require.NoError(ts.T(), err, "Error making new user")
-
-	u.Role = "supabase_admin"
-
-	token, err := generateAccessToken(u, "", time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
-	require.NoError(ts.T(), err, "Error generating access token")
-
-	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
-	_, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) {
-		return []byte(ts.Config.JWT.Secret), nil
-	})
-	require.NoError(ts.T(), err, "Error parsing token")
-
-	return token
+	claims := &GoTrueClaims{
+		Role: "supabase_admin",
+	}
+	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
+	require.NoError(ts.T(), err, "Error generating admin jwt")
+	ts.token = token
 }
 
 // TestAdminUsersUnauthorized tests API /admin/users route without authentication
diff --git a/api/middleware_test.go b/api/middleware_test.go
index eff2bee2..4ef66204 100644
--- a/api/middleware_test.go
+++ b/api/middleware_test.go
@@ -9,6 +9,7 @@ import (
 	"net/http/httptest"
 	"testing"
 
+	jwt "github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/conf"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -40,44 +41,70 @@ func TestHCaptcha(t *testing.T) {
 }
 
 func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    "test@example.com",
-		"password": "secret",
-		"gotrue_meta_security": map[string]interface{}{
-			"captcha_token": HCaptchaResponse,
-		},
-	}))
-
 	ts.Config.Security.Captcha.Enabled = true
 	ts.Config.Security.Captcha.Provider = "hcaptcha"
 	ts.Config.Security.Captcha.Secret = HCaptchaSecret
 
-	req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	beforeCtx := context.Background()
-	req = req.WithContext(beforeCtx)
-
-	w := httptest.NewRecorder()
-
-	afterCtx, err := ts.API.verifyCaptcha(w, req)
+	adminClaims := &GoTrueClaims{
+		Role: "supabase_admin",
+	}
+	adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, adminClaims).SignedString([]byte(ts.Config.JWT.Secret))
 	require.NoError(ts.T(), err)
-
-	body, err := ioutil.ReadAll(req.Body)
-
-	// re-initialize buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    "test@example.com",
-		"password": "secret",
-		"gotrue_meta_security": map[string]interface{}{
-			"captcha_token": HCaptchaResponse,
+	cases := []struct {
+		desc          string
+		adminJwt      string
+		captcha_token string
+	}{
+		{
+			"Valid captcha response",
+			"",
+			HCaptchaResponse,
 		},
-	}))
+		{
+			"Ignore captcha if admin role is present",
+			adminJwt,
+			"",
+		},
+	}
+	for _, c := range cases {
+		var buffer bytes.Buffer
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+			"email":    "test@example.com",
+			"password": "secret",
+			"gotrue_meta_security": map[string]interface{}{
+				"captcha_token": c.captcha_token,
+			},
+		}))
+		req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
+		req.Header.Set("Content-Type", "application/json")
+		if c.adminJwt != "" {
+			req.Header.Set("Authorization", "Bearer "+c.adminJwt)
+		}
+
+		beforeCtx := context.Background()
+		req = req.WithContext(beforeCtx)
+
+		w := httptest.NewRecorder()
+
+		afterCtx, err := ts.API.verifyCaptcha(w, req)
+		require.NoError(ts.T(), err)
+
+		body, err := ioutil.ReadAll(req.Body)
+		require.NoError(ts.T(), err)
+
+		// re-initialize buffer
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+			"email":    "test@example.com",
+			"password": "secret",
+			"gotrue_meta_security": map[string]interface{}{
+				"captcha_token": c.captcha_token,
+			},
+		}))
 
-	// check if body is the same
-	require.Equal(ts.T(), body, buffer.Bytes())
-	require.Equal(ts.T(), afterCtx, beforeCtx)
+		// check if body is the same
+		require.Equal(ts.T(), body, buffer.Bytes())
+		require.Equal(ts.T(), afterCtx, beforeCtx)
+	}
 }
 
 func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {