fix: Crash-Robustheit + Performance in Backend und Frontend härten
Backend: recover() in allen langlebigen Goroutinen (neues internal/safego- Paket), MIME-Multipart-Tiefenlimit gegen Stack-Overflow, IMAP-Zeilenlängen- und FETCH-Result-Limits gegen OOM, MBOX-Buffer-Aliasing-Bug (Datenkorruption beim Import), ungeprüfte Type Assertions abgesichert, Data Race im API-Key-Rate-Limiter behoben, SMTP-Session-Panic führt jetzt zu 451-Retry statt Prozessabsturz. Performance: O(n²)-String-Concat in Mailparser und IMAP-Parser durch strings.Builder ersetzt. Frontend: Error Boundaries für Root und Mail-Detailansicht ergänzt (gab es vorher nicht), zahlreiche Guards gegen nil-Slices aus dem Backend-JSON die sonst .map()/.length-Crashes/White-Screens auslösten, defekte JSON-Antworten in api/core.ts abgefangen, zwei React-Key-Bugs bei löschbaren Listen korrigiert. Verifiziert auf 192.168.1.132: Build und Tests der geänderten Pakete fehlerfrei, keine Regressionen gegenüber vorbestehendem Stand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0fa4fad49d
commit
798cb2817c
@@ -10,6 +10,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -31,6 +32,9 @@ const (
|
||||
maxConnsPerUser = 5
|
||||
readBufferSize = 8192
|
||||
maxLineLength = 65536
|
||||
// maxFetchResults caps how many messages a single sequence set may expand
|
||||
// to, bounding per-request memory for "FETCH 1:*" on huge mailboxes.
|
||||
maxFetchResults = 50000
|
||||
)
|
||||
|
||||
// tenantIMAPModeGetter is satisfied by tenantstore.Store.
|
||||
@@ -199,6 +203,7 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
remoteAddr := conn.RemoteAddr().String()
|
||||
|
||||
s.logger.Debug("imapserver: new connection", "remote", remoteAddr)
|
||||
|
||||
sess := &session{
|
||||
@@ -209,6 +214,19 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
state: stateNotAuth,
|
||||
}
|
||||
|
||||
// A panic while parsing a malformed/hostile client command must only kill
|
||||
// this connection, never the whole archivmail process. The per-user
|
||||
// connection counter is released here as well so it cannot leak.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if sess.username != "" {
|
||||
s.releaseConn(sess.username)
|
||||
}
|
||||
s.logger.Error("imapserver: recovered from panic in connection handler",
|
||||
"remote", remoteAddr, "panic", fmt.Sprintf("%v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
// Send greeting — use FQDN if configured (RFC 3501 §7.1)
|
||||
fqdn := s.cfg.FQDN
|
||||
if fqdn == "" {
|
||||
@@ -282,12 +300,26 @@ type mailEntry struct {
|
||||
HasAttach bool
|
||||
}
|
||||
|
||||
// readLine reads a single CRLF-terminated command line. The length is capped at
|
||||
// maxLineLength: a client that never sends a newline would otherwise make the
|
||||
// buffer grow without bound until the process is killed by the OOM killer.
|
||||
func (sess *session) readLine() (string, error) {
|
||||
line, err := sess.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
var buf []byte
|
||||
for {
|
||||
chunk, err := sess.reader.ReadSlice('\n')
|
||||
if len(buf)+len(chunk) > maxLineLength {
|
||||
return "", fmt.Errorf("imapserver: command line exceeds %d bytes", maxLineLength)
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, bufio.ErrBufferFull) {
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
return strings.TrimRight(string(buf), "\r\n"), nil
|
||||
}
|
||||
|
||||
func (sess *session) writeResponse(line string) {
|
||||
@@ -949,26 +981,29 @@ func parseFetchItems(items string) []string {
|
||||
items = strings.TrimSuffix(items, ")")
|
||||
|
||||
var result []string
|
||||
current := ""
|
||||
// strings.Builder instead of `current += string(ch)`: the latter reallocates
|
||||
// and copies on every rune, i.e. O(n²) for a command line that may be up to
|
||||
// maxLineLength bytes long.
|
||||
var current strings.Builder
|
||||
bracketDepth := 0
|
||||
for _, ch := range items {
|
||||
if ch == '[' {
|
||||
bracketDepth++
|
||||
current += string(ch)
|
||||
current.WriteRune(ch)
|
||||
} else if ch == ']' {
|
||||
bracketDepth--
|
||||
current += string(ch)
|
||||
current.WriteRune(ch)
|
||||
} else if ch == ' ' && bracketDepth == 0 {
|
||||
if current != "" {
|
||||
result = append(result, current)
|
||||
current = ""
|
||||
if current.Len() > 0 {
|
||||
result = append(result, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
} else {
|
||||
current += string(ch)
|
||||
current.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
result = append(result, current)
|
||||
if current.Len() > 0 {
|
||||
result = append(result, current.String())
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -976,15 +1011,22 @@ func parseFetchItems(items string) []string {
|
||||
|
||||
// parseSequenceSet parses an IMAP sequence set (e.g. "1:*", "1,3:5", "1")
|
||||
// and returns the expanded list of numbers.
|
||||
//
|
||||
// The expansion is capped at maxFetchResults: "FETCH 1:*" on a mailbox with
|
||||
// millions of mails would otherwise allocate a huge slice plus dedup map per
|
||||
// request — several concurrent clients could exhaust the machine's memory.
|
||||
func parseSequenceSet(set string, maxVal uint32) []uint32 {
|
||||
if maxVal == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []uint32
|
||||
result := make([]uint32, 0, 64)
|
||||
seen := make(map[uint32]bool)
|
||||
|
||||
for _, part := range strings.Split(set, ",") {
|
||||
if len(result) >= maxFetchResults {
|
||||
break
|
||||
}
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
@@ -998,6 +1040,9 @@ func parseSequenceSet(set string, maxVal uint32) []uint32 {
|
||||
start, end = end, start
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
if len(result) >= maxFetchResults {
|
||||
break
|
||||
}
|
||||
if !seen[i] {
|
||||
result = append(result, i)
|
||||
seen[i] = true
|
||||
@@ -1151,23 +1196,24 @@ func stripQuotes(s string) string {
|
||||
|
||||
func splitIMAPArgs(s string) []string {
|
||||
var parts []string
|
||||
current := ""
|
||||
// See parseFetchItems: strings.Builder avoids O(n²) rune-wise concatenation.
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
for _, ch := range s {
|
||||
if ch == '"' {
|
||||
inQuote = !inQuote
|
||||
current += string(ch)
|
||||
current.WriteRune(ch)
|
||||
} else if ch == ' ' && !inQuote {
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
current = ""
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
} else {
|
||||
current += string(ch)
|
||||
current.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user