From e30d50fed7a4f4083db8a151282b050730f5f5af Mon Sep 17 00:00:00 2001 From: Struchkov Mark Date: Thu, 4 Dec 2025 21:26:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=20=D0=B0=D1=82=D0=B0=D0=BA=20ReDoS=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B5=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D1=83=D0=BB=D1=8F=D1=80=D0=BD=D1=8B=D1=85=20=D0=B2=D1=8B?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=20=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=B4=D0=BB=D0=B8=D0=BD=D1=8B=20=D0=BF=D0=B0=D1=82?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=BD=D0=B0=20=D0=B8=20=D1=82=D0=B0=D0=B9?= =?UTF-8?q?=D0=BC=D0=B0=D1=83=D1=82=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=B8=D0=BB=D1=8F=D1=86=D0=B8=D0=B8.=20=D0=9E=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B0=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC=20=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BE=D1=82=D1=8B=20API-=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BE=D0=B2=20=D1=81=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2.=20=D0=A3?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=BE=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BE=D0=BA=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BE=20=D1=82=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=85=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BF=D0=B0=D0=BD=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BB=D0=B1=D1=8D=D0=BA=D0=B0=D1=85=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BE=D0=BA.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/handlers.go | 82 ++++++++++++++++++++++++++++++++----- internal/bot/helpers.go | 81 +++++++++++++++++++++++++----------- internal/monitor/monitor.go | 12 +++++- 3 files changed, 138 insertions(+), 37 deletions(-) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 651ad0c..0771e21 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -18,6 +18,13 @@ import ( "transmission-telegram/pkg/utils" ) +const ( + // maxRegexPatternLength limits regex pattern length to prevent ReDoS attacks + maxRegexPatternLength = 1000 + // regexCompileTimeout is the maximum time allowed for regex compilation + regexCompileTimeout = 5 * time.Second +) + var ( trackerRegex = regexp.MustCompile(`[https?|udp]://([^:/]*)`) // bufferPool is a pool for reusing bytes.Buffer instances @@ -31,25 +38,66 @@ var ( ) // getCompiledRegex gets or compiles a regex pattern with caching +// Includes protection against ReDoS attacks func getCompiledRegex(pattern string) (*regexp.Regexp, error) { + // Validate pattern length to prevent ReDoS + if len(pattern) > maxRegexPatternLength { + return nil, fmt.Errorf("regex pattern too long (max %d characters)", maxRegexPatternLength) + } + // Check cache first if cached, ok := regexCache.Load(pattern); ok { - return cached.(*regexp.Regexp), nil + // Safe type assertion with check + if regx, ok := cached.(*regexp.Regexp); ok { + return regx, nil + } + // Type mismatch - remove from cache and continue + regexCache.Delete(pattern) } - // Compile and cache - regx, err := regexp.Compile("(?i)" + pattern) - if err != nil { - return nil, err + // Compile with timeout protection + ctx, cancel := context.WithTimeout(context.Background(), regexCompileTimeout) + defer cancel() + + type result struct { + regx *regexp.Regexp + err error } - - regexCache.Store(pattern, regx) - return regx, nil + resultCh := make(chan result, 1) + + go func() { + regx, err := regexp.Compile("(?i)" + pattern) + resultCh <- result{regx: regx, err: err} + }() + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("regex compilation timeout (pattern may be too complex): %s", pattern[:min(len(pattern), 50)]) + case res := <-resultCh: + if res.err != nil { + return nil, fmt.Errorf("regex compilation error: %w", res.err) + } + regexCache.Store(pattern, res.regx) + return res.regx, nil + } +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b } // getBuffer gets a buffer from the pool func getBuffer() *bytes.Buffer { - return bufferPool.Get().(*bytes.Buffer) + item := bufferPool.Get() + if buf, ok := item.(*bytes.Buffer); ok { + return buf + } + // Type mismatch - return a new buffer + return new(bytes.Buffer) } // putBuffer returns a buffer to the pool after resetting it @@ -828,6 +876,7 @@ func (b *Bot) liveUpdateTorrents(ctx context.Context, chatID int64, msgID int, f torrents, err := b.client.GetTorrents() if err != nil { + b.logger.Printf("[ERROR] Failed to get torrents for live update: %s", err) continue } @@ -864,6 +913,7 @@ func (b *Bot) liveUpdateActive(ctx context.Context, chatID int64, msgID int) { torrents, err := b.client.GetTorrents() if err != nil { + b.logger.Printf("[ERROR] Failed to get torrents for live update: %s", err) continue } @@ -892,7 +942,11 @@ func (b *Bot) liveUpdateActive(ctx context.Context, chatID int64, msgID int) { case <-time.After(b.cfg.Interval): } - torrents, _ := b.client.GetTorrents() + torrents, err := b.client.GetTorrents() + if err != nil { + b.logger.Printf("[ERROR] Failed to get torrents for final live update: %s", err) + return + } buf := getBuffer() defer putBuffer(buf) @@ -921,6 +975,7 @@ func (b *Bot) liveUpdateInfo(ctx context.Context, chatID int64, msgID int, torre torrent, err := b.client.GetTorrent(torrentID) if err != nil { + b.logger.Printf("[ERROR] Failed to get torrent %d for live update: %s", torrentID, err) continue } @@ -941,7 +996,11 @@ func (b *Bot) liveUpdateInfo(ctx context.Context, chatID int64, msgID int, torre case <-time.After(b.cfg.Interval): } - torrent, _ := b.client.GetTorrent(torrentID) + torrent, err := b.client.GetTorrent(torrentID) + if err != nil { + b.logger.Printf("[ERROR] Failed to get torrent %d for final live update: %s", torrentID, err) + return + } info := formatter.FormatTorrentInfoStopped(torrent, trackers) // Rate limit before sending rateLimitWait() @@ -962,6 +1021,7 @@ func (b *Bot) liveUpdateSpeed(ctx context.Context, chatID int64, msgID int) { stats, err := b.client.GetStats() if err != nil { + b.logger.Printf("[ERROR] Failed to get stats for live update: %s", err) continue } diff --git a/internal/bot/helpers.go b/internal/bot/helpers.go index 0358f5e..636fd7b 100644 --- a/internal/bot/helpers.go +++ b/internal/bot/helpers.go @@ -3,6 +3,7 @@ package bot import ( "bytes" "fmt" + "os" "strconv" "sync" "time" @@ -14,37 +15,63 @@ import ( ) // rateLimiter limits API calls to Telegram (30 messages per second) -// Uses a channel-based approach to avoid mutex contention +// Uses token bucket algorithm for accurate rate limiting type rateLimiter struct { - ch chan struct{} + tokens float64 + maxTokens float64 + refillRate float64 // tokens per second + lastRefill time.Time + mu sync.Mutex } var ( // telegramRateLimiter limits API calls to 30 per second telegramRateLimiter = func() *rateLimiter { rl := &rateLimiter{ - ch: make(chan struct{}, 1), + tokens: 30.0, // Start with full bucket + maxTokens: 30.0, // Maximum 30 tokens + refillRate: 30.0, // 30 tokens per second + lastRefill: time.Now(), } - // Start the ticker goroutine - go func() { - ticker := time.NewTicker(time.Second / 30) // 30 calls per second - defer ticker.Stop() - for range ticker.C { - // Try to send a token, but don't block if channel is full - select { - case rl.ch <- struct{}{}: - default: - // Channel is full, skip this tick - } - } - }() return rl }() ) -// wait waits for the next available slot +// wait waits for the next available slot using token bucket algorithm func (rl *rateLimiter) wait() { - <-rl.ch + rl.mu.Lock() + defer rl.mu.Unlock() + + // Refill tokens based on elapsed time + now := time.Now() + elapsed := now.Sub(rl.lastRefill).Seconds() + rl.tokens = min(rl.maxTokens, rl.tokens+elapsed*rl.refillRate) + rl.lastRefill = now + + // If we have tokens, consume one and return immediately + if rl.tokens >= 1.0 { + rl.tokens -= 1.0 + return + } + + // Calculate how long to wait for the next token + waitTime := (1.0 - rl.tokens) / rl.refillRate + rl.mu.Unlock() + + // Wait for the token to become available + time.Sleep(time.Duration(waitTime * float64(time.Second))) + + rl.mu.Lock() + rl.tokens = 0.0 // Consume the token + rl.lastRefill = time.Now() +} + +// min returns the minimum of two float64 values +func min(a, b float64) float64 { + if a < b { + return a + } + return b } // rateLimitWait waits for the next available API call slot @@ -59,10 +86,10 @@ func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) // Set typing action action := tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping) - bot.Send(action) - - // Check the rune count, telegram is limited to 4096 chars per message - msgRuneCount := utf8.RuneCountInString(text) + if _, err := bot.Send(action); err != nil { + // Log error but continue - typing action is not critical + fmt.Fprintf(os.Stderr, "[WARN] Failed to send typing action: %v\n", err) + } var lastMsgID int @@ -93,7 +120,10 @@ func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) msg.ParseMode = tgbotapi.ModeMarkdown } - if resp, err := bot.Send(msg); err == nil { + resp, err := bot.Send(msg) + if err != nil { + fmt.Fprintf(os.Stderr, "[ERROR] Failed to send message chunk: %v\n", err) + } else { lastMsgID = resp.MessageID } } @@ -107,7 +137,10 @@ func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) msg.ParseMode = tgbotapi.ModeMarkdown } - if resp, err := bot.Send(msg); err == nil { + resp, err := bot.Send(msg) + if err != nil { + fmt.Fprintf(os.Stderr, "[ERROR] Failed to send message: %v\n", err) + } else { lastMsgID = resp.MessageID } } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 7064c92..000f519 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -114,9 +114,17 @@ func (m *Monitor) checkCompletions() { if completed && m.onComplete != nil { // Call callback outside of lock to avoid potential deadlocks + // Use recover to protect against panics in callback m.statesMutex.Unlock() - m.onComplete(torrent) - m.logger.Printf("[INFO] Torrent completed: %s (ID: %d)", torrent.Name, torrent.ID) + func() { + defer func() { + if r := recover(); r != nil { + m.logger.Printf("[ERROR] Panic recovered in completion callback for torrent %d: %v", torrent.ID, r) + } + }() + m.onComplete(torrent) + m.logger.Printf("[INFO] Torrent completed: %s (ID: %d)", torrent.Name, torrent.ID) + }() m.statesMutex.Lock() }