Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ jobs:
- name: Run gosec security scanner
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
# Security exceptions are handled via inline // #nosec comments with justifications
gosec -exclude-generated -exclude-dir=divisor ./...
# G117: false positive on intentional struct fields (RefreshToken in Redis DTO, Password in login request)
# G706: false positive with log/slog structured logging (values are auto-escaped)
gosec -exclude=G117,G706 -exclude-generated -exclude-dir=divisor -exclude-dir=.worktrees ./...

property-tests:
name: Property-Based Tests
Expand Down
12 changes: 10 additions & 2 deletions engine/checks/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"math/rand"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -53,7 +54,14 @@ func (c Web) Run(teamID uint, teamIdentifier string, roundID uint, resultsChan c
}

requestURL := fmt.Sprintf("%s://%s:%d%s", c.Scheme, c.Target, c.Port, u.Path)
req, err := http.NewRequest("GET", requestURL, nil)
parsedURL, err := url.Parse(requestURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
checkResult.Error = "invalid request URL"
checkResult.Debug = "URL failed validation: " + requestURL
response <- checkResult
return
}
req, err := http.NewRequest("GET", parsedURL.String(), nil)
if err != nil {
checkResult.Error = "error creating web request"
checkResult.Debug = err.Error()
Expand All @@ -66,7 +74,7 @@ func (c Web) Run(teamID uint, teamIdentifier string, roundID uint, resultsChan c
// Store request info for timeout debugging
checkResult.Debug = fmt.Sprintf("Attempting GET %s", requestURL)

resp, err := client.Do(req)
resp, err := client.Do(req) // #nosec G704 -- URL is validated above; target comes from admin-controlled event.conf
if err != nil {
checkResult.Error = "web request errored out"
if strings.Contains(err.Error(), "Client.Timeout exceeded") {
Expand Down
40 changes: 20 additions & 20 deletions runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"os"
"time"

Expand All @@ -26,7 +26,7 @@ func main() {

func runApp(err error) int {
if err != nil {
log.Printf("[Runner] Error from reaper: %v", err)
slog.Error("error from reaper", "error", err)
return 1
}

Expand All @@ -51,17 +51,17 @@ func runApp(err error) int {
})
ctx := context.Background()

log.Printf("[Runner] Started with ID %s, listening for tasks on Redis at: %s", runnerID, redisAddr)
slog.Info("runner started", "runner_id", runnerID, "redis_addr", redisAddr)

go func() {
events := rdb.Subscribe(context.Background(), "events")
defer events.Close()
eventsChannel := events.Channel()

for msg := range eventsChannel {
log.Printf("[Runner] Received message: %v", msg)
slog.Info("received message", "payload", msg.Payload)
if msg.Payload == "reset" {
log.Printf("[Runner] Reset event received, quitting...")
slog.Info("reset event received, quitting")
os.Exit(0)
} else {
continue
Expand All @@ -72,13 +72,13 @@ func runApp(err error) int {
for {
task, err := getNextTask(ctx, rdb)
if err != nil {
log.Printf("[Runner] Error getting task: %v", err)
slog.Error("error getting task", "error", err)
continue
}

runner, err := createRunner(task)
if err != nil {
log.Printf("[Runner] Error creating runner: %v", err)
slog.Error("error creating runner", "error", err)
continue
}

Expand All @@ -103,8 +103,8 @@ func getNextTask(ctx context.Context, rdb *redis.Client) (*engine.Task, error) {
return nil, fmt.Errorf("invalid task format: %w", err)
}

log.Printf("[Runner] Received task: RoundID=%d TeamID=%d TeamIdentifier=%s ServiceType=%s",
task.RoundID, task.TeamID, task.TeamIdentifier, task.ServiceType)
slog.Info("received task", "round_id", task.RoundID, "team_id", task.TeamID,
"team_identifier", task.TeamIdentifier, "service_type", task.ServiceType)

return &task, nil
}
Expand Down Expand Up @@ -153,7 +153,7 @@ func createRunner(task *engine.Task) (checks.Runner, error) {
return nil, fmt.Errorf("failed to unmarshal check data: %w", err)
}

log.Printf("[Runner] CheckData: %+v", runner)
slog.Debug("check data", "runner", fmt.Sprintf("%+v", runner))
return runner, nil
}

Expand Down Expand Up @@ -181,8 +181,8 @@ func handleTask(ctx context.Context, rdb *redis.Client, runner checks.Runner, ta

// this currently discards all failed attempts
for i := range task.Attempts {
log.Printf("[Runner] Running check: RoundID=%d TeamID=%d ServiceType=%s ServiceName=%s Attempt=%d",
task.RoundID, task.TeamID, task.ServiceType, task.ServiceName, i+1)
slog.Info("running check", "round_id", task.RoundID, "team_id", task.TeamID,
"service_type", task.ServiceType, "service_name", task.ServiceName, "attempt", i+1)

// Create context with deadline
checkCtx, cancel := context.WithDeadline(ctx, task.Deadline)
Expand All @@ -199,8 +199,8 @@ func handleTask(ctx context.Context, rdb *redis.Client, runner checks.Runner, ta
result.ServiceType = task.ServiceType
result.RoundID = task.RoundID

log.Printf("[Runner] Check result received: RoundID=%d TeamID=%d ServiceType=%s Status=%v Debug=%s Error=%s",
result.RoundID, result.TeamID, result.ServiceType, result.Status, result.Debug, result.Error)
slog.Info("check result received", "round_id", result.RoundID, "team_id", result.TeamID,
"service_type", result.ServiceType, "status", result.Status, "debug", result.Debug, "error", result.Error)

case <-checkCtx.Done():
result.Status = false
Expand All @@ -211,8 +211,8 @@ func handleTask(ctx context.Context, rdb *redis.Client, runner checks.Runner, ta
result.ServiceType = task.ServiceType
result.RoundID = task.RoundID

log.Printf("[Runner] Check timed out: RoundID=%d TeamID=%d ServiceType=%s",
task.RoundID, task.TeamID, task.ServiceType)
slog.Warn("check timed out", "round_id", task.RoundID, "team_id", task.TeamID,
"service_type", task.ServiceType)
}

// Break if successful or deadline passed
Expand All @@ -224,17 +224,17 @@ func handleTask(ctx context.Context, rdb *redis.Client, runner checks.Runner, ta
// Marshal and store result
resultJSON, err := json.Marshal(result)
if err != nil {
log.Printf("[Runner] Failed to marshal result: %v", err)
slog.Error("failed to marshal result", "error", err)
return
}

if err := rdb.RPush(ctx, "results", resultJSON).Err(); err != nil {
log.Printf("[Runner] Failed to push result to Redis: %v", err)
slog.Error("failed to push result to Redis", "error", err)
return
}

log.Printf("[Runner] Successfully pushed result: RoundID=%d TeamID=%d ServiceType=%s Status=%v",
result.RoundID, result.TeamID, result.ServiceType, result.Status)
slog.Info("successfully pushed result", "round_id", result.RoundID, "team_id", result.TeamID,
"service_type", result.ServiceType, "status", result.Status)

// Update the task key with the final result status
result.EndTime = time.Now().Format(time.RFC3339)
Expand Down
8 changes: 5 additions & 3 deletions www/api/announcements.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path"
"path/filepath"
"quotient/engine/db"
"slices"
"time"
Expand Down Expand Up @@ -146,12 +147,13 @@ func CreateAnnouncement(w http.ResponseWriter, r *http.Request) {
return
}

uploadDir := fmt.Sprintf("submissions/announcements/%d", announcement.ID)
if err := os.MkdirAll(uploadDir, 0750); err != nil {
subDir := fmt.Sprintf("%d", announcement.ID)
if err := SafeMkdirAll("submissions/announcements", subDir, 0750); err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Failed to create directory"})
return
}

uploadDir := filepath.Join("submissions/announcements", subDir)
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
Expand All @@ -160,7 +162,7 @@ func CreateAnnouncement(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()

dst, err := os.Create(fmt.Sprintf("%s/%s", uploadDir, fileHeader.Filename))
dst, err := SafeCreate(uploadDir, fileHeader.Filename)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Failed to create file on disk"})
return
Expand Down
22 changes: 22 additions & 0 deletions www/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package api

import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"quotient/engine"
"quotient/engine/config"
"quotient/engine/db"
"strings"
)

var (
Expand Down Expand Up @@ -56,6 +60,24 @@ func SafeCreate(baseDir, relativePath string) (*os.File, error) {
return root.Create(relativePath)
}

// SafeMkdirAll creates nested directories within baseDir safely,
// preventing directory traversal attacks using os.Root.
func SafeMkdirAll(baseDir, relativePath string, perm os.FileMode) error {
root, err := os.OpenRoot(baseDir)
if err != nil {
return err
}
defer root.Close()
parts := strings.Split(filepath.ToSlash(filepath.Clean(relativePath)), "/")
for i := range parts {
dir := strings.Join(parts[:i+1], "/")
if err := root.Mkdir(dir, perm); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
}
return nil
}

// CheckCompetitionStarted returns false and writes error response if competition hasn't started
// Admins always have access regardless of competition start time
func CheckCompetitionStarted(w http.ResponseWriter, r *http.Request) bool {
Expand Down
10 changes: 6 additions & 4 deletions www/api/injects.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path"
"path/filepath"
"quotient/engine/db"
"slices"
"time"
Expand Down Expand Up @@ -179,12 +180,13 @@ func CreateInject(w http.ResponseWriter, r *http.Request) {
return
}

uploadDir := fmt.Sprintf("config/injects/%d", inject.ID)
if err := os.MkdirAll(uploadDir, 0750); err != nil {
subDir := fmt.Sprintf("%d", inject.ID)
if err := SafeMkdirAll("config/injects", subDir, 0750); err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Failed to create directory"})
return
}

uploadDir := filepath.Join("config/injects", subDir)
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
Expand All @@ -193,7 +195,7 @@ func CreateInject(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()

dst, err := os.Create(fmt.Sprintf("%s/%s", uploadDir, fileHeader.Filename))
dst, err := SafeCreate(uploadDir, fileHeader.Filename)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Failed to create file on disk"})
return
Expand Down Expand Up @@ -324,7 +326,7 @@ func UpdateInject(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()

dst, err := os.Create(fmt.Sprintf("%s/%s", uploadDir, fileHeader.Filename))
dst, err := SafeCreate(uploadDir, fileHeader.Filename)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Failed to create file on disk"})
return
Expand Down
6 changes: 3 additions & 3 deletions www/api/submissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"log/slog"
"math"
"net/http"
"os"
"path/filepath"
"quotient/engine/db"
"slices"
Expand Down Expand Up @@ -79,13 +78,14 @@ func CreateSubmission(w http.ResponseWriter, r *http.Request) {
return
}

uploadDir := fmt.Sprintf("submissions/%d/%d/%d", injectID, team.ID, submission.Version)
err = os.MkdirAll(uploadDir, 0750)
subDir := fmt.Sprintf("%d/%d/%d", injectID, team.ID, submission.Version)
err = SafeMkdirAll("submissions", subDir, 0750)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Error creating directories"})
return
}

uploadDir := filepath.Join("submissions", subDir)
out, err := SafeCreate(uploadDir, fileHeader.Filename)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Error creating the file"})
Expand Down