feat(server): high-priority hardening + critical blockers

Phase 1+2 of the 2026-05-09 review remediation plan:

- Go-version alignment (Dockerfile/go.mod) + 4 nil-deref guards + CI
  docker-build step (Phase 1, 63482c4 carried over).
- Env allowlist: secretEnvKeys denylist replaced; modules opt-in via
  RequiredEnv. Future API keys do not auto-leak.
- Visibility enforcement: dispatcher gates Private/Protected commands
  via BOT_OWNER_ID / ADMIN_USER_IDS; non-permitted callers are silently
  denied.
- Panic recovery in webhook handler; logs runtime/debug.Stack and
  returns 200 to prevent Telegram retry storm.
- Cron timeout reduced 5m -> 60s.
- MaxBytesError handled separately from generic decode errors so 413
  from MaxBytesReader is not shadowed by a 400.
- Emoji clue HTML-escaped defensively in loldle-emoji renderer.
- Tests added for dispatcher Auth.Permits + webhook panic recovery.
This commit is contained in:
tiennm99 committed 2026-05-09 15:52:15 +07:00
1 parent 980126ea28
commit 939c5fedab
21 files changed
+1980 -43

No files matched your search

+49 -22
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@@ -22,14 +23,6 @@ import (
"github.com/tiennm99/miti99bot-go/internal/telegram"
)
// secretEnvKeys are stripped from Deps.Env before any module sees it. Each
// new credential added to the environment must be appended here.
var secretEnvKeys = []string{
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_WEBHOOK_SECRET",
"CRON_SHARED_SECRET",
}
// factories is the static module catalog. Adding a new module is a one-line
// change here. Lives in main rather than the modules package to avoid an
// import cycle (modules → util → modules).
@@ -75,10 +68,14 @@ func main() {
if err != nil {
log.Fatalf("module registry: %v", err)
}
modules.Install(b, reg)
auth := modules.Auth{BotOwnerID: cfg.BotOwnerID, AdminUserIDs: cfg.AdminUserIDs}
modules.Install(b, reg, auth)
log.Printf("loaded %d module(s), %d command(s), %d cron(s)",
len(reg.Modules), len(reg.AllCommands), len(reg.Crons()))
if cfg.BotOwnerID == 0 {
log.Println("WARN: BOT_OWNER_ID unset; all Private + Protected commands will be denied")
}
if cfg.CronSecret == "" {
log.Println("WARN: CRON_SHARED_SECRET unset; /cron/{name} disabled (404 to all)")
}
@@ -159,7 +156,9 @@ type config struct {
GCPProject string
FirestoreEmulatorHost string
Modules []string
ModuleEnv map[string]string // sensitive keys stripped, safe to hand to modules
BotOwnerID int64
AdminUserIDs map[int64]bool
ModuleEnv map[string]string // empty — modules opt in via per-module allowlist (Phase 07+)
}
func loadConfig() config {
@@ -181,21 +180,12 @@ func loadConfig() config {
GCPProject: envMap["GOOGLE_CLOUD_PROJECT"],
FirestoreEmulatorHost: envMap["FIRESTORE_EMULATOR_HOST"],
Modules: splitCSV(envMap["MODULES"]),
ModuleEnv: envForModules(envMap),
BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]),
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
ModuleEnv: map[string]string{}, // allowlist semantics — process env does not auto-flow
}
}
func envForModules(env map[string]string) map[string]string {
out := make(map[string]string, len(env))
for k, v := range env {
out[k] = v
}
for _, k := range secretEnvKeys {
delete(out, k)
}
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
@@ -209,3 +199,40 @@ func splitCSV(s string) []string {
}
return out
}
// parseInt64 returns 0 (the "unset" sentinel) when s is empty or invalid.
// Telegram user IDs are positive int64 so 0 is unambiguously "no value".
func parseInt64(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
log.Printf("WARN: invalid int64 %q in env: %v", s, err)
return 0
}
return n
}
// parseInt64Set parses a comma-separated list of int64 IDs into a set. Bad
// entries are logged and skipped — one malformed admin ID does not deny the
// rest.
func parseInt64Set(s string) map[int64]bool {
if s == "" {
return nil
}
out := map[int64]bool{}
for _, p := range strings.Split(s, ",") {
t := strings.TrimSpace(p)
if t == "" {
continue
}
n, err := strconv.ParseInt(t, 10, 64)
if err != nil {
log.Printf("WARN: invalid admin id %q: %v", t, err)
continue
}
out[n] = true
}
return out
}