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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+35
-17
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user