From bd561d9a5eb2b0ca6df2e5d061f4976dbcfe075a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 26 May 2026 16:57:03 -0400 Subject: [PATCH] fix(security): phase 3 medium reliability and hardening - Fail hard on critical migration errors (ignore only "already exists") - Cache SSH user keys with 30s TTL (avoid DB query per auth attempt) - Configure DB connection pooling (25 open, 5 idle, 5m lifetime) - Enable SQLite WAL mode for concurrent read/write - Optimize check history pruning (only prune above 1100 rows) - Add security headers: X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy - Add CORS policy on /status/json via UPTOP_CORS_ORIGIN env var - Add HTTP request logging middleware (method, path, status, duration, IP) - Fix config file permissions from 0644 to 0600 - Pin Docker images: golang:1.24-alpine3.21, alpine:3.21 - Fix Docker CI tag pattern for CalVer (was semver) - Pass build args (VERSION, COMMIT, BUILD_DATE) to Docker build --- .github/workflows/docker.yml | 12 ++++---- Dockerfile | 4 +-- cmd/uptop/main.go | 58 +++++++++++++++++++++++++++++++----- internal/config/export.go | 7 +++-- internal/server/server.go | 38 +++++++++++++++++++++-- internal/store/sqlite.go | 9 +++++- internal/store/sqlstore.go | 24 +++++++++++---- 7 files changed, 125 insertions(+), 27 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 446e3da..0ced3ab 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,7 +3,7 @@ name: Publish Release on: push: tags: - - 'v*' + - '[0-9]*' jobs: push_to_registry: @@ -31,9 +31,7 @@ jobs: with: images: ${{ secrets.DOCKERHUB_USERNAME }}/uptop tags: | - # This turns git tag "v1.0.0" into docker tag "1.0.0" - type=semver,pattern={{version}} - # This updates the "latest" tag to this version + type=match,pattern=\d+\.\d+\.\d+ type=raw,value=latest - name: Build and push @@ -42,4 +40,8 @@ jobs: context: . push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} diff --git a/Dockerfile b/Dockerfile index e4e804c..54671e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # --- Stage 1: Builder --- -FROM golang:alpine AS builder +FROM golang:1.24-alpine3.21 AS builder RUN apk add --no-cache gcc musl-dev WORKDIR /app COPY go.mod go.sum ./ @@ -12,7 +12,7 @@ ARG BUILD_DATE=unknown RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop/main.go # --- Stage 2: Runner --- -FROM alpine:latest +FROM alpine:3.21 WORKDIR /app RUN apk add --no-cache ca-certificates openssh-client RUN mkdir /data diff --git a/cmd/uptop/main.go b/cmd/uptop/main.go index 9a2977a..26aa1af 100644 --- a/cmd/uptop/main.go +++ b/cmd/uptop/main.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "strconv" + "sync" "syscall" "time" @@ -393,6 +394,7 @@ func runServe(args []string) { TLSKey: tlsKey, ClusterMode: clusterMode, MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true", + CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"), }, s, eng) cluster.Start(ctx, cluster.Config{ @@ -401,7 +403,8 @@ func runServe(args []string) { SharedKey: clusterKey, }, eng) - sshSrv := startSSHServer(*port, s, eng) + kc := newKeyCache(s) + sshSrv := startSSHServer(*port, s, eng, kc) if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion()) @@ -431,12 +434,12 @@ func runServe(args []string) { } } -func startSSHServer(port int, db store.Store, eng *monitor.Engine) *ssh.Server { +func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache) *ssh.Server { s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { - return isKeyAllowed(db, key) + return kc.IsAllowed(key) }), wish.WithMiddleware( bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { @@ -505,17 +508,56 @@ func seedDemoData(s store.Store) { } } -func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { - users, err := db.GetAllUsers() +type keyCache struct { + mu sync.RWMutex + keys []ssh.PublicKey + updated time.Time + ttl time.Duration + db store.Store +} + +func newKeyCache(db store.Store) *keyCache { + return &keyCache{db: db, ttl: 30 * time.Second} +} + +func (c *keyCache) refresh() { + users, err := c.db.GetAllUsers() if err != nil { - return false + return } + keys := make([]ssh.PublicKey, 0, len(users)) for _, u := range users { - allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) + k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) if err != nil { continue } - if ssh.KeysEqual(allowedKey, incomingKey) { + keys = append(keys, k) + } + c.mu.Lock() + c.keys = keys + c.updated = time.Now() + c.mu.Unlock() +} + +func (c *keyCache) Invalidate() { + c.mu.Lock() + c.updated = time.Time{} + c.mu.Unlock() +} + +func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool { + c.mu.RLock() + stale := time.Since(c.updated) > c.ttl + c.mu.RUnlock() + + if stale { + c.refresh() + } + + c.mu.RLock() + defer c.mu.RUnlock() + for _, k := range c.keys { + if ssh.KeysEqual(k, incomingKey) { return true } } diff --git a/internal/config/export.go b/internal/config/export.go index 57708fb..d3e6795 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -2,11 +2,12 @@ package config import ( "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/models" - "gitea.lerkolabs.com/lerko/uptop/internal/store" "os" "sort" + "gitea.lerkolabs.com/lerko/uptop/internal/models" + "gitea.lerkolabs.com/lerko/uptop/internal/store" + "gopkg.in/yaml.v3" ) @@ -142,7 +143,7 @@ func WriteFile(f *File, path string) error { _, err = os.Stdout.Write(data) return err } - return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable + return os.WriteFile(path, data, 0600) } func LoadFile(path string) (*File, error) { diff --git a/internal/server/server.go b/internal/server/server.go index 63e53df..1f9d3a4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -187,6 +187,7 @@ type ServerConfig struct { TLSKey string ClusterMode string MetricsPublic bool + CORSOrigin string } func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { @@ -449,6 +450,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { } state[id] = site } + if cfg.CORSOrigin != "" { + w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(state) //nolint:errcheck })) @@ -458,9 +462,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.") } - var handler http.Handler = mux + handler := loggingMiddleware(securityHeadersMiddleware(mux)) if cfg.TLSCert != "" { - handler = hstsMiddleware(mux) + handler = hstsMiddleware(handler) } addr := fmt.Sprintf(":%d", cfg.Port) @@ -488,6 +492,36 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { return srv } +type statusWriter struct { + http.ResponseWriter + code int +} + +func (w *statusWriter) WriteHeader(code int) { + w.code = code + w.ResponseWriter.WriteHeader(code) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, code: 200} + next.ServeHTTP(sw, r) + path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "") + log.Printf("%s %s %d %s %s", r.Method, path, sw.code, time.Since(start).Round(time.Millisecond), clientIP(r)) //nolint:gosec // path sanitized above + }) +} + +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'") + next.ServeHTTP(w, r) + }) +} + func hstsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 3de2c95..31b7880 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -10,7 +10,14 @@ import ( type SQLiteDialect struct{} func NewSQLiteStore(path string) (*SQLStore, error) { - return NewSQLStore("sqlite3", path, &SQLiteDialect{}) + s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{}) + if err != nil { + return nil, err + } + if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil { + log.Printf("WAL mode failed: %v", err) + } + return s, nil } func (d *SQLiteDialect) DriverName() string { return "sqlite3" } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 78f3e23..88281cf 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -6,7 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" - "log" + "strings" "time" "gitea.lerkolabs.com/lerko/uptop/internal/models" @@ -24,6 +24,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) { if err != nil { return nil, err } + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) _, isDollar := dialect.(*PostgresDialect) return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil } @@ -70,7 +73,11 @@ func (s *SQLStore) Init() error { } for _, m := range s.dialect.MigrationsSQL() { if _, err := s.db.Exec(m); err != nil { - log.Printf("migration error: %v", err) + errMsg := err.Error() + if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") { + continue + } + return fmt.Errorf("migration failed: %w", err) } } return nil @@ -342,10 +349,15 @@ func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, if err != nil { return err } - _, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( - SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000 - )`), siteID, siteID) - return err + var count int + _ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count) + if count > 1100 { + _, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( + SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000 + )`), siteID, siteID) + return err + } + return nil } func (s *SQLStore) RegisterNode(node models.ProbeNode) error {