Repository: supabase/gotrue
Pull request #75 (2021-03-06 09:35:49)

Feature/multiple whitelist
Feat: Implemented  logic according to issue #63
Added separate environment variable GOTRUE_URI_ALLOW_LIST which can accept list of domains that are permited to handle.
Added tests to verify handler. Test it with domains like http://test.com and mobile myApp://app/home. At that cases app works fine, but if there are some others - let me know. I'll test it.



Patch:
diff --git a/README.md b/README.md
index 8fb736b5..52631e58 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,10 @@ GOTRUE_SITE_URL=https://example.netlify.com/
 
 The base URL your site is located at. Currently used in combination with other settings to construct URLs used in emails.
 
+`URI_ALLOW_LIST` - `string`
+
+A comma separated list of domains that can be handled by backend. Defaults to [].
+
 `OPERATOR_TOKEN` - `string` _Multi-instance mode only_
 
 The shared secret with an operator (usually Netlify) for this microservice. Used to verify requests have been proxied through the operator and
diff --git a/api/helpers.go b/api/helpers.go
index 30422a30..e4c762c8 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/http/httptrace"
 	"net/url"
+	"strings"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
@@ -99,16 +100,26 @@ func (a *API) requestAud(ctx context.Context, r *http.Request) string {
 func (a *API) getReferrer(r *http.Request) string {
 	ctx := r.Context()
 	config := a.getConfig(ctx)
-	referrer := ""
-	if reqref := r.Referer(); reqref != "" {
-		base, berr := url.Parse(config.SiteURL)
-		refurl, rerr := url.Parse(reqref)
-		// As long as the referrer came from the site, we will redirect back there
-		if berr == nil && rerr == nil && base.Hostname() == refurl.Hostname() {
-			referrer = reqref
+	reqref := r.Referer()
+	if reqref == "" {
+		return ""
+	}
+
+	base, berr := url.Parse(config.SiteURL)
+	refurl, rerr := url.Parse(reqref)
+	// As long as the referrer came from the site, we will redirect back there
+	if berr == nil && rerr == nil && base.Hostname() == refurl.Hostname() {
+		return reqref
+	}
+
+	// For case when user came from mobile app or other permitted resource - redirect back
+	for _, uri := range config.URIAllowList {
+		if strings.HasPrefix(reqref, uri) {
+			return reqref
 		}
 	}
-	return referrer
+
+	return ""
 }
 
 // validateRedirectURL ensures any redirect URL is from a safe origin
@@ -116,14 +127,25 @@ func (a *API) validateRedirectURL(r *http.Request, reqref string) string {
 	ctx := r.Context()
 	config := a.getConfig(ctx)
 	redirectURL := config.SiteURL
-	if reqref != "" {
-		base, berr := url.Parse(config.SiteURL)
-		refurl, rerr := url.Parse(reqref)
-		// As long as the referrer came from the site, we will redirect back there
-		if berr == nil && rerr == nil && base.Hostname() == refurl.Hostname() {
-			redirectURL = reqref
+	if reqref == "" {
+		return redirectURL
+	}
+
+	base, berr := url.Parse(config.SiteURL)
+	refurl, rerr := url.Parse(reqref)
+
+	// As long as the referrer came from the site, we will redirect back there
+	if berr == nil && rerr == nil && base.Hostname() == refurl.Hostname() {
+		return reqref
+	}
+
+	// For case when user came from mobile app or other permitted resource - redirect back
+	for _, uri := range config.URIAllowList {
+		if strings.HasPrefix(reqref, uri) {
+			return reqref
 		}
 	}
+
 	return redirectURL
 }
 
diff --git a/conf/configuration.go b/conf/configuration.go
index bec53bbf..d1a27df9 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -109,7 +109,8 @@ type MailerConfiguration struct {
 
 // Configuration holds all the per-instance configuration.
 type Configuration struct {
-	SiteURL           string                `json:"site_url" split_words:"true" required:"true"`
+	SiteURL       string                `json:"site_url" split_words:"true" required:"true"`
+	URIAllowList  []string              `json:"uri_allow_list" split_words:"true"`
 	PasswordMinLength int                   `json:"password_min_length" default:"6"`
 	JWT               JWTConfiguration      `json:"jwt"`
 	SMTP              SMTPConfiguration     `json:"smtp"`
@@ -229,6 +230,10 @@ func (config *Configuration) ApplyDefaults() {
 	if config.Cookie.Duration == 0 {
 		config.Cookie.Duration = 86400
 	}
+
+	if config.URIAllowList == nil {
+		config.URIAllowList = []string{}
+	}
 }
 
 func (config *Configuration) Value() (driver.Value, error) {


Test patch:
diff --git a/api/verify_test.go b/api/verify_test.go
index 4103f8d6..ed1705d3 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -3,8 +3,10 @@ package api
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 	"time"
 
@@ -146,3 +148,90 @@ func (ts *VerifyTestSuite) TestExpiredRecoveryToken() {
 
 	assert.Equal(ts.T(), http.StatusGone, w.Code, w.Body.String())
 }
+
+func (ts *VerifyTestSuite) TestVerifyPermitedCustomUri() {
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	u.RecoverySentAt = &time.Time{}
+	require.NoError(ts.T(), ts.API.db.Update(u))
+
+	// Request body
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email": "test@example.com",
+	}))
+
+	// Setup request
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+
+	// Setup response recorder
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusOK, w.Code)
+
+	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+
+	assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second)
+	assert.False(ts.T(), u.IsConfirmed())
+
+	redirectUrl, _ := url.Parse(ts.Config.URIAllowList[0])
+
+	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "recovery", u.RecoveryToken, redirectUrl.String())
+	req = httptest.NewRequest(http.MethodGet, reqURL, nil)
+
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
+	rUrl, _ := w.Result().Location()
+	assert.Equal(ts.T(), redirectUrl.Hostname(), rUrl.Hostname())
+
+	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	assert.True(ts.T(), u.IsConfirmed())
+}
+
+func (ts *VerifyTestSuite) TestVerifyNotPermitedCustomUri() {
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	u.RecoverySentAt = &time.Time{}
+	require.NoError(ts.T(), ts.API.db.Update(u))
+
+	// Request body
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email": "test@example.com",
+	}))
+
+	// Setup request
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+
+	// Setup response recorder
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusOK, w.Code)
+
+	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+
+	assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second)
+	assert.False(ts.T(), u.IsConfirmed())
+
+	fakeRedirectUrl, _ := url.Parse("http://custom-url.com")
+	siteUrl, _ := url.Parse(ts.Config.SiteURL)
+
+	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "recovery", u.RecoveryToken, fakeRedirectUrl.String())
+	req = httptest.NewRequest(http.MethodGet, reqURL, nil)
+
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
+	rUrl, _ := w.Result().Location()
+	assert.Equal(ts.T(), siteUrl.Hostname(), rUrl.Hostname())
+
+	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	assert.True(ts.T(), u.IsConfirmed())
+}