package main import ( "bytes" "crypto/rand" "crypto/sha512" "encoding/base64" "encoding/binary" "errors" "flag" "fmt" "io" "log" "math" "math/big" "net" "net/smtp" "net/textproto" "os" "os/signal" "regexp" "strings" "sync" "sync/atomic" "syscall" "time" "golang.org/x/net/proxy" ) const ( Version = "0.9" AppName = "fog" TorSocksProxyAddr = "127.0.0.1:9050" RelayWorkerCount = 5 DeliveryTimeout = 30 * time.Second MixnetBatchWindow = 30 * time.Second CoverTrafficInterval = 15 * time.Second MessageIDCacheDuration = 24 * time.Hour PaddingSizeUnit = 8 * 1024 MinDelay = 100 * time.Millisecond MaxDelay = 2 * time.Second RateLimitPerIP = 10 RateLimitWindow = 1 * time.Minute MXLookupTimeout = 5 * time.Second MaxRetries = 9 MaxMessageSize = 10 * 1024 * 1024 // 10MB limit MaxHeaderSize = 100 * 1024 // 100KB header limit MaxRecipients = 100 // Max recipients per message ConnectionPoolSize = 5 // Tor connection pool size ConnectionMaxAge = 10 * time.Minute // Max connection age before rotation TimestampFuzzRange = 2 * time.Hour // ±2 hours timestamp fuzzing DummyRecipientsMin = 1 // Min dummy recipients per batch DummyRecipientsMax = 3 // Max dummy recipients per batch ) // Message size normalization buckets (in bytes) var MessageSizeBuckets = []int64{ 32 * 1024, // 32KB 64 * 1024, // 64KB 128 * 1024, // 128KB 256 * 1024, // 256KB 512 * 1024, // 512KB 1024 * 1024, // 1MB 2 * 1024 * 1024, // 2MB 5 * 1024 * 1024, // 5MB 10 * 1024 * 1024, // 10MB } var ( emailRegExp *regexp.Regexp localPartRegex *regexp.Regexp domainRegex *regexp.Regexp // Global variables (FIXED: were missing) mailQueue chan *Envelope mailQueueMutex sync.Mutex // Statistics (NEW) stats *Statistics // Graceful shutdown (NEW) shutdownSignal chan struct{} shutdownWg sync.WaitGroup ) // Statistics tracking (NEW) type Statistics struct { messagesReceived int64 messagesDelivered int64 messagesFailed int64 coverTrafficSent int64 dummyMessagesSent int64 mu sync.RWMutex } func NewStatistics() *Statistics { return &Statistics{} } func (s *Statistics) IncReceived() { atomic.AddInt64(&s.messagesReceived, 1) } func (s *Statistics) IncDelivered() { atomic.AddInt64(&s.messagesDelivered, 1) } func (s *Statistics) IncFailed() { atomic.AddInt64(&s.messagesFailed, 1) } func (s *Statistics) IncCoverTraffic() { atomic.AddInt64(&s.coverTrafficSent, 1) } func (s *Statistics) IncDummy() { atomic.AddInt64(&s.dummyMessagesSent, 1) } func (s *Statistics) GetStats() (received, delivered, failed, cover, dummy int64) { return atomic.LoadInt64(&s.messagesReceived), atomic.LoadInt64(&s.messagesDelivered), atomic.LoadInt64(&s.messagesFailed), atomic.LoadInt64(&s.coverTrafficSent), atomic.LoadInt64(&s.dummyMessagesSent) } func (s *Statistics) PrintStats() { received, delivered, failed, cover, dummy := s.GetStats() log.Printf("[STATS] Messages: Received=%d, Delivered=%d, Failed=%d, Cover=%d, Dummy=%d", received, delivered, failed, cover, dummy) } func init() { emailRegExp = regexp.MustCompile(`^[a-zA-Z0-9._%+=\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) localPartRegex = regexp.MustCompile(`^[a-zA-Z0-9._+=\-]+$`) domainRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) stats = NewStatistics() shutdownSignal = make(chan struct{}) } // sanitizeLogString prevents log injection attacks (NEW) func sanitizeLogString(s string) string { // Remove newlines, carriage returns, and other control characters s = strings.ReplaceAll(s, "\n", "\\n") s = strings.ReplaceAll(s, "\r", "\\r") s = strings.ReplaceAll(s, "\t", "\\t") // Limit length to prevent log flooding if len(s) > 200 { s = s[:200] + "..." } return s } func validateLocalPart(localPart string) error { if len(localPart) == 0 || len(localPart) > 64 { return errors.New("local part must be 1-64 characters") } if strings.HasPrefix(localPart, ".") || strings.HasSuffix(localPart, ".") { return errors.New("local part cannot start or end with a dot") } if strings.Contains(localPart, "..") { return errors.New("local part cannot contain consecutive dots") } if !localPartRegex.MatchString(localPart) { return errors.New("local part contains invalid characters") } return nil } func isOnionDomain(domain string) bool { domain = strings.ToLower(strings.TrimSpace(domain)) if domain == "" { return false } if strings.HasSuffix(domain, ".") { domain = domain[:len(domain)-1] } return strings.HasSuffix(domain, ".onion") } func validateDomain(domain string) error { domain = strings.ToLower(strings.TrimSpace(domain)) if strings.HasSuffix(domain, ".") { domain = domain[:len(domain)-1] } if len(domain) == 0 || len(domain) > 253 { return errors.New("domain must be 1-253 characters") } if isOnionDomain(domain) { onionName := strings.TrimSuffix(domain, ".onion") if len(onionName) != 16 && len(onionName) != 56 { return errors.New("invalid .onion domain length") } validOnion := regexp.MustCompile(`^[a-z2-7]+$`) if !validOnion.MatchString(onionName) { return errors.New("invalid .onion domain format") } return nil } if !domainRegex.MatchString(domain) { return errors.New("invalid domain format") } labels := strings.Split(domain, ".") if len(labels) < 2 { return errors.New("domain must have at least two labels") } for _, label := range labels { if len(label) == 0 || len(label) > 63 { return errors.New("domain label must be 1-63 characters") } } return nil } func ValidateEmailAddress(address string) (string, string, error) { address = strings.TrimSpace(address) address = strings.Trim(address, "<>") if !strings.Contains(address, "@") { return "", "", errors.New("invalid email address: missing '@'") } sepInd := strings.LastIndex(address, "@") if sepInd == 0 || sepInd == len(address)-1 { return "", "", errors.New("invalid email address: empty local part or domain") } localPart := address[:sepInd] domain := address[sepInd+1:] if err := validateLocalPart(localPart); err != nil { return "", "", fmt.Errorf("invalid local part: %w", err) } if err := validateDomain(domain); err != nil { return "", "", fmt.Errorf("invalid domain: %w", err) } return localPart, domain, nil } func validateServerName(name string) error { name = strings.TrimSpace(name) if name == "" { return errors.New("server name cannot be empty") } if !isOnionDomain(name) { return errors.New("server name must be a .onion domain") } onionName := strings.TrimSuffix(strings.ToLower(name), ".onion") if len(onionName) != 56 { return errors.New("server name must be a v3 .onion address (56 characters)") } validOnionV3 := regexp.MustCompile(`^[a-z2-7]{56}$`) if !validOnionV3.MatchString(onionName) { return errors.New("invalid v3 .onion address format") } return nil } // extractDomainFromAddress extracts domain from email address (FIXED: was missing) func extractDomainFromAddress(address string) string { _, domain, err := ValidateEmailAddress(address) if err != nil { return "" } return domain } func hasMXRecords(domain string) bool { if isOnionDomain(domain) { return true } domain = strings.ToLower(strings.TrimSpace(domain)) if strings.HasSuffix(domain, ".") { domain = domain[:len(domain)-1] } done := make(chan bool, 1) var hasMX bool go func() { defer func() { if r := recover(); r != nil { hasMX = false } done <- true }() mxRecords, err := net.LookupMX(domain) hasMX = err == nil && len(mxRecords) > 0 }() select { case <-done: return hasMX case <-time.After(MXLookupTimeout): return false } } type MessageIDCache struct { cache map[string]time.Time mu sync.RWMutex } func NewMessageIDCache() *MessageIDCache { cache := &MessageIDCache{cache: make(map[string]time.Time)} go cache.cleanupLoop() return cache } func (c *MessageIDCache) Has(messageID string) bool { c.mu.RLock() defer c.mu.RUnlock() expiry, exists := c.cache[messageID] if !exists { return false } return time.Now().Before(expiry) } func (c *MessageIDCache) Add(messageID string) bool { c.mu.Lock() defer c.mu.Unlock() if _, exists := c.cache[messageID]; exists { return false } c.cache[messageID] = time.Now().Add(MessageIDCacheDuration) return true } func (c *MessageIDCache) cleanupLoop() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: c.mu.Lock() now := time.Now() for id, expiry := range c.cache { if now.After(expiry) { delete(c.cache, id) } } c.mu.Unlock() case <-shutdownSignal: return } } } type RateLimiter struct { requests map[string][]time.Time mu sync.RWMutex } func NewRateLimiter() *RateLimiter { limiter := &RateLimiter{requests: make(map[string][]time.Time)} go limiter.cleanupLoop() return limiter } func (rl *RateLimiter) Allow(clientIP string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() cutoff := now.Add(-RateLimitWindow) requests := rl.requests[clientIP] var validRequests []time.Time for _, t := range requests { if t.After(cutoff) { validRequests = append(validRequests, t) } } if len(validRequests) >= RateLimitPerIP { rl.requests[clientIP] = validRequests return false } validRequests = append(validRequests, now) rl.requests[clientIP] = validRequests return true } func (rl *RateLimiter) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: rl.mu.Lock() now := time.Now() cutoff := now.Add(-RateLimitWindow * 2) for ip, requests := range rl.requests { var validRequests []time.Time for _, t := range requests { if t.After(cutoff) { validRequests = append(validRequests, t) } } if len(validRequests) == 0 { delete(rl.requests, ip) } else { rl.requests[ip] = validRequests } } rl.mu.Unlock() case <-shutdownSignal: return } } } func cryptoRandInt63n(max int64) int64 { if max <= 0 { return 0 } n, err := rand.Int(rand.Reader, big.NewInt(max)) if err != nil { return time.Now().UnixNano() % max } return n.Int64() } func applyPadding(data []byte, targetSize int) []byte { if len(data) >= targetSize { return data } padding := make([]byte, targetSize-len(data)) if _, err := rand.Read(padding); err != nil { // Fallback to zero padding if random fails padding = make([]byte, targetSize-len(data)) } return append(data, padding...) } // normalizeToBucket finds the appropriate size bucket and pads the message (NEW) func normalizeToBucket(data []byte) ([]byte, error) { currentSize := int64(len(data)) // Find the smallest bucket that fits the data var targetBucket int64 for _, bucket := range MessageSizeBuckets { if currentSize <= bucket { targetBucket = bucket break } } // If data is larger than largest bucket, reject if targetBucket == 0 { return nil, fmt.Errorf("message too large: %d bytes (max %d)", currentSize, MessageSizeBuckets[len(MessageSizeBuckets)-1]) } // Pad to target bucket size return applyPadding(data, int(targetBucket)), nil } // fuzzTimestamp adds random offset to timestamp (NEW) func fuzzTimestamp(t time.Time) time.Time { // Add random offset between -2h and +2h maxOffset := int64(TimestampFuzzRange) offset := cryptoRandInt63n(maxOffset*2) - maxOffset return t.Add(time.Duration(offset)) } // sanitizeHeaders removes revealing headers and normalizes others (NEW) func sanitizeHeaders(data []byte) []byte { lines := strings.Split(string(data), "\r\n") var sanitized []string revealingHeaders := map[string]bool{ "x-mailer": true, "user-agent": true, "x-originating-ip": true, "x-forwarded-for": true, "x-sender": true, "x-priority": true, "importance": true, "x-msmail-priority": true, } inHeader := true for _, line := range lines { // Empty line marks end of headers if line == "" { inHeader = false sanitized = append(sanitized, line) continue } if inHeader && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) headerName := strings.ToLower(strings.TrimSpace(parts[0])) // Skip revealing headers if revealingHeaders[headerName] { continue } // Normalize Message-ID format if headerName == "message-id" { // Replace with our own format continue // Will be added by generateMessageID } // Normalize Date header with fuzzing if headerName == "date" { // Parse and fuzz timestamp continue // Will be added with fuzzed timestamp } } sanitized = append(sanitized, line) } return []byte(strings.Join(sanitized, "\r\n")) } // MessageFragment represents a fragment of a larger message (NEW) type MessageFragment struct { FragmentID string TotalParts int PartNumber int Data []byte OriginalHash string } // fragmentMessage splits a large message into random-sized fragments (NEW) func fragmentMessage(data []byte, messageID string) ([]*MessageFragment, error) { dataLen := len(data) // Decide on fragment sizes (random between 16KB and 64KB) var fragments []*MessageFragment offset := 0 partNumber := 1 // Calculate hash of original message hash := sha512.Sum512(data) originalHash := base64.URLEncoding.EncodeToString(hash[:16]) for offset < dataLen { // Random fragment size between 16KB and 64KB fragSize := int(16*1024 + cryptoRandInt63n(48*1024)) if offset+fragSize > dataLen { fragSize = dataLen - offset } fragment := &MessageFragment{ FragmentID: messageID, PartNumber: partNumber, Data: data[offset : offset+fragSize], OriginalHash: originalHash, } fragments = append(fragments, fragment) offset += fragSize partNumber++ } // Set total parts for all fragments totalParts := len(fragments) for _, frag := range fragments { frag.TotalParts = totalParts } return fragments, nil } // encodeFragment encodes a fragment into email format (NEW) func encodeFragment(frag *MessageFragment) []byte { header := fmt.Sprintf("X-Fog-Fragment-ID: %s\r\n", frag.FragmentID) header += fmt.Sprintf("X-Fog-Fragment-Part: %d/%d\r\n", frag.PartNumber, frag.TotalParts) header += fmt.Sprintf("X-Fog-Fragment-Hash: %s\r\n", frag.OriginalHash) header += "\r\n" return append([]byte(header), frag.Data...) } type Envelope struct { MessageFrom string MessageTo string MessageData io.Reader ReceivedAt time.Time RetryCount int MessageID string IsCoverTraffic bool IsDummy bool // NEW: marks dummy recipients OriginalSize int64 // NEW: track original size before padding } // TorConnection represents a pooled Tor connection (NEW) type TorConnection struct { dialer proxy.Dialer createdAt time.Time usageCount int mu sync.Mutex } // ConnectionPool manages Tor connections (NEW) type ConnectionPool struct { connections []*TorConnection current int mu sync.Mutex } func NewConnectionPool(size int) (*ConnectionPool, error) { pool := &ConnectionPool{ connections: make([]*TorConnection, size), } // Initialize connections for i := 0; i < size; i++ { dialer := &net.Dialer{Timeout: DeliveryTimeout} torDialer, err := proxy.SOCKS5("tcp", TorSocksProxyAddr, nil, dialer) if err != nil { return nil, fmt.Errorf("failed to create Tor dialer %d: %w", i, err) } pool.connections[i] = &TorConnection{ dialer: torDialer, createdAt: time.Now(), usageCount: 0, } } // Start rotation goroutine go pool.rotationLoop() return pool, nil } func (cp *ConnectionPool) GetConnection() *TorConnection { cp.mu.Lock() defer cp.mu.Unlock() // Round-robin selection conn := cp.connections[cp.current] cp.current = (cp.current + 1) % len(cp.connections) conn.mu.Lock() conn.usageCount++ conn.mu.Unlock() return conn } func (cp *ConnectionPool) rotationLoop() { ticker := time.NewTicker(ConnectionMaxAge) defer ticker.Stop() for { select { case <-ticker.C: cp.mu.Lock() now := time.Now() // Rotate old connections for i, conn := range cp.connections { conn.mu.Lock() age := now.Sub(conn.createdAt) if age > ConnectionMaxAge { // Create new connection dialer := &net.Dialer{Timeout: DeliveryTimeout} torDialer, err := proxy.SOCKS5("tcp", TorSocksProxyAddr, nil, dialer) if err != nil { log.Printf("[POOL] Failed to rotate connection %d: %v", i, err) conn.mu.Unlock() continue } cp.connections[i] = &TorConnection{ dialer: torDialer, createdAt: now, usageCount: 0, } log.Printf("[POOL] Rotated connection %d (age: %v, usage: %d)", i, age, conn.usageCount) } conn.mu.Unlock() } cp.mu.Unlock() case <-shutdownSignal: return } } } type MixnetBatcher struct { envelopes []*Envelope mu sync.Mutex queue chan *Envelope batchWindow time.Duration lastBatchAt time.Time connPool *ConnectionPool // NEW: connection pool } func NewMixnetBatcher(queue chan *Envelope) *MixnetBatcher { // Create connection pool connPool, err := NewConnectionPool(ConnectionPoolSize) if err != nil { log.Fatalf("[BATCHER] Failed to create connection pool: %v", err) } batcher := &MixnetBatcher{ envelopes: make([]*Envelope, 0), queue: queue, batchWindow: MixnetBatchWindow, lastBatchAt: time.Now(), connPool: connPool, } go batcher.processBatches() return batcher } func (mb *MixnetBatcher) Add(envelope *Envelope) { mb.mu.Lock() mb.envelopes = append(mb.envelopes, envelope) mb.mu.Unlock() } // generateDummyRecipients creates dummy recipients for the batch (NEW) func (mb *MixnetBatcher) generateDummyRecipients(count int, serverName string) []*Envelope { dummies := make([]*Envelope, count) for i := 0; i < count; i++ { // Generate random recipient randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); err != nil { continue } dummyRecipient := fmt.Sprintf("dummy-%s@%s", base64.URLEncoding.EncodeToString(randomBytes[:8]), serverName) // Create dummy envelope with random data dummyData := make([]byte, int(32*1024+cryptoRandInt63n(32*1024))) if _, err := rand.Read(dummyData); err != nil { continue } dummies[i] = &Envelope{ MessageFrom: fmt.Sprintf("system@%s", serverName), MessageTo: dummyRecipient, MessageData: bytes.NewReader(dummyData), ReceivedAt: time.Now(), RetryCount: 0, MessageID: generateMessageID(), IsCoverTraffic: false, IsDummy: true, } stats.IncDummy() } return dummies } func (mb *MixnetBatcher) processBatches() { ticker := time.NewTicker(mb.batchWindow) defer ticker.Stop() for { select { case <-ticker.C: mb.flushBatch() case <-shutdownSignal: // Flush remaining batch before shutting down mb.flushBatch() return } } } func (mb *MixnetBatcher) flushBatch() { mb.mu.Lock() if len(mb.envelopes) == 0 { mb.mu.Unlock() return } batch := mb.envelopes mb.envelopes = make([]*Envelope, 0) mb.lastBatchAt = time.Now() mb.mu.Unlock() // Add dummy recipients (NEW) dummyCount := int(DummyRecipientsMin + cryptoRandInt63n(int64(DummyRecipientsMax-DummyRecipientsMin+1))) serverName := "localhost.onion" // Will be set properly by the server dummies := mb.generateDummyRecipients(dummyCount, serverName) batch = append(batch, dummies...) log.Printf("[BATCH] Flushing %d messages (including %d dummies) with inter-message jitter", len(batch), dummyCount) // Shuffle batch to randomize order for i := len(batch) - 1; i > 0; i-- { j := int(cryptoRandInt63n(int64(i + 1))) batch[i], batch[j] = batch[j], batch[i] } // Apply adaptive padding to all messages for _, env := range batch { data, err := io.ReadAll(env.MessageData) if err != nil { log.Printf("[BATCH] Error reading message data: %v", err) continue } // Store original size env.OriginalSize = int64(len(data)) // Sanitize headers (NEW) data = sanitizeHeaders(data) // Normalize to bucket size (NEW) normalizedData, err := normalizeToBucket(data) if err != nil { log.Printf("[BATCH] Error normalizing message: %v", err) continue } env.MessageData = bytes.NewReader(normalizedData) // Apply exponential jitter between messages (NEW) baseDelay := time.Duration(100+cryptoRandInt63n(400)) * time.Millisecond exponentialFactor := math.Exp(float64(cryptoRandInt63n(100)) / 100.0) jitter := time.Duration(float64(baseDelay) * exponentialFactor) // Add to queue with jitter go func(e *Envelope, delay time.Duration) { time.Sleep(delay) queueEnvelope(e) }(env, jitter) } } type CoverTrafficGenerator struct { queue chan *Envelope interval time.Duration serverName string } func NewCoverTrafficGenerator(queue chan *Envelope, serverName string) *CoverTrafficGenerator { gen := &CoverTrafficGenerator{ queue: queue, interval: CoverTrafficInterval, serverName: serverName, } go gen.generateCoverTraffic() return gen } func (ctg *CoverTrafficGenerator) generateCoverTraffic() { // Use Poisson distribution for more natural timing (NEW) for { // Calculate next interval using exponential distribution (Poisson process) lambda := 1.0 / CoverTrafficInterval.Seconds() u := float64(cryptoRandInt63n(1000000)) / 1000000.0 nextInterval := time.Duration(-math.Log(1-u)/lambda) * time.Second // Clamp to reasonable bounds if nextInterval < CoverTrafficInterval/2 { nextInterval = CoverTrafficInterval / 2 } if nextInterval > CoverTrafficInterval*2 { nextInterval = CoverTrafficInterval * 2 } timer := time.NewTimer(nextInterval) select { case <-timer.C: ctg.sendCoverTraffic() case <-shutdownSignal: timer.Stop() return } } } func (ctg *CoverTrafficGenerator) sendCoverTraffic() { // Generate random cover traffic recipient randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); err != nil { log.Printf("[COVER] Failed to generate random bytes: %v", err) return } coverRecipient := fmt.Sprintf("cover-%s@%s", base64.URLEncoding.EncodeToString(randomBytes[:8]), ctg.serverName) // Generate random message data dataSize := int(32*1024 + cryptoRandInt63n(96*1024)) coverData := make([]byte, dataSize) if _, err := rand.Read(coverData); err != nil { log.Printf("[COVER] Failed to generate cover data: %v", err) return } envelope := &Envelope{ MessageFrom: fmt.Sprintf("system@%s", ctg.serverName), MessageTo: coverRecipient, MessageData: bytes.NewReader(coverData), ReceivedAt: time.Now(), RetryCount: 0, MessageID: generateMessageID(), IsCoverTraffic: true, } queueEnvelope(envelope) stats.IncCoverTraffic() } type Server struct { Name string Addr string Handler Handler MessageIDCache *MessageIDCache RateLimiter *RateLimiter MixnetBatcher *MixnetBatcher CoverTraffic *CoverTrafficGenerator } type Handler interface { ServeSMTP(envelope *Envelope) error } func (s *Server) Serve(listener net.Listener) error { defer listener.Close() for { select { case <-shutdownSignal: log.Println("[SERVER] Shutdown signal received, stopping listener") return nil default: } // Set deadline to allow periodic checks for shutdown if tcpListener, ok := listener.(*net.TCPListener); ok { tcpListener.SetDeadline(time.Now().Add(1 * time.Second)) } conn, err := listener.Accept() if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { continue } select { case <-shutdownSignal: return nil default: return err } } clientAddr := conn.RemoteAddr().String() clientIP := strings.Split(clientAddr, ":")[0] if !s.RateLimiter.Allow(clientIP) { log.Printf("[SERVER] Rate limit exceeded for %s", sanitizeLogString(clientIP)) conn.Close() continue } log.Printf("[SERVER] New connection from %s", sanitizeLogString(clientAddr)) shutdownWg.Add(1) go func() { defer shutdownWg.Done() s.handleConnection(conn) }() } } func (s *Server) handleConnection(conn net.Conn) { defer conn.Close() // Set read/write deadlines (NEW) conn.SetDeadline(time.Now().Add(5 * time.Minute)) smtpConn := &SMTPConn{ conn: conn, text: textproto.NewConn(conn), server: s, remoteAddr: conn.RemoteAddr().String(), } if err := smtpConn.serve(); err != nil { log.Printf("[CONN] Connection error: %v", err) } } type SMTPConn struct { conn net.Conn text *textproto.Conn server *Server remoteAddr string mailFrom string mailTo []string data bytes.Buffer dataSize int64 // Track data size (NEW) quitSent bool } func (c *SMTPConn) resetSession() { c.mailFrom = "" c.mailTo = nil c.data.Reset() c.dataSize = 0 } func (c *SMTPConn) serve() error { log.Printf("[SMTP] <- %s: Client connected", sanitizeLogString(c.remoteAddr)) if err := c.text.PrintfLine("220 %s %s v%s SMTP Ready", c.server.Name, AppName, Version); err != nil { return err } log.Printf("[SMTP] -> %s: 220 %s %s v%s SMTP Ready", sanitizeLogString(c.remoteAddr), c.server.Name, AppName, Version) for { // Check for shutdown select { case <-shutdownSignal: c.text.PrintfLine("421 Service shutting down") return nil default: } // Update deadline for each command c.conn.SetDeadline(time.Now().Add(5 * time.Minute)) line, err := c.text.ReadLine() if err != nil { if err == io.EOF { log.Printf("[SMTP] <- %s: Client disconnected (EOF)", sanitizeLogString(c.remoteAddr)) } else { log.Printf("[SMTP] <- %s: Read error: %v", sanitizeLogString(c.remoteAddr), err) } return err } // Check header size limit (NEW) if int64(len(line)) > MaxHeaderSize { log.Printf("[SMTP] -> %s: 552 Header line too long", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("552 Header line too long") } log.Printf("[SMTP] <- %s: %s", sanitizeLogString(c.remoteAddr), sanitizeLogString(line)) if err := c.handleCommand(line); err != nil { return err } if c.quitSent { return nil } } } func (c *SMTPConn) handleCommand(line string) error { parts := strings.Fields(line) if len(parts) == 0 { log.Printf("[SMTP] -> %s: 500 Empty command", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("500 Empty command") } cmd := strings.ToUpper(parts[0]) // Apply random delay for timing attack prevention minDelay := MinDelay maxDelay := MaxDelay delay := time.Duration(cryptoRandInt63n(int64(maxDelay-minDelay))) + minDelay time.Sleep(delay) switch cmd { case "HELO", "EHLO": log.Printf("[SMTP] -> %s: 250 %s", sanitizeLogString(c.remoteAddr), c.server.Name) return c.text.PrintfLine("250 %s", c.server.Name) case "MAIL": if len(parts) < 2 || !strings.HasPrefix(strings.ToUpper(parts[1]), "FROM:") { log.Printf("[SMTP] -> %s: 501 Syntax: MAIL FROM:
", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("501 Syntax: MAIL FROM:") } from := strings.TrimPrefix(parts[1], "FROM:") from = strings.TrimPrefix(from, "from:") from = strings.Trim(from, "<>") // Validate and sanitize email (NEW) _, _, err := ValidateEmailAddress(from) if err != nil { log.Printf("[SMTP] -> %s: 553 Invalid sender address: %v", sanitizeLogString(c.remoteAddr), err) return c.text.PrintfLine("553 Invalid sender address") } c.mailFrom = from log.Printf("[SMTP] -> %s: 250 OK (sender: %s)", sanitizeLogString(c.remoteAddr), sanitizeLogString(from)) return c.text.PrintfLine("250 OK") case "RCPT": if c.mailFrom == "" { log.Printf("[SMTP] -> %s: 503 Need MAIL command first", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("503 Need MAIL command first") } // Check max recipients (NEW) if len(c.mailTo) >= MaxRecipients { log.Printf("[SMTP] -> %s: 452 Too many recipients", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("452 Too many recipients") } if len(parts) < 2 || !strings.HasPrefix(strings.ToUpper(parts[1]), "TO:") { log.Printf("[SMTP] -> %s: 501 Syntax: RCPT TO:", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("501 Syntax: RCPT TO:") } to := strings.TrimPrefix(parts[1], "TO:") to = strings.TrimPrefix(to, "to:") to = strings.Trim(to, "<>") // Validate recipient _, domain, err := ValidateEmailAddress(to) if err != nil { log.Printf("[SMTP] -> %s: 553 Invalid recipient address: %v", sanitizeLogString(c.remoteAddr), err) return c.text.PrintfLine("553 Invalid recipient address") } // Check for email header injection attempts (NEW) if strings.Contains(to, "\r") || strings.Contains(to, "\n") { log.Printf("[SMTP] -> %s: 553 Invalid recipient (injection attempt)", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("553 Invalid recipient") } if !hasMXRecords(domain) { log.Printf("[SMTP] -> %s: 550 No MX records for domain %s", sanitizeLogString(c.remoteAddr), sanitizeLogString(domain)) return c.text.PrintfLine("550 No MX records for domain") } c.mailTo = append(c.mailTo, to) log.Printf("[SMTP] -> %s: 250 OK (recipient: %s)", sanitizeLogString(c.remoteAddr), sanitizeLogString(to)) return c.text.PrintfLine("250 OK") case "DATA": if c.mailFrom == "" || len(c.mailTo) == 0 { log.Printf("[SMTP] -> %s: 503 Need MAIL and RCPT commands first", sanitizeLogString(c.remoteAddr)) return c.text.PrintfLine("503 Need MAIL and RCPT commands first") } log.Printf("[SMTP] -> %s: 354 Start mail input; end with