From 5e529446db7380e9f2c184ffc92b7eac7eee838f Mon Sep 17 00:00:00 2001 From: Artem Lytkin Date: Mon, 16 Feb 2026 10:35:26 +0300 Subject: [PATCH] add: refine tournament participant validation, enforce user role type checks, limit request body size, and implement automatic partition maintenance --- cmd/api/main.go | 1 + internal/api/handlers/game.go | 4 +- internal/api/handlers/game_test.go | 2 +- internal/api/routes.go | 1 + internal/domain/validation.go | 6 +-- internal/infrastructure/db/db.go | 24 ++++++++++ internal/infrastructure/executor/executor.go | 13 +++++ .../executor/executor_unit_test.go | 48 +++++++++++++++++-- 8 files changed, 90 insertions(+), 9 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 886414a..b40fa58 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -93,6 +93,7 @@ func main() { if err := database.EnsureMatchPartitions(context.Background()); err != nil { log.Error("Failed to ensure match partitions", zap.Error(err)) } + database.StartPartitionMaintenance() // Подключаемся к Redis redisCache, err := cache.New(&cfg.Redis, log, m) diff --git a/internal/api/handlers/game.go b/internal/api/handlers/game.go index 63ea075..9cfd0aa 100644 --- a/internal/api/handlers/game.go +++ b/internal/api/handlers/game.go @@ -299,10 +299,10 @@ func (h *GameHandler) AddGameToTournament(w http.ResponseWriter, r *http.Request writeError(w, errors.ErrUnauthorized) return } - userRole, _ := r.Context().Value(middleware.RoleKey).(string) + userRole, _ := r.Context().Value(middleware.RoleKey).(domain.Role) // Проверяем права: админ или создатель турнира - isAdmin := userRole == "admin" + isAdmin := userRole == domain.RoleAdmin isCreator := false if !isAdmin && h.tournamentRepo != nil { diff --git a/internal/api/handlers/game_test.go b/internal/api/handlers/game_test.go index 9aa4022..47cb674 100644 --- a/internal/api/handlers/game_test.go +++ b/internal/api/handlers/game_test.go @@ -552,7 +552,7 @@ func TestGameHandler_AddGameToTournament_AdminSuccess(t *testing.T) { rctx.URLParams.Add("id", tournamentID.String()) ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, middleware.UserIDKey, userID) - ctx = context.WithValue(ctx, middleware.RoleKey, "admin") + ctx = context.WithValue(ctx, middleware.RoleKey, domain.RoleAdmin) req = req.WithContext(ctx) rr := httptest.NewRecorder() diff --git a/internal/api/routes.go b/internal/api/routes.go index 518c399..ba0e8fd 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -234,6 +234,7 @@ func (s *Server) setupRoutes() { // Program routes (все требуют аутентификации) r.Route("/programs", func(r chi.Router) { r.Use(middleware.Auth(s.authService, s.log)) + r.Use(middleware.MaxBodySize(10 << 20)) // 10MB for file uploads r.Post("/", s.programHandler.Create) r.Get("/", s.programHandler.List) diff --git a/internal/domain/validation.go b/internal/domain/validation.go index f00ba73..f1c8e17 100644 --- a/internal/domain/validation.go +++ b/internal/domain/validation.go @@ -114,9 +114,9 @@ func (t *Tournament) Validate() error { } } - // Валидация max_participants - if t.MaxParticipants != nil && *t.MaxParticipants <= 0 { - errs.Add("max_participants", "max_participants must be positive") + // Валидация max_participants (минимум 2, т.к. турнир требует ≥2 участников) + if t.MaxParticipants != nil && *t.MaxParticipants < 2 { + errs.Add("max_participants", "max_participants must be at least 2") } if errs.HasErrors() { diff --git a/internal/infrastructure/db/db.go b/internal/infrastructure/db/db.go index 9399786..26a804f 100644 --- a/internal/infrastructure/db/db.go +++ b/internal/infrastructure/db/db.go @@ -170,6 +170,30 @@ func (db *DB) EnsureMatchPartitions(ctx context.Context) error { return nil } +// StartPartitionMaintenance launches a background goroutine that periodically +// ensures match partitions exist for the current and next month. This prevents +// partition-not-found errors if the application runs for extended periods +// without restart. +func (db *DB) StartPartitionMaintenance() { + go func() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := db.EnsureMatchPartitions(ctx); err != nil { + db.log.Error("Periodic partition maintenance failed", zap.Error(err)) + } + cancel() + case <-db.done: + return + } + } + }() +} + // Close закрывает соединение с базой данных func (db *DB) Close() error { close(db.done) diff --git a/internal/infrastructure/executor/executor.go b/internal/infrastructure/executor/executor.go index 4552729..fb61f3b 100644 --- a/internal/infrastructure/executor/executor.go +++ b/internal/infrastructure/executor/executor.go @@ -320,6 +320,19 @@ func (e *Executor) parseResult(exitCode int64, stdout, stderr string) (*domain.M return nil, fmt.Errorf("invalid score2: %s", scores[1]) } + // Validate score bounds to prevent extreme values from malicious bot output. + // Scale the ceiling with configured iterations so legitimate high-iteration + // runs are not rejected. 1000 points-per-iteration is a generous upper + // bound for any supported game type; the floor of 100 000 covers the + // default 100-iteration setting comfortably. + maxScore := e.config.DefaultIterations * 1000 + if maxScore < 100_000 { + maxScore = 100_000 + } + if score1 < 0 || score1 > maxScore || score2 < 0 || score2 > maxScore { + return nil, fmt.Errorf("scores out of bounds [0, %d]: %d, %d", maxScore, score1, score2) + } + result.Score1 = score1 result.Score2 = score2 diff --git a/internal/infrastructure/executor/executor_unit_test.go b/internal/infrastructure/executor/executor_unit_test.go index ba722b6..3aff6c8 100644 --- a/internal/infrastructure/executor/executor_unit_test.go +++ b/internal/infrastructure/executor/executor_unit_test.go @@ -208,14 +208,56 @@ func TestBoolPtr(t *testing.T) { func TestParseResult_LargeScores(t *testing.T) { e := newTestExecutor(t) - result, err := e.parseResult(0, "999999 888888", "") + // Scores within the allowed bound [0, 100000] + result, err := e.parseResult(0, "99999 88888", "") require.NoError(t, err) - assert.Equal(t, 999999, result.Score1) - assert.Equal(t, 888888, result.Score2) + assert.Equal(t, 99999, result.Score1) + assert.Equal(t, 88888, result.Score2) assert.Equal(t, 1, result.Winner) } +func TestParseResult_ScoresOutOfBounds(t *testing.T) { + e := newTestExecutor(t) + + // Default config (0 iterations) → floor of 100_000 + _, err := e.parseResult(0, "999999 888888", "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "scores out of bounds") +} + +func TestParseResult_HighIterationsAcceptsLargeScores(t *testing.T) { + e := newTestExecutor(t) + e.config.DefaultIterations = 500 // maxScore = 500*1000 = 500_000 + + result, err := e.parseResult(0, "450000 300000", "") + + require.NoError(t, err) + assert.Equal(t, 450000, result.Score1) + assert.Equal(t, 300000, result.Score2) + assert.Equal(t, 1, result.Winner) +} + +func TestParseResult_HighIterationsStillRejectsExtremeScores(t *testing.T) { + e := newTestExecutor(t) + e.config.DefaultIterations = 500 // maxScore = 500_000 + + _, err := e.parseResult(0, "999999 888888", "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "scores out of bounds") +} + +func TestParseResult_NegativeScore(t *testing.T) { + e := newTestExecutor(t) + + _, err := e.parseResult(0, "-1 50", "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "scores out of bounds") +} + func TestParseResult_ZeroScores(t *testing.T) { e := newTestExecutor(t)