mirror of
https://codeberg.org/forgejo/forgejo
synced 2024-12-01 06:16:09 +01:00
9508aa7713
- If the incoming mail feature is enabled, tokens are being sent with outgoing mails. These tokens contains information about what type of action is allow with such token (such as replying to a certain issue ID), to verify these tokens the code uses the HMAC-SHA256 construction. - The output of the HMAC is truncated to 80 bits, because this is recommended by RFC2104, but RFC2104 actually doesn't recommend this. It recommends, if truncation should need to take place, it should use max(80, hash_len/2) of the leftmost bits. For HMAC-SHA256 this works out to 128 bits instead of the currently used 80 bits. - Update to token version 2 and disallow any usage of token version 1, token version 2 are generated with 128 bits of HMAC output. - Add test to verify the deprecation of token version 1 and a general MAC check test.
291 lines
8.8 KiB
Go
291 lines
8.8 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"encoding/base32"
|
|
"io"
|
|
"net"
|
|
"net/smtp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
|
"code.gitea.io/gitea/models/unittest"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/services/mailer/incoming"
|
|
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
|
|
token_service "code.gitea.io/gitea/services/mailer/token"
|
|
"code.gitea.io/gitea/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gopkg.in/gomail.v2"
|
|
)
|
|
|
|
func TestIncomingEmail(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
|
|
|
t.Run("Payload", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
|
|
|
_, err := incoming_payload.CreateReferencePayload(user)
|
|
require.Error(t, err)
|
|
|
|
issuePayload, err := incoming_payload.CreateReferencePayload(issue)
|
|
require.NoError(t, err)
|
|
commentPayload, err := incoming_payload.CreateReferencePayload(comment)
|
|
require.NoError(t, err)
|
|
|
|
_, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3})
|
|
require.Error(t, err)
|
|
|
|
ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload)
|
|
require.NoError(t, err)
|
|
assert.IsType(t, ref, new(issues_model.Issue))
|
|
assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID)
|
|
|
|
ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload)
|
|
require.NoError(t, err)
|
|
assert.IsType(t, ref, new(issues_model.Comment))
|
|
assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID)
|
|
})
|
|
|
|
t.Run("Token", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
payload := []byte{1, 2, 3, 4, 5}
|
|
|
|
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, token)
|
|
|
|
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, token_service.ReplyHandlerType, ht)
|
|
assert.Equal(t, user.ID, u.ID)
|
|
assert.Equal(t, payload, p)
|
|
})
|
|
|
|
tokenEncoding := base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
t.Run("Deprecated token version", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
payload := []byte{1, 2, 3, 4, 5}
|
|
|
|
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, token)
|
|
|
|
// Set the token to version 1.
|
|
unencodedToken, err := tokenEncoding.DecodeString(token)
|
|
require.NoError(t, err)
|
|
unencodedToken[0] = 1
|
|
token = tokenEncoding.EncodeToString(unencodedToken)
|
|
|
|
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
|
|
require.ErrorContains(t, err, "unsupported token version: 1")
|
|
assert.Equal(t, token_service.UnknownHandlerType, ht)
|
|
assert.Nil(t, u)
|
|
assert.Nil(t, p)
|
|
})
|
|
|
|
t.Run("MAC check", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
payload := []byte{1, 2, 3, 4, 5}
|
|
|
|
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, token)
|
|
|
|
// Modify the MAC.
|
|
unencodedToken, err := tokenEncoding.DecodeString(token)
|
|
require.NoError(t, err)
|
|
unencodedToken[len(unencodedToken)-1] ^= 0x01
|
|
token = tokenEncoding.EncodeToString(unencodedToken)
|
|
|
|
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
|
|
require.ErrorContains(t, err, "verification failed")
|
|
assert.Equal(t, token_service.UnknownHandlerType, ht)
|
|
assert.Nil(t, u)
|
|
assert.Nil(t, p)
|
|
})
|
|
|
|
t.Run("Handler", func(t *testing.T) {
|
|
t.Run("Reply", func(t *testing.T) {
|
|
checkReply := func(t *testing.T, payload []byte, issue *issues_model.Issue, commentType issues_model.CommentType) {
|
|
t.Helper()
|
|
|
|
handler := &incoming.ReplyHandler{}
|
|
|
|
require.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload))
|
|
require.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload))
|
|
|
|
content := &incoming.MailContent{
|
|
Content: "reply by mail",
|
|
Attachments: []*incoming.Attachment{
|
|
{
|
|
Name: "attachment.txt",
|
|
Content: []byte("test"),
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
|
|
|
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
|
IssueID: issue.ID,
|
|
Type: commentType,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, comments)
|
|
comment := comments[len(comments)-1]
|
|
assert.Equal(t, user.ID, comment.PosterID)
|
|
assert.Equal(t, content.Content, comment.Content)
|
|
require.NoError(t, comment.LoadAttachments(db.DefaultContext))
|
|
assert.Len(t, comment.Attachments, 1)
|
|
attachment := comment.Attachments[0]
|
|
assert.Equal(t, content.Attachments[0].Name, attachment.Name)
|
|
assert.EqualValues(t, 4, attachment.Size)
|
|
}
|
|
t.Run("Issue", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
payload, err := incoming_payload.CreateReferencePayload(issue)
|
|
require.NoError(t, err)
|
|
|
|
checkReply(t, payload, issue, issues_model.CommentTypeComment)
|
|
})
|
|
|
|
t.Run("CodeComment", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
|
|
|
payload, err := incoming_payload.CreateReferencePayload(comment)
|
|
require.NoError(t, err)
|
|
|
|
checkReply(t, payload, issue, issues_model.CommentTypeCode)
|
|
})
|
|
|
|
t.Run("Comment", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
|
|
|
payload, err := incoming_payload.CreateReferencePayload(comment)
|
|
require.NoError(t, err)
|
|
|
|
checkReply(t, payload, issue, issues_model.CommentTypeComment)
|
|
})
|
|
})
|
|
|
|
t.Run("Unsubscribe", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
watching, err := issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
|
|
require.NoError(t, err)
|
|
assert.True(t, watching)
|
|
|
|
handler := &incoming.UnsubscribeHandler{}
|
|
|
|
content := &incoming.MailContent{
|
|
Content: "unsub me",
|
|
}
|
|
|
|
payload, err := incoming_payload.CreateReferencePayload(issue)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
|
|
|
watching, err = issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
|
|
require.NoError(t, err)
|
|
assert.False(t, watching)
|
|
})
|
|
})
|
|
|
|
if setting.IncomingEmail.Enabled {
|
|
// This test connects to the configured email server and is currently only enabled for MySql integration tests.
|
|
// It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails.
|
|
t.Run("IMAP", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
payload, err := incoming_payload.CreateReferencePayload(issue)
|
|
require.NoError(t, err)
|
|
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
|
require.NoError(t, err)
|
|
|
|
msg := gomail.NewMessage()
|
|
msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1))
|
|
msg.SetHeader("From", user.Email)
|
|
msg.SetBody("text/plain", token)
|
|
err = gomail.Send(&smtpTestSender{}, msg)
|
|
require.NoError(t, err)
|
|
|
|
assert.Eventually(t, func() bool {
|
|
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
|
IssueID: issue.ID,
|
|
Type: issues_model.CommentTypeComment,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, comments)
|
|
|
|
comment := comments[len(comments)-1]
|
|
|
|
return comment.PosterID == user.ID && comment.Content == token
|
|
}, 10*time.Second, 1*time.Second)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A simple SMTP mail sender used for integration tests.
|
|
type smtpTestSender struct{}
|
|
|
|
func (s *smtpTestSender) Send(from string, to []string, msg io.WriterTo) error {
|
|
conn, err := net.Dial("tcp", net.JoinHostPort(setting.IncomingEmail.Host, "25"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
|
|
client, err := smtp.NewClient(conn, setting.IncomingEmail.Host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = client.Mail(from); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, rec := range to {
|
|
if err = client.Rcpt(rec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
w, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := msg.WriteTo(w); err != nil {
|
|
return err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return client.Quit()
|
|
}
|