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 0e5ae0cd9..45b08f041 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 a37a4513a..f6a890566 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 76852f711..f0260dd97 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 e3fc0315b..44f9fc88f 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 00bb58e7e..c82214918 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 90608a13a..fd3de7c80 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 e690bdfe2..1821da3ee 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 cbcff81d8..7c03c3246 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 a1f4246c8..2c305360b 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 0a1b8c6c4..22ac7dc02 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 616ad23c7..47ec8e9fd 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 5adc80744..dbd4d76c6 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 73ef6f768..a0232efcf 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 18b5c3ff9..a4315866c 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"` }