From 60b30935b3174a42626e7915d9520f65d5d818f8 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Mon, 25 May 2026 11:26:47 -0400 Subject: [PATCH] fix(security): phase 1 critical fixes for public release - Redact PostgreSQL DSN password from stdout/logs - Harden .dockerignore to exclude .ssh/, .claude/, *.db, *.local files - SSRF protection: block private/loopback/link-local IPs by default (UPTOP_ALLOW_PRIVATE_TARGETS=true to override for homelab use) - Fix email header injection via CRLF in monitor names - AES-256-GCM encryption for alert credentials at rest (UPTOP_ENCRYPTION_KEY env var, migrate-secrets subcommand) - TLS support for HTTP server (UPTOP_TLS_CERT/UPTOP_TLS_KEY) with HSTS header when TLS enabled --- .dockerignore | 14 ++- cmd/uptop/main.go | 149 +++++++++++++++++++++++++----- internal/alert/alert.go | 24 ++++- internal/alert/alert_test.go | 20 +++- internal/cluster/cluster_test.go | 9 +- internal/cluster/probe.go | 35 ++++--- internal/monitor/checker.go | 23 ++++- internal/monitor/checker_test.go | 25 ++++- internal/monitor/monitor.go | 52 +++++++---- internal/monitor/safedial.go | 68 ++++++++++++++ internal/monitor/safedial_test.go | 47 ++++++++++ internal/server/server.go | 47 ++++++++-- internal/store/crypto.go | 70 ++++++++++++++ internal/store/crypto_test.go | 83 +++++++++++++++++ internal/store/sqlstore.go | 83 +++++++++++++---- 15 files changed, 650 insertions(+), 99 deletions(-) create mode 100644 internal/monitor/safedial.go create mode 100644 internal/monitor/safedial_test.go create mode 100644 internal/store/crypto.go create mode 100644 internal/store/crypto_test.go diff --git a/.dockerignore b/.dockerignore index 8d0e38d..1b69ae3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,15 @@ .git tmp/ -vendor/ \ No newline at end of file +vendor/ + +# Security: keep sensitive/local files out of Docker build context +.ssh/ +.claude/ +.github/ +.gitea/ +CLAUDE.md +*.local.json +*.local.md +*.local +*.db +*.db-journal diff --git a/cmd/uptop/main.go b/cmd/uptop/main.go index 79968fd..5afc488 100644 --- a/cmd/uptop/main.go +++ b/cmd/uptop/main.go @@ -5,6 +5,14 @@ import ( "errors" "flag" "fmt" + "log" + "net/url" + "os" + "os/signal" + "strconv" + "syscall" + "time" + "gitea.lerkolabs.com/lerko/uptop/internal/cluster" "gitea.lerkolabs.com/lerko/uptop/internal/config" "gitea.lerkolabs.com/lerko/uptop/internal/importer" @@ -13,12 +21,6 @@ import ( "gitea.lerkolabs.com/lerko/uptop/internal/server" "gitea.lerkolabs.com/lerko/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/tui" - "log" - "os" - "os/signal" - "strconv" - "syscall" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/ssh" @@ -47,6 +49,9 @@ func main() { case "version", "--version", "-v": printVersion() return + case "migrate-secrets": + runMigrateSecrets(os.Args[2:]) + return } } runServe(os.Args[1:]) @@ -67,23 +72,42 @@ func envOrDefault(key, fallback string) string { return fallback } +func redactDSN(dsn string) string { + u, err := url.Parse(dsn) + if err != nil { + return "***" + } + u.User = nil + return u.String() +} + func openStore(dbType, dsn string) store.Store { - var s store.Store + var ss *store.SQLStore var err error if dbType == "postgres" { - s, err = store.NewPostgresStore(dsn) + ss, err = store.NewPostgresStore(dsn) } else { - s, err = store.NewSQLiteStore(dsn) + ss, err = store.NewSQLiteStore(dsn) } if err != nil { fmt.Fprintf(os.Stderr, "database error: %v\n", err) os.Exit(1) } - if err := s.Init(); err != nil { + if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" { + enc, err := store.NewEncryptor(encKey) + if err != nil { + fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err) + os.Exit(1) + } + ss.SetEncryptor(enc) + } else { + fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.") + } + if err := ss.Init(); err != nil { fmt.Fprintf(os.Stderr, "database init error: %v\n", err) os.Exit(1) } - return s + return ss } func runApply(args []string) { @@ -142,6 +166,56 @@ func runExport(args []string) { } } +func runMigrateSecrets(args []string) { + fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError) + dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type") + dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN") + _ = fs.Parse(args) + + encKey := os.Getenv("UPTOP_ENCRYPTION_KEY") + if encKey == "" { + fmt.Fprintln(os.Stderr, "error: UPTOP_ENCRYPTION_KEY must be set") + os.Exit(1) + } + enc, err := store.NewEncryptor(encKey) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + var ss *store.SQLStore + if *dbType == "postgres" { + ss, err = store.NewPostgresStore(*dsn) + } else { + ss, err = store.NewSQLiteStore(*dsn) + } + if err != nil { + fmt.Fprintf(os.Stderr, "database error: %v\n", err) + os.Exit(1) + } + if err := ss.Init(); err != nil { + fmt.Fprintf(os.Stderr, "database init error: %v\n", err) + os.Exit(1) + } + + alerts, err := ss.GetAllAlerts() + if err != nil { + fmt.Fprintf(os.Stderr, "error loading alerts: %v\n", err) + os.Exit(1) + } + + ss.SetEncryptor(enc) + migrated := 0 + for _, a := range alerts { + if err := ss.UpdateAlert(a.ID, a.Name, a.Type, a.Settings); err != nil { + fmt.Fprintf(os.Stderr, "error migrating alert %q: %v\n", a.Name, err) + os.Exit(1) + } + migrated++ + } + fmt.Printf("Migrated %d alert(s) to encrypted storage.\n", migrated) +} + func runServe(args []string) { portVal := 23234 dbType := "sqlite" @@ -211,13 +285,19 @@ func runServe(args []string) { cancel() }() + probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true" + if probeAllowPrivate { + fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.") + } + if err := cluster.RunProbe(ctx, cluster.ProbeConfig{ - NodeID: nodeID, - NodeName: nodeName, - Region: nodeRegion, - LeaderURL: clusterPeer, - SharedKey: clusterKey, - Interval: 30, + NodeID: nodeID, + NodeName: nodeName, + Region: nodeRegion, + LeaderURL: clusterPeer, + SharedKey: clusterKey, + Interval: 30, + AllowPrivateTargets: probeAllowPrivate, }); err != nil { fmt.Fprintf(os.Stderr, "Probe error: %v\n", err) } @@ -232,21 +312,33 @@ func runServe(args []string) { importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file") _ = fs.Parse(args) // ExitOnError: parse errors exit before returning - var s store.Store + var ss *store.SQLStore var dbErr error if *flagDBType == "postgres" { - s, dbErr = store.NewPostgresStore(*flagDSN) - fmt.Printf("Using PostgreSQL: %s\n", *flagDSN) + ss, dbErr = store.NewPostgresStore(*flagDSN) + fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN)) } else { - s, dbErr = store.NewSQLiteStore(*flagDSN) + ss, dbErr = store.NewSQLiteStore(*flagDSN) fmt.Printf("Using SQLite: %s\n", *flagDSN) } if dbErr != nil { fmt.Printf("Database connection error: %v\n", dbErr) os.Exit(1) } - defer s.Close() + defer ss.Close() + if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" { + enc, err := store.NewEncryptor(encKey) + if err != nil { + fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err) + os.Exit(1) + } + ss.SetEncryptor(enc) + } else { + fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.") + } + + var s store.Store = ss if err := s.Init(); err != nil { fmt.Printf("Database init error: %v\n", err) os.Exit(1) @@ -269,7 +361,12 @@ func runServe(args []string) { fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) } - eng := monitor.NewEngine(s) + allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true" + if allowPrivate { + fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.") + } + + eng := monitor.NewEngineWithOpts(s, allowPrivate) if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" { eng.SetInsecureSkipVerify(true) } @@ -284,11 +381,17 @@ func runServe(args []string) { eng.InitLogs() eng.Start(ctx) + tlsCert := os.Getenv("UPTOP_TLS_CERT") + tlsKey := os.Getenv("UPTOP_TLS_KEY") + httpSrv := server.Start(server.ServerConfig{ Port: httpPort, EnableStatus: enableStatus, Title: statusTitle, ClusterKey: clusterKey, + TLSCert: tlsCert, + TLSKey: tlsKey, + ClusterMode: clusterMode, }, s, eng) cluster.Start(ctx, cluster.Config{ diff --git a/internal/alert/alert.go b/internal/alert/alert.go index e80a2e7..6a9a235 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -5,12 +5,13 @@ import ( "context" "encoding/json" "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "net/http" "net/smtp" "strconv" "strings" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/models" ) var alertClient = &http.Client{Timeout: 10 * time.Second} @@ -176,6 +177,12 @@ type EmailProvider struct { Host, Port, User, Pass, To, From string } +func sanitizeHeader(s string) string { + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + return s +} + func (e *EmailProvider) Send(ctx context.Context, title, message string) error { select { case <-ctx.Done(): @@ -183,11 +190,18 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error { default: } auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) - msg := []byte("To: " + e.To + "\r\n" + - "Subject: uptop: " + title + "\r\n" + + to := sanitizeHeader(e.To) + from := sanitizeHeader(e.From) + subject := sanitizeHeader(title) + body := strings.ReplaceAll(message, "\r", "") + msg := []byte("From: " + from + "\r\n" + + "To: " + to + "\r\n" + + "Subject: uptop: " + subject + "\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + - message + "\r\n") - return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) + body + "\r\n") + return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg) } type NtfyProvider struct { diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index 3668cd0..955642e 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -3,10 +3,11 @@ package alert import ( "context" "encoding/json" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "net/http" "net/http/httptest" "testing" + + "gitea.lerkolabs.com/lerko/uptop/internal/models" ) func TestHTTPProviderDiscord(t *testing.T) { @@ -212,3 +213,20 @@ func TestGetProviderUnknown(t *testing.T) { t.Error("expected nil for unknown provider type") } } + +func TestSanitizeHeader(t *testing.T) { + tests := []struct { + input, want string + }{ + {"normal subject", "normal subject"}, + {"inject\r\nBcc: evil@bad.com", "injectBcc: evil@bad.com"}, + {"has\nnewline", "hasnewline"}, + {"has\rcarriage", "hascarriage"}, + } + for _, tt := range tests { + got := sanitizeHeader(tt.input) + if got != tt.want { + t.Errorf("sanitizeHeader(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index 504ed35..083b49f 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -3,14 +3,15 @@ package cluster import ( "context" "encoding/json" - "gitea.lerkolabs.com/lerko/uptop/internal/models" - "gitea.lerkolabs.com/lerko/uptop/internal/monitor" "net/http" "net/http/httptest" "sync" "sync/atomic" "testing" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/models" + "gitea.lerkolabs.com/lerko/uptop/internal/monitor" ) // --- Mock Store (minimal, for monitor.NewEngine) --- @@ -295,7 +296,7 @@ func TestProbeExecuteChecks(t *testing.T) { strict := &http.Client{} insecure := &http.Client{} - results := probeExecuteChecks(context.Background(), sites, strict, insecure) + results := probeExecuteChecks(context.Background(), sites, strict, insecure, true) if len(results) != 2 { t.Fatalf("expected 2 results, got %d", len(results)) @@ -329,7 +330,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) { sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL}) } - results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}) + results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true) if len(results) != 20 { t.Errorf("expected 20 results, got %d", len(results)) } diff --git a/internal/cluster/probe.go b/internal/cluster/probe.go index 70197a4..7f36d9e 100644 --- a/internal/cluster/probe.go +++ b/internal/cluster/probe.go @@ -6,21 +6,23 @@ import ( "crypto/tls" "encoding/json" "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/models" - "gitea.lerkolabs.com/lerko/uptop/internal/monitor" "log" "net/http" "sync" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/models" + "gitea.lerkolabs.com/lerko/uptop/internal/monitor" ) type ProbeConfig struct { - NodeID string - NodeName string - Region string - LeaderURL string - SharedKey string - Interval int + NodeID string + NodeName string + Region string + LeaderURL string + SharedKey string + Interval int + AllowPrivateTargets bool } func RunProbe(ctx context.Context, cfg ProbeConfig) error { @@ -29,11 +31,18 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error { } apiClient := &http.Client{Timeout: 10 * time.Second} + dial := monitor.SafeDialContext(cfg.AllowPrivateTargets) strictClient := &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + DialContext: dial, + }, } insecureClient := &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites + DialContext: dial, + }, } if err := probeRegister(ctx, apiClient, cfg); err != nil { @@ -59,7 +68,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error { continue } - results := probeExecuteChecks(ctx, sites, strictClient, insecureClient) + results := probeExecuteChecks(ctx, sites, strictClient, insecureClient, cfg.AllowPrivateTargets) if len(results) > 0 { if err := probeReportResults(ctx, apiClient, cfg, results); err != nil { @@ -121,7 +130,7 @@ type probeResultItem struct { IsUp bool `json:"is_up"` } -func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client) []probeResultItem { +func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client, allowPrivate bool) []probeResultItem { var mu sync.Mutex var results []probeResultItem sem := make(chan struct{}, 10) @@ -140,7 +149,7 @@ loop: defer wg.Done() defer func() { <-sem }() - cr := monitor.RunCheck(s, strict, insecure, false) + cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate) mu.Lock() results = append(results, probeResultItem{ SiteID: s.ID, diff --git a/internal/monitor/checker.go b/internal/monitor/checker.go index c3cb9da..30795f4 100644 --- a/internal/monitor/checker.go +++ b/internal/monitor/checker.go @@ -2,13 +2,14 @@ package monitor import ( "context" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "net" "net/http" "strconv" "strings" "time" + "gitea.lerkolabs.com/lerko/uptop/internal/models" + "github.com/miekg/dns" probing "github.com/prometheus-community/pro-bing" ) @@ -22,7 +23,25 @@ type CheckResult struct { CertExpiry time.Time } -func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult { +func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult { + private := len(allowPrivate) > 0 && allowPrivate[0] + + if site.Type != "http" && site.Type != "dns" && !private { + host := site.Hostname + if host == "" { + host = site.URL + } + if host != "" { + if ips, err := net.LookupIP(host); err == nil { + for _, ip := range ips { + if isPrivateIP(ip) { + return CheckResult{SiteID: site.ID, Status: "DOWN"} + } + } + } + } + } + switch site.Type { case "http": return runHTTPCheck(site, strict, insecure, globalInsecure) diff --git a/internal/monitor/checker_test.go b/internal/monitor/checker_test.go index 3db1324..93e66f8 100644 --- a/internal/monitor/checker_test.go +++ b/internal/monitor/checker_test.go @@ -2,13 +2,14 @@ package monitor import ( "crypto/tls" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "net" "net/http" "net/http/httptest" "strconv" "testing" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/models" ) func TestRunCheck_HTTP_Success(t *testing.T) { @@ -132,7 +133,7 @@ func TestRunCheck_Port_Open(t *testing.T) { port, _ := strconv.Atoi(portStr) site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} - result := RunCheck(site, nil, nil, false) + result := RunCheck(site, nil, nil, false, true) if result.Status != "UP" { t.Errorf("expected UP, got %s", result.Status) @@ -152,13 +153,31 @@ func TestRunCheck_Port_Closed(t *testing.T) { ln.Close() site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} - result := RunCheck(site, nil, nil, false) + result := RunCheck(site, nil, nil, false, true) if result.Status != "DOWN" { t.Errorf("expected DOWN, got %s", result.Status) } } +func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + + site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} + result := RunCheck(site, nil, nil, false) + + if result.Status != "DOWN" { + t.Errorf("expected DOWN when private targets blocked, got %s", result.Status) + } +} + func TestRunCheck_UnknownType(t *testing.T) { site := models.Site{ID: 1, Type: "invalid"} result := RunCheck(site, nil, nil, false) diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index a7a096b..a7fcf1c 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -4,13 +4,14 @@ import ( "context" "crypto/tls" "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/alert" - "gitea.lerkolabs.com/lerko/uptop/internal/models" - "gitea.lerkolabs.com/lerko/uptop/internal/store" "math/rand/v2" "net/http" "sync" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/alert" + "gitea.lerkolabs.com/lerko/uptop/internal/models" + "gitea.lerkolabs.com/lerko/uptop/internal/store" ) type Engine struct { @@ -32,26 +33,43 @@ type Engine struct { probeResults map[int]map[string]NodeResult aggStrategy AggregationStrategy - db store.Store - insecureSkipVerify bool - strictClient *http.Client - insecureClient *http.Client + db store.Store + insecureSkipVerify bool + allowPrivateTargets bool + strictClient *http.Client + insecureClient *http.Client } func NewEngine(s store.Store) *Engine { + return newEngine(s, false) +} + +func NewEngineWithOpts(s store.Store, allowPrivateTargets bool) *Engine { + return newEngine(s, allowPrivateTargets) +} + +func newEngine(s store.Store, allowPrivateTargets bool) *Engine { + dial := SafeDialContext(allowPrivateTargets) return &Engine{ - liveState: make(map[int]models.Site), - histories: make(map[int]*SiteHistory), - tokenIndex: make(map[string]int), - probeResults: make(map[int]map[string]NodeResult), - aggStrategy: AggAnyDown, - isActive: true, - db: s, + liveState: make(map[int]models.Site), + histories: make(map[int]*SiteHistory), + tokenIndex: make(map[string]int), + probeResults: make(map[int]map[string]NodeResult), + aggStrategy: AggAnyDown, + isActive: true, + allowPrivateTargets: allowPrivateTargets, + db: s, strictClient: &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + DialContext: dial, + }, }, insecureClient: &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites + DialContext: dial, + }, }, } } @@ -351,7 +369,7 @@ func (e *Engine) checkByID(id int) { case "group": e.checkGroup(site) default: - result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify) + result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets) updatedSite := site updatedSite.HasSSL = result.HasSSL updatedSite.CertExpiry = result.CertExpiry diff --git a/internal/monitor/safedial.go b/internal/monitor/safedial.go new file mode 100644 index 0000000..ae8a0b8 --- /dev/null +++ b/internal/monitor/safedial.go @@ -0,0 +1,68 @@ +package monitor + +import ( + "context" + "fmt" + "net" + "time" +) + +var privateRanges []*net.IPNet + +func init() { + cidrs := []string{ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "fe80::/10", + "fc00::/7", + } + for _, cidr := range cidrs { + _, network, _ := net.ParseCIDR(cidr) + privateRanges = append(privateRanges, network) + } +} + +func isPrivateIP(ip net.IP) bool { + for _, network := range privateRanges { + if network.Contains(ip) { + return true + } + } + return false +} + +func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + if !allowPrivate { + for _, ip := range ips { + if isPrivateIP(ip.IP) { + return nil, fmt.Errorf("blocked: %s resolves to private address %s", host, ip.IP) + } + } + } + + dialer := &net.Dialer{Timeout: 10 * time.Second} + for _, ip := range ips { + target := net.JoinHostPort(ip.IP.String(), port) + conn, err := dialer.DialContext(ctx, network, target) + if err == nil { + return conn, nil + } + } + return nil, fmt.Errorf("failed to connect to %s", addr) + } +} diff --git a/internal/monitor/safedial_test.go b/internal/monitor/safedial_test.go new file mode 100644 index 0000000..1c6b658 --- /dev/null +++ b/internal/monitor/safedial_test.go @@ -0,0 +1,47 @@ +package monitor + +import ( + "net" + "testing" +) + +func TestIsPrivateIP(t *testing.T) { + tests := []struct { + ip string + private bool + }{ + {"127.0.0.1", true}, + {"10.0.0.1", true}, + {"172.16.0.1", true}, + {"192.168.1.1", true}, + {"169.254.169.254", true}, + {"::1", true}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"93.184.216.34", false}, + } + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + got := isPrivateIP(ip) + if got != tt.private { + t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private) + } + } +} + +func TestSafeDialContext_BlocksPrivate(t *testing.T) { + dial := SafeDialContext(false) + _, err := dial(t.Context(), "tcp", "127.0.0.1:80") + if err == nil { + t.Error("expected error dialing loopback with private blocking enabled") + } +} + +func TestSafeDialContext_AllowsPrivate(t *testing.T) { + dial := SafeDialContext(true) + _, err := dial(t.Context(), "tcp", "127.0.0.1:80") + // Will fail to connect (nothing listening) but should NOT be blocked + if err != nil && err.Error() == "blocked: 127.0.0.1 resolves to private address 127.0.0.1" { + t.Error("should not block private IPs when allowPrivate is true") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7908fae..b75f5cd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,17 +4,18 @@ import ( "crypto/subtle" "encoding/json" "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/importer" - "gitea.lerkolabs.com/lerko/uptop/internal/metrics" - "gitea.lerkolabs.com/lerko/uptop/internal/models" - "gitea.lerkolabs.com/lerko/uptop/internal/monitor" - "gitea.lerkolabs.com/lerko/uptop/internal/store" "html/template" "log" "net/http" "sort" "strings" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/importer" + "gitea.lerkolabs.com/lerko/uptop/internal/metrics" + "gitea.lerkolabs.com/lerko/uptop/internal/models" + "gitea.lerkolabs.com/lerko/uptop/internal/monitor" + "gitea.lerkolabs.com/lerko/uptop/internal/store" ) func checkSecret(got, want string) bool { @@ -156,7 +157,10 @@ type ServerConfig struct { Port int EnableStatus bool Title string - ClusterKey string // Shared Secret for Security + ClusterKey string + TLSCert string + TLSKey string + ClusterMode string } func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { @@ -399,17 +403,40 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { }) } + if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" { + fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.") + } + + var handler http.Handler = mux + if cfg.TLSCert != "" { + handler = hstsMiddleware(mux) + } + addr := fmt.Sprintf(":%d", cfg.Port) - srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} + srv := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second} go func() { - fmt.Printf("HTTP Server listening on %s\n", addr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("HTTP server error: %v", err) + if cfg.TLSCert != "" && cfg.TLSKey != "" { + fmt.Printf("HTTPS Server listening on %s\n", addr) + if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed { + log.Printf("HTTPS server error: %v", err) + } + } else { + fmt.Printf("HTTP Server listening on %s\n", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("HTTP server error: %v", err) + } } }() return srv } +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") + next.ServeHTTP(w, r) + }) +} + func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) { sites := eng.GetAllSites() diff --git a/internal/store/crypto.go b/internal/store/crypto.go new file mode 100644 index 0000000..7cebd1b --- /dev/null +++ b/internal/store/crypto.go @@ -0,0 +1,70 @@ +package store + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "strings" +) + +const encryptedPrefix = "enc:" + +type Encryptor struct { + gcm cipher.AEAD +} + +func NewEncryptor(hexKey string) (*Encryptor, error) { + key, err := hex.DecodeString(hexKey) + if err != nil { + return nil, fmt.Errorf("invalid encryption key: must be hex-encoded: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("invalid encryption key: must be 32 bytes (64 hex chars), got %d bytes", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM: %w", err) + } + return &Encryptor{gcm: gcm}, nil +} + +func (e *Encryptor) Encrypt(plaintext string) (string, error) { + nonce := make([]byte, e.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (e *Encryptor) Decrypt(data string) (string, error) { + if !strings.HasPrefix(data, encryptedPrefix) { + return data, nil + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data, encryptedPrefix)) + if err != nil { + return "", fmt.Errorf("decode base64: %w", err) + } + nonceSize := e.gcm.NonceSize() + if len(raw) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ciphertext := raw[:nonceSize], raw[nonceSize:] + plaintext, err := e.gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + return string(plaintext), nil +} + +func IsEncrypted(data string) bool { + return strings.HasPrefix(data, encryptedPrefix) +} diff --git a/internal/store/crypto_test.go b/internal/store/crypto_test.go new file mode 100644 index 0000000..75b5a05 --- /dev/null +++ b/internal/store/crypto_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "encoding/hex" + "testing" +) + +func testKey() string { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + return hex.EncodeToString(key) +} + +func TestEncryptorRoundTrip(t *testing.T) { + enc, err := NewEncryptor(testKey()) + if err != nil { + t.Fatal(err) + } + + original := `{"host":"smtp.example.com","pass":"s3cret"}` + encrypted, err := enc.Encrypt(original) + if err != nil { + t.Fatal(err) + } + + if !IsEncrypted(encrypted) { + t.Error("expected encrypted prefix") + } + if encrypted == original { + t.Error("encrypted should differ from original") + } + + decrypted, err := enc.Decrypt(encrypted) + if err != nil { + t.Fatal(err) + } + if decrypted != original { + t.Errorf("got %q, want %q", decrypted, original) + } +} + +func TestEncryptorDecryptPlaintext(t *testing.T) { + enc, err := NewEncryptor(testKey()) + if err != nil { + t.Fatal(err) + } + + plain := `{"url":"https://hooks.slack.com/test"}` + result, err := enc.Decrypt(plain) + if err != nil { + t.Fatal(err) + } + if result != plain { + t.Errorf("plaintext passthrough failed: got %q", result) + } +} + +func TestEncryptorBadKey(t *testing.T) { + _, err := NewEncryptor("tooshort") + if err == nil { + t.Error("expected error for short key") + } + + _, err = NewEncryptor("not-hex-at-all-but-long-enough-to-be-64-chars-if-we-keep-going!!") + if err == nil { + t.Error("expected error for non-hex key") + } +} + +func TestEncryptorUniqueCiphertexts(t *testing.T) { + enc, err := NewEncryptor(testKey()) + if err != nil { + t.Fatal(err) + } + + a, _ := enc.Encrypt("same") + b, _ := enc.Encrypt("same") + if a == b { + t.Error("two encryptions of same plaintext should produce different ciphertexts") + } +} diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index ccff582..f3368e3 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -6,15 +6,17 @@ import ( "encoding/hex" "encoding/json" "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "log" "time" + + "gitea.lerkolabs.com/lerko/uptop/internal/models" ) type SQLStore struct { - db *sql.DB - dialect Dialect - dollar bool + db *sql.DB + dialect Dialect + dollar bool + encryptor *Encryptor } func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) { @@ -26,6 +28,24 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) { return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil } +func (s *SQLStore) SetEncryptor(enc *Encryptor) { + s.encryptor = enc +} + +func (s *SQLStore) encryptSettings(jsonStr string) (string, error) { + if s.encryptor == nil { + return jsonStr, nil + } + return s.encryptor.Encrypt(jsonStr) +} + +func (s *SQLStore) decryptSettings(data string) (string, error) { + if s.encryptor == nil { + return data, nil + } + return s.encryptor.Decrypt(data) +} + func (s *SQLStore) q(query string) string { return rewritePlaceholders(query, s.dollar) } @@ -140,15 +160,36 @@ func (s *SQLStore) GetSiteByName(name string) (models.Site, error) { return st, err } +func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) { + decrypted, err := s.decryptSettings(raw) + if err != nil { + return nil, fmt.Errorf("decrypt settings: %w", err) + } + var m map[string]string + if err := json.Unmarshal([]byte(decrypted), &m); err != nil { + return nil, fmt.Errorf("unmarshal settings: %w", err) + } + return m, nil +} + +func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) { + jsonBytes, err := json.Marshal(settings) + if err != nil { + return "", err + } + return s.encryptSettings(string(jsonBytes)) +} + func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) { var a models.AlertConfig - var settingsJSON string - err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + var settingsRaw string + err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw) if err != nil { return a, err } - if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil { - return a, fmt.Errorf("unmarshal alert settings: %w", err) + a.Settings, err = s.unmarshalSettings(settingsRaw) + if err != nil { + return a, fmt.Errorf("alert %q: %w", name, err) } return a, nil } @@ -184,12 +225,13 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) { var alerts []models.AlertConfig for rows.Next() { var a models.AlertConfig - var settingsJSON string - if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil { + var settingsRaw string + if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil { return alerts, err } - if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil { - return alerts, fmt.Errorf("unmarshal alert settings for %q: %w", a.Name, err) + a.Settings, err = s.unmarshalSettings(settingsRaw) + if err != nil { + return alerts, fmt.Errorf("alert %q: %w", a.Name, err) } alerts = append(alerts, a) } @@ -198,32 +240,33 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) { func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) { var a models.AlertConfig - var settingsJSON string - err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + var settingsRaw string + err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw) if err != nil { return a, err } - if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil { - return a, fmt.Errorf("unmarshal alert settings: %w", err) + a.Settings, err = s.unmarshalSettings(settingsRaw) + if err != nil { + return a, fmt.Errorf("alert %d: %w", id, err) } return a, nil } func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error { - jsonBytes, err := json.Marshal(settings) + stored, err := s.marshalSettings(settings) if err != nil { return err } - _, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes)) + _, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored) return err } func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error { - jsonBytes, err := json.Marshal(settings) + stored, err := s.marshalSettings(settings) if err != nil { return err } - _, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id) + _, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id) return err } -- 2.52.0