From f3a28d182d193cf528cc72a985dfeaf7ecb67056 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 2 Sep 2024 13:07:41 +0200 Subject: [PATCH] feat: add authorized email address support (#1757) Adds support for authorized email addresses, useful when needing to restrict email sending to a handful of email addresses. In the Supabase platform use case, this can be used to allow sending of emails on new projects only to the project's owners or developers, reducing email abuse. To enable it, specify `GOTRUE_EXTERNAL_EMAIL_AUTHORIZED_ADDRESSES` as a comma-delimited string of email addresses. The addresses should be lowercased and without labels. Labels are supported so emails will be sent to `someone+test1@gmail.com` and `someone+test2@gmail.com` if and only if the `someone@gmail.com` address is added in the authorized list. Not a substitute for blocklists! --- internal/api/admin.go | 4 ++-- internal/api/errorcodes.go | 3 ++- internal/api/invite.go | 2 +- internal/api/magic_link.go | 6 +++--- internal/api/mail.go | 28 +++++++++++++++++++++++---- internal/api/mail_test.go | 35 ++++++++++++++++++++++++++++++++++ internal/api/otp.go | 2 +- internal/api/recover.go | 6 +++--- internal/api/resend.go | 10 +++++----- internal/api/signup.go | 2 +- internal/api/user.go | 2 +- internal/api/verify.go | 8 ++++---- internal/api/verify_test.go | 2 +- internal/conf/configuration.go | 2 ++ 14 files changed, 85 insertions(+), 27 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index 0e5ae0cd93..45b08f0415 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -150,7 +150,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } if params.Email != "" { - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } @@ -343,7 +343,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { var providers []string if params.Email != "" { - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } diff --git a/internal/api/errorcodes.go b/internal/api/errorcodes.go index a37a4513a2..f6a8905660 100644 --- a/internal/api/errorcodes.go +++ b/internal/api/errorcodes.go @@ -83,5 +83,6 @@ const ( ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled" ErrorCodeMFAVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists" //#nosec G101 -- Not a secret value. - ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials" + ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials" + ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized" ) diff --git a/internal/api/invite.go b/internal/api/invite.go index 76852f711d..f0260dd979 100644 --- a/internal/api/invite.go +++ b/internal/api/invite.go @@ -26,7 +26,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error { } var err error - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go index e3fc0315b7..44f9fc88fd 100644 --- a/internal/api/magic_link.go +++ b/internal/api/magic_link.go @@ -21,12 +21,12 @@ type MagicLinkParams struct { CodeChallenge string `json:"code_challenge"` } -func (p *MagicLinkParams) Validate() error { +func (p *MagicLinkParams) Validate(a *API) error { if p.Email == "" { return unprocessableEntityError(ErrorCodeValidationFailed, "Password recovery requires an email") } var err error - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return err } @@ -57,7 +57,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { return badRequestError(ErrorCodeBadJSON, "Could not read verification params: %v", err).WithInternalError(err) } - if err := params.Validate(); err != nil { + if err := params.Validate(a); err != nil { return err } diff --git a/internal/api/mail.go b/internal/api/mail.go index 00bb58e7ea..c82214918e 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "regexp" "strings" "time" @@ -24,6 +25,7 @@ import ( var ( EmailRateLimitExceeded error = errors.New("email rate limit exceeded") + AddressNotAuthorized error = errors.New("Destination email address not authorized") ) type GenerateLinkParams struct { @@ -56,7 +58,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error { } var err error - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } @@ -230,7 +232,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error { if !config.Mailer.SecureEmailChangeEnabled && params.Type == "email_change_current" { return badRequestError(ErrorCodeValidationFailed, "Enable secure email change to generate link for current email") } - params.NewEmail, terr = validateEmail(params.NewEmail) + params.NewEmail, terr = a.validateEmail(params.NewEmail) if terr != nil { return terr } @@ -548,7 +550,9 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models return nil } -func validateEmail(email string) (string, error) { +var emailLabelPattern = regexp.MustCompile("[+][^@]+@") + +func (a *API) validateEmail(email string) (string, error) { if email == "" { return "", badRequestError(ErrorCodeValidationFailed, "An email address is required") } @@ -558,7 +562,23 @@ func validateEmail(email string) (string, error) { if err := checkmail.ValidateFormat(email); err != nil { return "", badRequestError(ErrorCodeValidationFailed, "Unable to validate email address: "+err.Error()) } - return strings.ToLower(email), nil + + email = strings.ToLower(email) + + if len(a.config.External.Email.AuthorizedAddresses) > 0 { + // allow labelled emails when authorization rules are in place + normalized := emailLabelPattern.ReplaceAllString(email, "@") + + for _, authorizedAddress := range a.config.External.Email.AuthorizedAddresses { + if normalized == authorizedAddress { + return email, nil + } + } + + return "", badRequestError(ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", email) + } + + return email, nil } func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration) error { diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index 90608a13ab..fd3de7c80c 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -48,6 +48,41 @@ func (ts *MailTestSuite) SetupTest() { require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new user") } +func (ts *MailTestSuite) TestValidateEmailAuthorizedAddresses() { + ts.Config.External.Email.AuthorizedAddresses = []string{"someone-a@example.com", "someone-b@example.com"} + defer func() { + ts.Config.External.Email.AuthorizedAddresses = nil + }() + + positiveExamples := []string{ + "someone-a@example.com", + "someone-b@example.com", + "someone-a+test-1@example.com", + "someone-b+test-2@example.com", + "someone-A@example.com", + "someone-B@example.com", + "someone-a@Example.com", + "someone-b@Example.com", + } + + negativeExamples := []string{ + "someone@example.com", + "s.omeone@example.com", + "someone-a+@example.com", + "someone+a@example.com", + } + + for _, example := range positiveExamples { + _, err := ts.API.validateEmail(example) + require.NoError(ts.T(), err) + } + + for _, example := range negativeExamples { + _, err := ts.API.validateEmail(example) + require.Error(ts.T(), err) + } +} + func (ts *MailTestSuite) TestGenerateLink() { // create admin jwt claims := &AccessTokenClaims{ diff --git a/internal/api/otp.go b/internal/api/otp.go index e690bdfe24..1821da3ee6 100644 --- a/internal/api/otp.go +++ b/internal/api/otp.go @@ -216,7 +216,7 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) aud := a.requestAud(ctx, r) var err error if params.Email != "" { - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return false, err } diff --git a/internal/api/recover.go b/internal/api/recover.go index cbcff81d8a..7c03c3246e 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -14,12 +14,12 @@ type RecoverParams struct { CodeChallengeMethod string `json:"code_challenge_method"` } -func (p *RecoverParams) Validate() error { +func (p *RecoverParams) Validate(a *API) error { if p.Email == "" { return badRequestError(ErrorCodeValidationFailed, "Password recovery requires an email") } var err error - if p.Email, err = validateEmail(p.Email); err != nil { + if p.Email, err = a.validateEmail(p.Email); err != nil { return err } if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil { @@ -38,7 +38,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { } flowType := getFlowFromChallenge(params.CodeChallenge) - if err := params.Validate(); err != nil { + if err := params.Validate(a); err != nil { return err } diff --git a/internal/api/resend.go b/internal/api/resend.go index a1f4246c8f..2c305360bc 100644 --- a/internal/api/resend.go +++ b/internal/api/resend.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/supabase/auth/internal/api/sms_provider" - "github.com/supabase/auth/internal/conf" mail "github.com/supabase/auth/internal/mailer" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" @@ -17,7 +16,9 @@ type ResendConfirmationParams struct { Phone string `json:"phone"` } -func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) error { +func (p *ResendConfirmationParams) Validate(a *API) error { + config := a.config + switch p.Type { case mail.SignupVerification, mail.EmailChangeVerification, smsVerification, phoneChangeVerification: break @@ -40,7 +41,7 @@ func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) er if !config.External.Email.Enabled { return badRequestError(ErrorCodeEmailProviderDisabled, "Email logins are disabled") } - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return err } @@ -63,13 +64,12 @@ func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) er func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config params := &ResendConfirmationParams{} if err := retrieveRequestParams(r, params); err != nil { return err } - if err := params.Validate(config); err != nil { + if err := params.Validate(a); err != nil { return err } diff --git a/internal/api/signup.go b/internal/api/signup.go index 0a1b8c6c49..22ac7dc022 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -141,7 +141,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if !config.External.Email.Enabled { return badRequestError(ErrorCodeEmailProviderDisabled, "Email signups are disabled") } - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } diff --git a/internal/api/user.go b/internal/api/user.go index 616ad23c7d..47ec8e9fd1 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -30,7 +30,7 @@ func (a *API) validateUserUpdateParams(ctx context.Context, p *UserUpdateParams) var err error if p.Email != "" { - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return err } diff --git a/internal/api/verify.go b/internal/api/verify.go index 5adc807448..dbd4d76c66 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -45,7 +45,7 @@ type VerifyParams struct { RedirectTo string `json:"redirect_to"` } -func (p *VerifyParams) Validate(r *http.Request) error { +func (p *VerifyParams) Validate(r *http.Request, a *API) error { var err error if p.Type == "" { return badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type") @@ -69,7 +69,7 @@ func (p *VerifyParams) Validate(r *http.Request) error { } p.TokenHash = crypto.GenerateTokenHash(p.Phone, p.Token) } else if isEmailOtpVerification(p) { - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return unprocessableEntityError(ErrorCodeValidationFailed, "Invalid email format").WithInternalError(err) } @@ -96,7 +96,7 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { params.Token = r.FormValue("token") params.Type = r.FormValue("type") params.RedirectTo = utilities.GetReferrer(r, a.config) - if err := params.Validate(r); err != nil { + if err := params.Validate(r, a); err != nil { return err } return a.verifyGet(w, r, params) @@ -104,7 +104,7 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { if err := retrieveRequestParams(r, params); err != nil { return err } - if err := params.Validate(r); err != nil { + if err := params.Validate(r, a); err != nil { return err } return a.verifyPost(w, r, params) diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index 73ef6f768e..a0232efcfc 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -1248,7 +1248,7 @@ func (ts *VerifyTestSuite) TestVerifyValidateParams() { for _, c := range cases { ts.Run(c.desc, func() { req := httptest.NewRequest(c.method, "http://localhost", nil) - err := c.params.Validate(req) + err := c.params.Validate(req, ts.API) require.Equal(ts.T(), c.expected, err) }) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 18b5c3ff9c..a4315866c4 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -72,6 +72,8 @@ type AnonymousProviderConfiguration struct { type EmailProviderConfiguration struct { Enabled bool `json:"enabled" default:"true"` + AuthorizedAddresses []string `json:"authorized_addresses" split_words:"true"` + MagicLinkEnabled bool `json:"magic_link_enabled" default:"true" split_words:"true"` }