Добавлена защита от атак ReDoS в обработке регулярных выражений с ограничением длины паттерна и таймаутом компиляции. Оптимизирован алгоритм ограничения частоты API-запросов с использованием токенов. Улучшено логирование ошибок при получении данных о торрентах и добавлена обработка паник в колбэках завершения загрузок.

This commit is contained in:
Struchkov Mark
2025-12-04 21:26:35 +03:00
parent 013a5491b2
commit e30d50fed7
3 changed files with 138 additions and 37 deletions

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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()
}