fix(security): phase 4 code quality and low-severity fixes
CI / test (pull_request) Successful in 4m24s
CI / lint (pull_request) Successful in 1m1s

- Fix limitStr to handle multi-byte UTF-8 characters correctly
- Sanitize log messages: strip ANSI escape sequences and newlines
- URL-encode probe node_id instead of string concatenation
- Fix follower resp.Body leak on non-200 responses
- Make SSH host key path configurable via UPTOP_SSH_HOST_KEY env var
- Add HTTP method checks on GET-only endpoints (405 for wrong methods)
- Extract magic numbers into named constants across monitor/store/server
- Standardize error output to stderr for all startup errors
This commit is contained in:
2026-05-26 17:25:47 -04:00
parent c50ec82dcb
commit 986f9f1d55
7 changed files with 84 additions and 35 deletions
+28 -10
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"math/rand/v2"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -14,6 +16,13 @@ import (
"gitea.lerkolabs.com/lerko/uptop/internal/store"
)
const (
maxLogEntries = 100
pollInterval = 5 * time.Second
pushGracePeriod = 5 * time.Second
minCheckInterval = 5
)
type Engine struct {
mu sync.RWMutex
liveState map[int]models.Site
@@ -78,14 +87,23 @@ func (e *Engine) SetInsecureSkipVerify(skip bool) {
e.insecureSkipVerify = skip
}
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
func sanitizeLog(s string) string {
s = ansiRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}
func (e *Engine) AddLog(msg string) {
e.logMu.Lock()
defer e.logMu.Unlock()
ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, msg)
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
e.logStore = append([]string{entry}, e.logStore...)
if len(e.logStore) > 100 {
e.logStore = e.logStore[:100]
if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries]
}
go func() { _ = e.db.SaveLog(entry) }()
}
@@ -210,7 +228,7 @@ func (e *Engine) Start(ctx context.Context) {
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
select {
case <-time.After(5 * time.Second):
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
@@ -244,7 +262,7 @@ func (e *Engine) Start(ctx context.Context) {
}
select {
case <-time.After(5 * time.Second):
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
@@ -314,7 +332,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if !e.IsActive() {
select {
case <-time.After(5 * time.Second):
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
@@ -330,7 +348,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if site.Paused {
select {
case <-time.After(5 * time.Second):
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
@@ -338,8 +356,8 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
}
interval := site.Interval
if interval < 5 {
interval = 5
if interval < minCheckInterval {
interval = minCheckInterval
}
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select {
@@ -380,7 +398,7 @@ func (e *Engine) checkByID(id int) {
}
func (e *Engine) checkPush(site models.Site) {
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second)
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(pushGracePeriod)
if time.Now().After(deadline) {
e.handleStatusChange(site, "DOWN", 0, 0)
} else if site.Status != "UP" {