From 7fc0e1ca8fdf42c3a15bc5a52a47cdff5d56cc69 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 21 Sep 2026 00:39:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=82=AE=E4=BB=B6=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E6=95=B4=E6=95=B0=E8=BE=B9=E7=95=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E4=BA=8B=E5=8A=A1=E5=9B=9E=E6=BB=9A=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/migrate.go | 36 +++--- backend/config/migrate_port_test.go | 36 ++++++ backend/listen/imap_server/numbers.go | 17 +++ backend/listen/imap_server/numbers_test.go | 114 ++++++++++++++++++ backend/listen/imap_server/server.go | 16 ++- backend/listen/imap_server/session_expunge.go | 24 ++-- .../listen/imap_server/session_messages.go | 28 +++-- backend/listen/imap_server/session_poll.go | 24 ++-- backend/services/list/imap_search.go | 22 +++- backend/services/list/imap_snapshot_test.go | 27 +++++ 10 files changed, 299 insertions(+), 45 deletions(-) create mode 100644 backend/config/migrate_port_test.go create mode 100644 backend/listen/imap_server/numbers_test.go create mode 100644 backend/services/list/imap_snapshot_test.go diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 660549d..c518a32 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -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) { diff --git a/backend/config/migrate_port_test.go b/backend/config/migrate_port_test.go new file mode 100644 index 0000000..81f5d17 --- /dev/null +++ b/backend/config/migrate_port_test.go @@ -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("默认端口不能为零") + } +} diff --git a/backend/listen/imap_server/numbers.go b/backend/listen/imap_server/numbers.go index 2fca0c6..4656b77 100644 --- a/backend/listen/imap_server/numbers.go +++ b/backend/listen/imap_server/numbers.go @@ -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 +} diff --git a/backend/listen/imap_server/numbers_test.go b/backend/listen/imap_server/numbers_test.go new file mode 100644 index 0000000..0e9045f --- /dev/null +++ b/backend/listen/imap_server/numbers_test.go @@ -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: \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 留下了邮件或关联记录") + } +} diff --git a/backend/listen/imap_server/server.go b/backend/listen/imap_server/server.go index 0779547..b222e1b 100644 --- a/backend/listen/imap_server/server.go +++ b/backend/listen/imap_server/server.go @@ -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 } diff --git a/backend/listen/imap_server/session_expunge.go b/backend/listen/imap_server/session_expunge.go index 69eec2d..eee3645 100644 --- a/backend/listen/imap_server/session_expunge.go +++ b/backend/listen/imap_server/session_expunge.go @@ -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") diff --git a/backend/listen/imap_server/session_messages.go b/backend/listen/imap_server/session_messages.go index 7b8822a..95a99e7 100644 --- a/backend/listen/imap_server/session_messages.go +++ b/backend/listen/imap_server/session_messages.go @@ -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 { diff --git a/backend/listen/imap_server/session_poll.go b/backend/listen/imap_server/session_poll.go index a391c57..1adf830 100644 --- a/backend/listen/imap_server/session_poll.go +++ b/backend/listen/imap_server/session_poll.go @@ -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 } diff --git a/backend/services/list/imap_search.go b/backend/services/list/imap_search.go index 14001ce..6f3183a 100644 --- a/backend/services/list/imap_search.go +++ b/backend/services/list/imap_search.go @@ -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 diff --git a/backend/services/list/imap_snapshot_test.go b/backend/services/list/imap_snapshot_test.go new file mode 100644 index 0000000..faa5f2d --- /dev/null +++ b/backend/services/list/imap_snapshot_test.go @@ -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 不应被截断为搜索结果") + } + } +}