diff --git a/models/asymkey/ssh_key_principals.go b/models/asymkey/ssh_key_principals.go index e8b97d306e..2d4c74740d 100644 --- a/models/asymkey/ssh_key_principals.go +++ b/models/asymkey/ssh_key_principals.go @@ -40,7 +40,7 @@ func CheckPrincipalKeyString(ctx context.Context, user *user_model.User, content if !email.IsActivated { continue } - if content == email.Email { + if strings.EqualFold(content, email.LowerEmail) { return content, nil } } diff --git a/modules/setting/incoming_email.go b/modules/setting/incoming_email.go index 4e433dde60..bf9f7f9776 100644 --- a/modules/setting/incoming_email.go +++ b/modules/setting/incoming_email.go @@ -12,10 +12,11 @@ import ( "code.gitea.io/gitea/modules/log" ) +const IncomingEmailTokenPlaceholder = "%{token}" + var IncomingEmail = struct { Enabled bool ReplyToAddress string - TokenPlaceholder string `ini:"-"` Host string Port int UseTLS bool `ini:"USE_TLS"` @@ -28,7 +29,6 @@ var IncomingEmail = struct { }{ Mailbox: "INBOX", DeleteHandledMessage: true, - TokenPlaceholder: "%{token}", MaximumMessageSize: 10485760, } @@ -54,19 +54,10 @@ func checkReplyToAddress() error { return errors.New("name must not be set") } - c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder) - switch c { - case 0: - return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder) - case 1: - default: - return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder) + placeholderCount := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmailTokenPlaceholder) + userPart, _, _ := strings.Cut(IncomingEmail.ReplyToAddress, "@") + if placeholderCount != 1 || !strings.Contains(userPart, IncomingEmailTokenPlaceholder) { + return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmailTokenPlaceholder) } - - parts := strings.Split(IncomingEmail.ReplyToAddress, "@") - if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) { - return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder) - } - return nil } diff --git a/services/mailer/incoming/incoming.go b/services/mailer/incoming/incoming.go index 495e246c91..d08afac0d3 100644 --- a/services/mailer/incoming/incoming.go +++ b/services/mailer/incoming/incoming.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" net_mail "net/mail" - "regexp" "strings" "time" @@ -24,31 +23,10 @@ import ( "github.com/jhillyerd/enmime/v2" ) -var ( - addressTokenRegex *regexp.Regexp - referenceTokenRegex *regexp.Regexp -) - func Init(ctx context.Context) error { if !setting.IncomingEmail.Enabled { return nil } - - var err error - addressTokenRegex, err = regexp.Compile( - fmt.Sprintf( - `\A%s\z`, - strings.Replace(regexp.QuoteMeta(setting.IncomingEmail.ReplyToAddress), regexp.QuoteMeta(setting.IncomingEmail.TokenPlaceholder), "(.+)", 1), - ), - ) - if err != nil { - return err - } - referenceTokenRegex, err = regexp.Compile(fmt.Sprintf(`\Areply-(.+)@%s\z`, regexp.QuoteMeta(setting.Domain))) - if err != nil { - return err - } - go func() { ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Incoming Email", process.SystemProcessType, true) defer finished() @@ -241,7 +219,7 @@ loop: return nil } - handlerType, user, payload, err := token.ExtractToken(ctx, t) + handlerType, user, payload, err := token.DecodeToken(ctx, t) if err != nil { if _, ok := err.(*token.ErrToken); ok { log.Info("Invalid incoming email token: %v", err) @@ -292,22 +270,31 @@ func isAutomaticReply(env *enmime.Envelope) bool { return autoRespond != "" } +func extractToken(s, tokenPrefix, tokenSuffix string) string { + if len(s) <= len(tokenPrefix)+len(tokenSuffix) { + return "" + } + prefix, suffix := s[0:len(tokenPrefix)], s[len(s)-len(tokenSuffix):] + if strings.EqualFold(prefix, tokenPrefix) && strings.EqualFold(suffix, tokenSuffix) { + return s[len(tokenPrefix) : len(s)-len(tokenSuffix)] + } + return "" +} + // searchTokenInHeaders looks for the token in To, Delivered-To and References func searchTokenInHeaders(env *enmime.Envelope) string { - if addressTokenRegex != nil { - to, _ := env.AddressList("To") + to, _ := env.AddressList("To") - token := searchTokenInAddresses(to) - if token != "" { - return token - } + token := searchTokenInAddresses(to) + if token != "" { + return token + } - deliveredTo, _ := env.AddressList("Delivered-To") + deliveredTo, _ := env.AddressList("Delivered-To") - token = searchTokenInAddresses(deliveredTo) - if token != "" { - return token - } + token = searchTokenInAddresses(deliveredTo) + if token != "" { + return token } references := env.GetHeader("References") @@ -322,10 +309,9 @@ func searchTokenInHeaders(env *enmime.Envelope) string { if end == -1 || begin > end { break } - - match := referenceTokenRegex.FindStringSubmatch(references[begin:end]) - if len(match) == 2 { - return match[1] + t := extractToken(references[begin:end], "reply-", "@"+setting.Domain) + if t != "" { + return t } references = references[end+1:] @@ -336,15 +322,15 @@ func searchTokenInHeaders(env *enmime.Envelope) string { // searchTokenInAddresses looks for the token in an address func searchTokenInAddresses(addresses []*net_mail.Address) string { - for _, address := range addresses { - match := addressTokenRegex.FindStringSubmatch(address.Address) - if len(match) != 2 { - continue - } - - return match[1] + tokenPrefix, tokenSuffix, _ := strings.Cut(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder) + if tokenSuffix == "" { + return "" + } + for _, address := range addresses { + if t := extractToken(address.Address, tokenPrefix, tokenSuffix); t != "" { + return t + } } - return "" } diff --git a/services/mailer/incoming/incoming_test.go b/services/mailer/incoming/incoming_test.go index 4403527de8..11db51603d 100644 --- a/services/mailer/incoming/incoming_test.go +++ b/services/mailer/incoming/incoming_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" + "code.gitea.io/gitea/modules/setting" + "github.com/jhillyerd/enmime/v2" "github.com/stretchr/testify/assert" ) @@ -68,6 +70,18 @@ func TestIsAutomaticReply(t *testing.T) { } } +func TestSearchTokenInHeadersCaseInsensitive(t *testing.T) { + setting.IncomingEmail.ReplyToAddress = "InComing+%{token}@ExAmPle.com" + setting.Domain = "DoMain.com" + mkEnv := func(s string) *enmime.Envelope { + env, _ := enmime.ReadEnvelope(strings.NewReader(s + "\r\n\r\n")) + return env + } + assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("To: incoming+abc@EXAMPLE.COM"))) + assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("Delivered-To: INCOMING+abc@example.com"))) + assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("References: "))) +} + func TestGetContentFromMailReader(t *testing.T) { mailString := "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + "\r\n" + diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go index 994df6707a..544a504a91 100644 --- a/services/mailer/mail_issue_common.go +++ b/services/mailer/mail_issue_common.go @@ -182,7 +182,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang if err != nil { log.Error("CreateToken failed: %v", err) } else { - replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) + replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1) msg.ReplyTo = replyAddress msg.SetHeader("List-Post", fmt.Sprintf("", replyAddress)) @@ -194,7 +194,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang if err != nil { log.Error("CreateToken failed: %v", err) } else { - unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) + unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1) listUnsubscribe = append(listUnsubscribe, "") } } diff --git a/services/mailer/token/token.go b/services/mailer/token/token.go index 8a5a762d6b..a78f5349a1 100644 --- a/services/mailer/token/token.go +++ b/services/mailer/token/token.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "encoding/base32" "fmt" + "strings" "time" user_model "code.gitea.io/gitea/models/user" @@ -73,9 +74,11 @@ func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, er return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion1}, packagedData...)), nil } -// ExtractToken extracts the action/user tuple from the token and verifies the content -func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) { - data, err := encodingWithoutPadding.DecodeString(token) +// DecodeToken decodes the handler, user and payload from the token and verifies the content +func DecodeToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) { + // MTAs are permitted to alter the case of the local-part (RFC 5321 §2.4), so normalize + // to the base32 alphabet before decoding to survive a lowercased reply-to address. + data, err := encodingWithoutPadding.DecodeString(strings.ToUpper(token)) if err != nil { return UnknownHandlerType, nil, nil, err } @@ -118,11 +121,11 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U return handlerType, user, innerPayload, nil } -// generateHmac creates a trunkated HMAC for the given payload +// generateHmac creates a truncated HMAC for the given payload func generateHmac(secret, payload []byte) []byte { mac := crypto_hmac.New(sha256.New, secret) mac.Write(payload) hmac := mac.Sum(nil) - return hmac[:10] // RFC2104 recommends not using less then 80 bits + return hmac[:10] // RFC2104 recommends not using less than 80 bits } diff --git a/tests/integration/incoming_email_test.go b/tests/integration/incoming_email_test.go index 09872fe857..b82c67475a 100644 --- a/tests/integration/incoming_email_test.go +++ b/tests/integration/incoming_email_test.go @@ -66,7 +66,14 @@ func TestIncomingEmail(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, token) - ht, u, p, err := token_service.ExtractToken(t.Context(), token) + ht, u, p, err := token_service.DecodeToken(t.Context(), token) + assert.NoError(t, err) + assert.Equal(t, token_service.ReplyHandlerType, ht) + assert.Equal(t, user.ID, u.ID) + assert.Equal(t, payload, p) + + // MTAs may lowercase the local-part of the reply-to address (RFC 5321 §2.4). + ht, u, p, err = token_service.DecodeToken(t.Context(), strings.ToLower(token)) assert.NoError(t, err) assert.Equal(t, token_service.ReplyHandlerType, ht) assert.Equal(t, user.ID, u.ID) @@ -189,7 +196,7 @@ func TestIncomingEmail(t *testing.T) { assert.NoError(t, err) msg := sender_service.NewMessageFrom( - strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1), + strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1), "", user.Email, "",