修复邮件协议整数边界并补充事务回滚回归
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 00:39:26 +08:00
parent a4e2857cee
commit 7fc0e1ca8f
10 changed files with 299 additions and 45 deletions

View File

@ -670,19 +670,15 @@ func parseLegacyMySQLDSN(value string) (DatabaseConfig, string, bool) {
if matches == nil {
return DatabaseConfig{}, "", false
}
port := int(DefaultMySQLPort)
if matches[4] != "" {
parsed, err := strconv.Atoi(matches[4])
if err != nil || !validPort(parsed) {
return DatabaseConfig{}, "", false
}
port = parsed
port, valid := parseLegacyDatabasePort(matches[4], DefaultMySQLPort)
if !valid {
return DatabaseConfig{}, "", false
}
host, ok := normalizeLegacyDatabaseHost(matches[3])
if !ok || !validLegacyDatabaseUser(matches[1]) || !validLegacyDatabasePassword(matches[2]) {
return DatabaseConfig{}, "", false
}
return DatabaseConfig{Type: DatabaseTypeMySQL, Host: host, Port: uint16(port), Username: matches[1], Name: matches[5]}, matches[2], true
return DatabaseConfig{Type: DatabaseTypeMySQL, Host: host, Port: port, Username: matches[1], Name: matches[5]}, matches[2], true
}
func parseLegacyPostgreSQLDSN(value string) (DatabaseConfig, string, bool) {
@ -699,19 +695,27 @@ func parseLegacyPostgreSQLDSN(value string) (DatabaseConfig, string, bool) {
if !ok {
return DatabaseConfig{}, "", false
}
port := int(DefaultPostgreSQLPort)
if rawPort := parsed.Port(); rawPort != "" {
parsedPort, parseErr := strconv.Atoi(rawPort)
if parseErr != nil || !validPort(parsedPort) {
return DatabaseConfig{}, "", false
}
port = parsedPort
port, valid := parseLegacyDatabasePort(parsed.Port(), DefaultPostgreSQLPort)
if !valid {
return DatabaseConfig{}, "", false
}
name := strings.TrimPrefix(parsed.EscapedPath(), "/")
if name == "" || strings.Contains(name, "/") || !databaseLocalRefPattern.MatchString(name) {
return DatabaseConfig{}, "", false
}
return DatabaseConfig{Type: DatabaseTypePostgreSQL, Host: host, Port: uint16(port), Username: username, Name: name}, password, true
return DatabaseConfig{Type: DatabaseTypePostgreSQL, Host: host, Port: port, Username: username, Name: name}, password, true
}
// 直接按端口位宽解析,避免先解析为平台整数再缩窄。
func parseLegacyDatabasePort(value string, fallback uint16) (uint16, bool) {
if value == "" {
return fallback, fallback != 0
}
port, err := strconv.ParseUint(value, 10, 16)
if err != nil || port == 0 {
return 0, false
}
return uint16(port), true
}
func normalizeLegacyDatabaseHost(value string) (string, bool) {

View File

@ -0,0 +1,36 @@
package config
import "testing"
func TestLegacyDatabasePortBoundaries(t *testing.T) {
for _, test := range []struct {
value string
want uint16
valid bool
}{
{value: "", want: 3306, valid: true},
{value: "1", want: 1, valid: true},
{value: "65535", want: 65535, valid: true},
{value: "0"},
{value: "-1"},
{value: "65536"},
{value: "18446744073709551616"},
{value: " 3306"},
} {
port, valid := parseLegacyDatabasePort(test.value, DefaultMySQLPort)
if port != test.want || valid != test.valid {
t.Fatalf("端口 %q 的解析结果错误:%d %t", test.value, port, valid)
}
}
for _, value := range []string{"0", "65536", "18446744073709551616"} {
if _, _, valid := parseLegacyMySQLDSN("smail:sample@tcp(db.example.test:" + value + ")/smail"); valid {
t.Fatal("MySQL 连接串接受了越界端口")
}
if _, _, valid := parseLegacyPostgreSQLDSN("postgres://smail:sample@db.example.test:" + value + "/smail"); valid {
t.Fatal("PostgreSQL 连接串接受了越界端口")
}
}
if _, valid := parseLegacyDatabasePort("", 0); valid {
t.Fatal("默认端口不能为零")
}
}

View File

@ -22,3 +22,20 @@ func imapUID(value int) (imap.UID, error) {
}
return imap.UID(number), nil
}
// 在发送更新、选择邮件或删除数据之前校验整个会话快照,禁止截断后指向另一封邮件。
func imapSnapshotBounds(uids []int) (uint32, uint32, error) {
count, err := imapUint32(len(uids))
if err != nil {
return 0, 0, err
}
var maximum uint32
for _, value := range uids {
uid, err := imapUID(value)
if err != nil {
return 0, 0, err
}
maximum = max(maximum, uint32(uid))
}
return maximum, count, nil
}

View File

@ -0,0 +1,114 @@
package imap_server
import (
"bytes"
"math"
"reflect"
"strconv"
"testing"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/context"
"github.com/emersion/go-imap/v2"
)
func TestIMAPSnapshotBounds(t *testing.T) {
for _, test := range []struct {
name string
uids []int
maximum uint32
invalid bool
}{
{name: "empty"},
{name: "valid", uids: []int{7, 2, 9}, maximum: 9},
{name: "zero", uids: []int{7, 0}, invalid: true},
{name: "negative", uids: []int{7, -1}, invalid: true},
} {
t.Run(test.name, func(t *testing.T) {
maximum, count, err := imapSnapshotBounds(test.uids)
if test.invalid {
if err == nil || maximum != 0 || count != 0 {
t.Fatal("非法快照必须完整拒绝")
}
return
}
if err != nil || maximum != test.maximum || int(count) != len(test.uids) {
t.Fatalf("快照边界错误:%d %d %v", maximum, count, err)
}
})
}
}
func TestIMAPRejectsOverflowBeforeProtocolSideEffects(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("此用例验证大于协议上限的六十四位数据库标识")
}
limit := uint64(math.MaxUint32)
if maximum, count, err := imapSnapshotBounds([]int{int(limit)}); err != nil || maximum != math.MaxUint32 || count != 1 {
t.Fatalf("最大合法 UID 被拒绝:%d %d %v", maximum, count, err)
}
for _, uid := range []int{0, -1, int(limit + 1), int(limit + 2)} {
snapshot := []int{1, uid}
session := &serverSession{ctx: &context.Context{UserID: 1}, currentMailbox: "INBOX", mailboxUIDs: append([]int{}, snapshot...)}
if _, err := session.selectedMessages(imap.UIDSetNum(1)); err == nil {
t.Fatal("邮件选择未拒绝非法快照")
}
if err := session.poll(nil, true, true); err == nil {
t.Fatal("轮询未在发送更新前拒绝非法快照")
}
if err := session.Expunge(nil, nil); err == nil {
t.Fatal("删除未在写数据库前拒绝非法快照")
}
if !reflect.DeepEqual(session.mailboxUIDs, snapshot) {
t.Fatal("拒绝非法快照时修改了会话状态")
}
}
set := imap.UIDSet{{Start: 0, Stop: 0}}
if _, err := expandIMAPUIDSet(&set, int(limit+1)); err == nil {
t.Fatal("动态范围上界发生截断")
}
}
func TestAppendUIDOverflowRollsBackMessageAndRelation(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("此用例验证六十四位数据库自增值超过协议上限")
}
rows, err := db.Instance.QueryString("SELECT seq FROM sqlite_sequence WHERE name = 'user_email'")
if err != nil || len(rows) != 1 {
t.Fatalf("读取测试自增序列失败:%v", err)
}
previous := rows[0]["seq"]
defer func() {
if _, err := db.Instance.Exec("UPDATE sqlite_sequence SET seq = ? WHERE name = 'user_email'", previous); err != nil {
t.Errorf("恢复测试序列失败:%v", err)
}
}()
if _, err := db.Instance.Exec("UPDATE sqlite_sequence SET seq = ? WHERE name = 'user_email'", int64(math.MaxUint32)); err != nil {
t.Fatal(err)
}
beforeMessages, err := db.Instance.Count(new(models.Email))
if err != nil {
t.Fatal(err)
}
beforeRelations, err := db.Instance.Count(new(models.UserEmail))
if err != nil {
t.Fatal(err)
}
message := []byte("From: sender@test.domain\r\nTo: testCase@test.domain\r\nSubject: boundary\r\nMessage-ID: <uid-overflow@test.domain>\r\n\r\nbody\r\n")
session := &serverSession{ctx: &context.Context{UserID: clientLoginUserID(t)}, status: AUTHORIZED}
if data, err := session.Append("INBOX", bytes.NewReader(message), nil); err == nil || data != nil {
t.Fatal("越界 APPEND 不应提交或返回 UID")
}
afterMessages, err := db.Instance.Count(new(models.Email))
if err != nil {
t.Fatal(err)
}
afterRelations, err := db.Instance.Count(new(models.UserEmail))
if err != nil {
t.Fatal(err)
}
if beforeMessages != afterMessages || beforeRelations != afterRelations {
t.Fatal("失败 APPEND 留下了邮件或关联记录")
}
}

View File

@ -110,6 +110,10 @@ func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *im
if err != nil {
return nil, err
}
validity, err := imapUID(uidValidity)
if err != nil {
return nil, appendNoError("邮箱标识超出协议允许范围")
}
if r.Size() < 0 || r.Size() > maxIMAPAppendBytes {
return nil, appendTooBigError()
@ -180,7 +184,7 @@ func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *im
MsgID: parsed.MsgID,
}
var savedUID int
var savedUID imap.UID
if err := db.Transaction(db.Instance, func(session *xorm.Session) error {
candidate := modelEmail
candidate.Id = 0
@ -200,15 +204,19 @@ func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *im
if _, err := session.Insert(&relation); err != nil {
return fmt.Errorf("insert appended mailbox relation: %w", err)
}
savedUID = relation.ID
// 必须在事务提交前拒绝越界 UID,不能留下客户端无法寻址的邮件。
savedUID, err = imapUID(relation.ID)
if err != nil {
return err
}
return nil
}); err != nil {
return nil, appendNoError("Save message failed")
}
return &imap.AppendData{
UID: imap.UID(savedUID),
UIDValidity: uint32(uidValidity),
UID: savedUID,
UIDValidity: uint32(validity),
}, nil
}

View File

@ -34,16 +34,20 @@ func (s *serverSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet)
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Expunge failed"}
}
// 只匹配会话内真实存在的 UID,不展开巨大或稀疏区间。
var maximum uint32
for _, uid := range snapshot {
maximum = max(maximum, uint32(uid))
maximum, _, err := imapSnapshotBounds(snapshot)
if err != nil {
return err
}
requestedUIDs := make([]int, 0, len(snapshot))
for _, uid := range snapshot {
number, err := imapUID(uid)
if err != nil {
return err
}
matched := uids == nil
if uids != nil {
for _, span := range *uids {
matched = matched || numberInRange(uint32(uid), uint32(span.Start), uint32(span.Stop), maximum)
matched = matched || numberInRange(uint32(number), uint32(span.Start), uint32(span.Stop), maximum)
}
}
if matched {
@ -96,6 +100,10 @@ func expandIMAPUIDSet(uids *imap.UIDSet, maxUID int) ([]int, error) {
if uids == nil {
return nil, nil
}
maximum, err := imapUint32(maxUID)
if err != nil {
return nil, err
}
result := make([]int, 0)
seen := make(map[int]struct{})
appendUID := func(uid imap.UID) error {
@ -118,7 +126,7 @@ func expandIMAPUIDSet(uids *imap.UIDSet, maxUID int) ([]int, error) {
stop := uidRange.Stop
if start == 0 {
if maxUID > 0 {
if err := appendUID(imap.UID(maxUID)); err != nil {
if err := appendUID(imap.UID(maximum)); err != nil {
return nil, err
}
}
@ -128,13 +136,13 @@ func expandIMAPUIDSet(uids *imap.UIDSet, maxUID int) ([]int, error) {
if maxUID <= 0 {
continue
}
if imap.UID(maxUID) < start {
if err := appendUID(imap.UID(maxUID)); err != nil {
if imap.UID(maximum) < start {
if err := appendUID(imap.UID(maximum)); err != nil {
return nil, err
}
continue
}
stop = imap.UID(maxUID)
stop = imap.UID(maximum)
}
if stop < start {
return nil, fmt.Errorf("invalid UID range")

View File

@ -18,10 +18,16 @@ func (s *serverSession) selectedUIDs() ([]int, error) {
if err != nil {
return nil, err
}
s.mailboxUIDs = make([]int, 0, len(relations))
uids := make([]int, 0, len(relations))
for _, relation := range relations {
s.mailboxUIDs = append(s.mailboxUIDs, relation.ID)
uids = append(uids, relation.ID)
}
if _, _, err := imapSnapshotBounds(uids); err != nil {
return nil, err
}
s.mailboxUIDs = uids
} else if _, _, err := imapSnapshotBounds(s.mailboxUIDs); err != nil {
return nil, err
}
return append([]int{}, s.mailboxUIDs...), nil
}
@ -44,23 +50,31 @@ func (s *serverSession) selectedMessages(numbers imap.NumSet) ([]*response.Email
if err != nil {
return nil, err
}
var maximum uint32
for _, uid := range uids {
maximum = max(maximum, uint32(uid))
maximum, count, err := imapSnapshotBounds(uids)
if err != nil {
return nil, err
}
selected := make([]int, 0)
sequences := make(map[int]int, len(uids))
for index, uid := range uids {
number, err := imapUID(uid)
if err != nil {
return nil, err
}
sequence, err := imapUint32(index + 1)
if err != nil {
return nil, err
}
sequences[uid] = index + 1
matched := false
switch set := numbers.(type) {
case imap.SeqSet:
for _, span := range set {
matched = matched || numberInRange(uint32(index+1), span.Start, span.Stop, uint32(len(uids)))
matched = matched || numberInRange(sequence, span.Start, span.Stop, count)
}
case imap.UIDSet:
for _, span := range set {
matched = matched || numberInRange(uint32(uid), uint32(span.Start), uint32(span.Stop), maximum)
matched = matched || numberInRange(uint32(number), uint32(span.Start), uint32(span.Stop), maximum)
}
}
if matched {

View File

@ -17,6 +17,9 @@ func (s *serverSession) poll(w *imapserver.UpdateWriter, allowExpunge, forceExis
if s.currentMailbox == "" {
return nil
}
if _, _, err := imapSnapshotBounds(s.mailboxUIDs); err != nil {
return err
}
relations, err := list.GetUEListByUID(s.ctx, s.currentMailbox, 0, 0, nil)
if err != nil {
log.WithContext(s.ctx).WithError(err).Error("query IMAP polling updates failed")
@ -24,8 +27,14 @@ func (s *serverSession) poll(w *imapserver.UpdateWriter, allowExpunge, forceExis
}
present := make(map[int]bool, len(relations))
nextUIDs := make([]int, 0, len(relations))
for _, relation := range relations {
present[relation.ID] = true
nextUIDs = append(nextUIDs, relation.ID)
}
_, count, err := imapSnapshotBounds(nextUIDs)
if err != nil {
return err
}
// 不允许 EXPUNGE 的命令后保留旧序号,等到可通知时再同步整个变化。
for _, uid := range s.mailboxUIDs {
@ -35,24 +44,21 @@ func (s *serverSession) poll(w *imapserver.UpdateWriter, allowExpunge, forceExis
}
for index := len(s.mailboxUIDs) - 1; index >= 0; index-- {
if !present[s.mailboxUIDs[index]] {
if err := w.WriteExpunge(uint32(index + 1)); err != nil {
sequence, err := imapUint32(index + 1)
if err != nil {
return err
}
if err := w.WriteExpunge(sequence); err != nil {
return err
}
s.mailboxUIDs = append(s.mailboxUIDs[:index], s.mailboxUIDs[index+1:]...)
}
}
if forceExists || len(relations) != len(s.mailboxUIDs) {
count, err := imapUint32(len(relations))
if err != nil {
return err
}
if err := w.WriteNumMessages(count); err != nil {
return err
}
}
s.mailboxUIDs = s.mailboxUIDs[:0]
for _, relation := range relations {
s.mailboxUIDs = append(s.mailboxUIDs, relation.ID)
}
s.mailboxUIDs = nextUIDs
return nil
}

View File

@ -64,6 +64,14 @@ type IMAPSearchResult struct {
// SearchEmails performs IMAP search based on criteria
func SearchEmails(ctx *context.Context, groupName string, criteria *imap.SearchCriteria, snapshot ...[]int) ([]*response.UserEmailUIDData, error) {
var snapshotCount uint32
if len(snapshot) > 0 {
var err error
snapshotCount, err = imapSnapshotCount(snapshot[0])
if err != nil {
return nil, err
}
}
// First get the base list for the mailbox
baseList, err := GetUEListByUID(ctx, groupName, 0, 0, nil)
if err != nil {
@ -104,7 +112,7 @@ func SearchEmails(ctx *context.Context, groupName string, criteria *imap.SearchC
}
if len(snapshot) > 0 {
maxUID = 0
maxSequence = uint32(len(snapshot[0]))
maxSequence = snapshotCount
for _, uid := range snapshot[0] {
if number, ok := validIMAPNumber(uid); ok {
maxUID = max(maxUID, number)
@ -195,6 +203,18 @@ func validIMAPNumber(value int) (uint32, bool) {
return cast.ToUint32(value), true
}
func imapSnapshotCount(snapshot []int) (uint32, error) {
if uint64(len(snapshot)) > math.MaxUint32 {
return 0, fmt.Errorf("邮箱邮件数量超出协议允许范围")
}
for _, value := range snapshot {
if _, ok := validIMAPNumber(value); !ok {
return 0, fmt.Errorf("邮箱邮件标识超出协议允许范围")
}
}
return cast.ToUint32(len(snapshot)), nil
}
// filterByUIDSets filters the list by UID sets
func filterByUIDSets(list []*response.UserEmailUIDData, uidSets []imap.UIDSet, upper ...uint32) []*response.UserEmailUIDData {
var maximum uint32

View File

@ -0,0 +1,27 @@
package list
import (
"math"
"strconv"
"testing"
)
func TestIMAPSnapshotCountRejectsInvalidUIDBeforeDatabaseRead(t *testing.T) {
for _, snapshot := range [][]int{{0}, {-1}, {1, 0}} {
if _, err := SearchEmails(nil, "INBOX", nil, snapshot); err == nil {
t.Fatal("非法快照必须在查询数据库之前拒绝")
}
}
if count, err := imapSnapshotCount(nil); err != nil || count != 0 {
t.Fatalf("空快照验证失败:%d %v", count, err)
}
if strconv.IntSize >= 64 {
limit := uint64(math.MaxUint32)
if count, err := imapSnapshotCount([]int{int(limit)}); err != nil || count != 1 {
t.Fatalf("最大合法 UID 被拒绝:%d %v", count, err)
}
if _, err := SearchEmails(nil, "INBOX", nil, []int{int(limit + 1)}); err == nil {
t.Fatal("越界 UID 不应被截断为搜索结果")
}
}
}