Full codebase audit: 21 security/robustness fixes, 328 tests across 4 packages

Security & economy:
- Credit()/Debit() reject non-positive amounts (closes infinite-money exploit)
- Lottery ticket purchase uses in-transaction count check (closes TOCTOU race)
- Arena bail channel ownership prevents double-close panic
- Entry.ID validated before exec.Command in fetch-esteemed
- Internal errors no longer leaked to users (forex, esteemed)

Robustness:
- safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins
- db.Close() added and called on shutdown
- Miniflux mutex pattern fixed (snapshot-under-lock)
- Silently ignored DB/JSON errors now logged (lottery_db)

Performance:
- Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id)

UX:
- Empty !buy args shows usage instead of "No item matching ''"
- "Failed to load character" now suggests !adventure

Test coverage:
- internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing)
- internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey)
- internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks)
- internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow)
- internal/db/db_test.go (4 tests: Init, Close, schema indexes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-08 17:04:40 -07:00
parent 68b2f8b7a5
commit e459b6e78d
34 changed files with 2443 additions and 486 deletions

View File

@@ -2,6 +2,7 @@ package plugin
import (
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -52,15 +53,19 @@ func lotteryCurrentWeekStart() string {
func lotteryTicketCount(userID id.UserID, weekStart string) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&count)
if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&count); err != nil {
slog.Error("lottery: ticket count query failed", "user", userID, "err", err)
}
return count
}
func lotteryTotalTicketCount(weekStart string) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count)
if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count); err != nil {
slog.Error("lottery: total ticket count query failed", "err", err)
}
return count
}
@@ -73,6 +78,16 @@ func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) e
}
defer tx.Rollback()
// Re-check ticket count inside transaction to prevent TOCTOU race.
var existing int
if err := tx.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&existing); err != nil {
return fmt.Errorf("lottery: count tickets in tx: %w", err)
}
if existing+len(tickets) > 100 {
return fmt.Errorf("lottery: ticket limit exceeded (have %d, buying %d)", existing, len(tickets))
}
for _, nums := range tickets {
data, _ := json.Marshal(nums)
_, err := tx.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`,
@@ -167,7 +182,9 @@ func lotteryLoadHistory(limit int) ([]lotteryHistoryRow, error) {
&h.PotTotal, &h.RolledOver); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(winJSON), &h.WinningNumbers)
if err := json.Unmarshal([]byte(winJSON), &h.WinningNumbers); err != nil {
slog.Warn("lottery: corrupt winning_numbers JSON", "draw", h.DrawDate, "err", err)
}
history = append(history, h)
}
return history, rows.Err()
@@ -200,7 +217,9 @@ func scanLotteryTickets(rows lotteryRows) ([]lotteryTicket, error) {
if err := rows.Scan(&t.ID, &t.UserID, &t.WeekStart, &numsJSON, &matchCount, &prize); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(numsJSON), &t.Numbers)
if err := json.Unmarshal([]byte(numsJSON), &t.Numbers); err != nil {
slog.Warn("lottery: corrupt ticket numbers JSON", "id", t.ID, "err", err)
}
t.MatchCount = matchCount
t.Prize = prize
tickets = append(tickets, t)