From 3f333e6f26d68ed1e5e2f70c137b35dcc32f1dbd Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 01:37:02 +0000 Subject: [PATCH 01/57] refactor: add shared request helpers Add a small package for per-process secrets and local address checks, and share the management network helper. --- backend/internal/api/middleware/emergency.go | 32 +-- .../api/middleware/management_network.go | 50 +++++ .../api/middleware/management_network_test.go | 34 ++++ backend/internal/security/selfhop/selfhop.go | 185 ++++++++++++++++++ .../internal/security/selfhop/selfhop_test.go | 164 ++++++++++++++++ 5 files changed, 435 insertions(+), 30 deletions(-) create mode 100644 backend/internal/api/middleware/management_network.go create mode 100644 backend/internal/api/middleware/management_network_test.go create mode 100644 backend/internal/security/selfhop/selfhop.go create mode 100644 backend/internal/security/selfhop/selfhop_test.go diff --git a/backend/internal/api/middleware/emergency.go b/backend/internal/api/middleware/emergency.go index 2b19c27f3..cc6cb57e8 100644 --- a/backend/internal/api/middleware/emergency.go +++ b/backend/internal/api/middleware/emergency.go @@ -56,27 +56,7 @@ func EmergencyBypass(managementCIDRs []string, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { c.Next() } // noop } - // Parse management CIDRs - var managementNets []*net.IPNet - for _, cidr := range managementCIDRs { - _, ipnet, err := net.ParseCIDR(cidr) - if err != nil { - logger.Log().WithError(err).WithField("cidr", cidr).Warn("Invalid management CIDR") - continue - } - managementNets = append(managementNets, ipnet) - } - - // Default to RFC1918 private networks if none specified - if len(managementNets) == 0 { - managementNets = []*net.IPNet{ - mustParseCIDR("10.0.0.0/8"), - mustParseCIDR("172.16.0.0/12"), - mustParseCIDR("192.168.0.0/16"), - mustParseCIDR("127.0.0.0/8"), // localhost for local development - mustParseCIDR("::1/128"), // IPv6 localhost - } - } + managementNets := ParseManagementNets(managementCIDRs) return func(c *gin.Context) { // Check if emergency token is present @@ -95,15 +75,7 @@ func EmergencyBypass(managementCIDRs []string, db *gorm.DB) gin.HandlerFunc { return } - inManagementNet := false - for _, ipnet := range managementNets { - if ipnet.Contains(clientIP) { - inManagementNet = true - break - } - } - - if !inManagementNet { + if !IsManagementIP(managementNets, clientIP) { logger.Log().WithField("ip", util.SanitizeForLog(clientIP.String())).Warn("Emergency bypass: IP not in management network") c.Next() return diff --git a/backend/internal/api/middleware/management_network.go b/backend/internal/api/middleware/management_network.go new file mode 100644 index 000000000..08aa61415 --- /dev/null +++ b/backend/internal/api/middleware/management_network.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "net" + + "github.com/Wikid82/charon/backend/internal/logger" +) + +// defaultManagementCIDRs are used when no management networks are configured: +// RFC 1918 private ranges plus loopback. +var defaultManagementCIDRs = []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "::1/128", +} + +// ParseManagementNets parses CIDR strings, skipping invalid entries. When no +// valid entry remains it returns the default private and loopback ranges. +func ParseManagementNets(cidrs []string) []*net.IPNet { + var nets []*net.IPNet + for _, cidr := range cidrs { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + logger.Log().WithError(err).WithField("cidr", cidr).Warn("Invalid management CIDR") + continue + } + nets = append(nets, ipnet) + } + if len(nets) == 0 { + for _, cidr := range defaultManagementCIDRs { + nets = append(nets, mustParseCIDR(cidr)) + } + } + return nets +} + +// IsManagementIP reports whether ip falls inside any of nets. +func IsManagementIP(nets []*net.IPNet, ip net.IP) bool { + if ip == nil { + return false + } + for _, ipnet := range nets { + if ipnet.Contains(ip) { + return true + } + } + return false +} diff --git a/backend/internal/api/middleware/management_network_test.go b/backend/internal/api/middleware/management_network_test.go new file mode 100644 index 000000000..97c9013e9 --- /dev/null +++ b/backend/internal/api/middleware/management_network_test.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseManagementNets_DefaultsWhenEmpty(t *testing.T) { + nets := ParseManagementNets(nil) + assert.True(t, IsManagementIP(nets, net.ParseIP("10.1.2.3"))) + assert.True(t, IsManagementIP(nets, net.ParseIP("192.168.1.1"))) + assert.True(t, IsManagementIP(nets, net.ParseIP("172.16.5.5"))) + assert.True(t, IsManagementIP(nets, net.ParseIP("127.0.0.1"))) + assert.True(t, IsManagementIP(nets, net.ParseIP("::1"))) + assert.False(t, IsManagementIP(nets, net.ParseIP("203.0.113.7"))) +} + +func TestParseManagementNets_CustomAndInvalid(t *testing.T) { + nets := ParseManagementNets([]string{"203.0.113.0/24", "not-a-cidr"}) + assert.Len(t, nets, 1) + assert.True(t, IsManagementIP(nets, net.ParseIP("203.0.113.7"))) + assert.False(t, IsManagementIP(nets, net.ParseIP("10.1.2.3"))) +} + +func TestParseManagementNets_AllInvalidFallsBackToDefaults(t *testing.T) { + nets := ParseManagementNets([]string{"garbage"}) + assert.True(t, IsManagementIP(nets, net.ParseIP("10.1.2.3"))) +} + +func TestIsManagementIP_NilIP(t *testing.T) { + assert.False(t, IsManagementIP(ParseManagementNets(nil), nil)) +} diff --git a/backend/internal/security/selfhop/selfhop.go b/backend/internal/security/selfhop/selfhop.go new file mode 100644 index 000000000..770abd444 --- /dev/null +++ b/backend/internal/security/selfhop/selfhop.go @@ -0,0 +1,185 @@ +// Package selfhop provides the building blocks Charon uses to recognise +// requests that reach its own API through its embedded reverse proxy. +// +// The proxy attaches a per-process secret and the connecting client address to +// requests whose upstream is Charon itself. The API verifies the secret in +// constant time before it relies on the address. The secret lives in memory +// only and is redacted by every formatting path. +package selfhop + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net" + "os" + "strings" + "sync" + "time" +) + +const ( + // HeaderSecret carries the per-process secret. + HeaderSecret = "X-Charon-Self-Hop" //nolint:gosec // header name, not a credential + // HeaderClient carries the address of the client connected to the proxy. + HeaderClient = "X-Charon-Self-Hop-Client" + // ClientPlaceholder is the Caddy placeholder that expands to the address of + // the TCP peer connected to the proxy. + ClientPlaceholder = "{http.request.remote.host}" + + secretBytes = 32 + minSecretLength = 32 + redacted = "[redacted]" +) + +// randRead is the entropy source; replaced in tests. +var randRead = rand.Read + +// Secret is an opaque per-process shared secret. +type Secret struct { + value string +} + +// NewSecret generates a fresh random secret. +func NewSecret() (*Secret, error) { + buf := make([]byte, secretBytes) + if _, err := randRead(buf); err != nil { + return nil, fmt.Errorf("generate secret: %w", err) + } + return &Secret{value: hex.EncodeToString(buf)}, nil +} + +// ParseSecret wraps an existing value, rejecting values that are too short. +func ParseSecret(value string) (*Secret, error) { + if len(value) < minSecretLength { + return nil, errors.New("secret too short") + } + return &Secret{value: value}, nil +} + +// Reveal returns the raw value. It is only meant for building proxy configuration. +func (s *Secret) Reveal() string { + if s == nil { + return "" + } + return s.value +} + +// Verify reports whether candidate equals the secret, in constant time. +// A nil or empty secret never verifies. +func (s *Secret) Verify(candidate string) bool { + if s == nil || s.value == "" || candidate == "" { + return false + } + want := sha256.Sum256([]byte(s.value)) + got := sha256.Sum256([]byte(candidate)) + return subtle.ConstantTimeCompare(want[:], got[:]) == 1 +} + +// String implements fmt.Stringer without exposing the value. +func (s *Secret) String() string { return redacted } + +// GoString implements fmt.GoStringer without exposing the value. +func (s *Secret) GoString() string { return redacted } + +// Format redacts the value for every fmt verb. +func (s *Secret) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(redacted)) } + +// MarshalText redacts the value for encoders. +func (s *Secret) MarshalText() ([]byte, error) { return []byte(redacted), nil } + +// LocalAddrs answers whether an address belongs to this host: loopback or the +// address of one of its network interfaces. The interface list is cached for a +// short time because it rarely changes. +type LocalAddrs struct { + mu sync.Mutex + ttl time.Duration + now func() time.Time + lister func() ([]net.Addr, error) + fetched time.Time + ips []net.IP +} + +// defaultLocalAddrsTTL bounds how stale the cached interface list may be. +const defaultLocalAddrsTTL = 30 * time.Second + +// NewLocalAddrs returns a LocalAddrs backed by the host's interfaces. +func NewLocalAddrs() *LocalAddrs { + return &LocalAddrs{ttl: defaultLocalAddrsTTL, now: time.Now, lister: net.InterfaceAddrs} +} + +func (l *LocalAddrs) snapshot() []net.IP { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + if l.ips != nil && now.Sub(l.fetched) < l.ttl { + return l.ips + } + addrs, err := l.lister() + if err != nil { + // Keep serving the previous snapshot rather than failing open. + return l.ips + } + ips := make([]net.IP, 0, len(addrs)) + for _, a := range addrs { + switch v := a.(type) { + case *net.IPNet: + ips = append(ips, v.IP) + case *net.IPAddr: + ips = append(ips, v.IP) + } + } + l.ips, l.fetched = ips, now + return ips +} + +// Contains reports whether ip is loopback or an interface address of this host. +func (l *LocalAddrs) Contains(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() { + return true + } + for _, local := range l.snapshot() { + if local.Equal(ip) { + return true + } + } + return false +} + +// IsLocalHost reports whether a dial host names this machine: a loopback or +// unspecified address, an interface address, "localhost" or the host name. +func (l *LocalAddrs) IsLocalHost(host string) bool { + host = strings.ToLower(strings.TrimSuffix(strings.Trim(strings.TrimSpace(host), "[]"), ".")) + if host == "" { + return false + } + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsUnspecified() || l.Contains(ip) + } + for _, name := range hostNames() { + if host == name { + return true + } + } + return false +} + +func hostNames() []string { + var names []string + if h, err := os.Hostname(); err == nil && h != "" { + names = append(names, strings.ToLower(h)) + } + if h := os.Getenv("HOSTNAME"); h != "" { + names = append(names, strings.ToLower(h)) + } + return names +} diff --git a/backend/internal/security/selfhop/selfhop_test.go b/backend/internal/security/selfhop/selfhop_test.go new file mode 100644 index 000000000..548e4ec1c --- /dev/null +++ b/backend/internal/security/selfhop/selfhop_test.go @@ -0,0 +1,164 @@ +package selfhop + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSecret_UniqueAndVerifies(t *testing.T) { + a, err := NewSecret() + require.NoError(t, err) + b, err := NewSecret() + require.NoError(t, err) + + assert.NotEqual(t, a.Reveal(), b.Reveal()) + assert.GreaterOrEqual(t, len(a.Reveal()), minSecretLength) + assert.True(t, a.Verify(a.Reveal())) + assert.False(t, a.Verify(b.Reveal())) + assert.False(t, a.Verify("")) + assert.False(t, a.Verify(a.Reveal()+"x")) +} + +func TestParseSecret(t *testing.T) { + _, err := ParseSecret("short") + require.Error(t, err) + + s, err := ParseSecret(strings.Repeat("a", minSecretLength)) + require.NoError(t, err) + assert.True(t, s.Verify(strings.Repeat("a", minSecretLength))) +} + +func TestSecret_NilAndEmptyNeverVerify(t *testing.T) { + var nilSecret *Secret + assert.False(t, nilSecret.Verify("anything")) + assert.Equal(t, "", nilSecret.Reveal()) + assert.False(t, (&Secret{}).Verify("")) + assert.False(t, (&Secret{}).Verify("x")) +} + +func TestSecret_FormattingIsRedacted(t *testing.T) { + s, err := NewSecret() + require.NoError(t, err) + raw := s.Reveal() + + type holder struct{ S *Secret } + outputs := []string{ + s.String(), + s.GoString(), + fmt.Sprintf("%v", s), + fmt.Sprintf("%+v", s), + fmt.Sprintf("%#v", s), + fmt.Sprintf("%s", s), //nolint:gosimple // exercising the verb + fmt.Sprintf("%v", holder{S: s}), + fmt.Sprintf("%+v", holder{S: s}), + } + enc, err := json.Marshal(holder{S: s}) + require.NoError(t, err) + outputs = append(outputs, string(enc)) + + for _, out := range outputs { + assert.NotContains(t, out, raw) + assert.Contains(t, out, redacted) + } +} + +func fakeLister(addrs ...net.Addr) func() ([]net.Addr, error) { + return func() ([]net.Addr, error) { return addrs, nil } +} + +func ipnet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + ip, n, err := net.ParseCIDR(cidr) + require.NoError(t, err) + n.IP = ip + return n +} + +func TestLocalAddrs_Contains(t *testing.T) { + l := NewLocalAddrs() + l.lister = fakeLister(ipnet(t, "172.18.0.5/16"), &net.IPAddr{IP: net.ParseIP("10.1.2.3")}) + + assert.True(t, l.Contains(net.ParseIP("127.0.0.1"))) + assert.True(t, l.Contains(net.ParseIP("::1"))) + assert.True(t, l.Contains(net.ParseIP("172.18.0.5"))) + assert.True(t, l.Contains(net.ParseIP("10.1.2.3"))) + assert.False(t, l.Contains(net.ParseIP("172.18.0.6"))) + assert.False(t, l.Contains(net.ParseIP("203.0.113.9"))) + assert.False(t, l.Contains(nil)) +} + +func TestLocalAddrs_CacheAndRefresh(t *testing.T) { + calls := 0 + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + l := NewLocalAddrs() + l.now = func() time.Time { return now } + l.lister = func() ([]net.Addr, error) { + calls++ + return []net.Addr{ipnet(t, "10.9.9.9/24")}, nil + } + + assert.True(t, l.Contains(net.ParseIP("10.9.9.9"))) + assert.True(t, l.Contains(net.ParseIP("10.9.9.9"))) + assert.Equal(t, 1, calls) + + now = now.Add(defaultLocalAddrsTTL + time.Second) + assert.True(t, l.Contains(net.ParseIP("10.9.9.9"))) + assert.Equal(t, 2, calls) +} + +func TestLocalAddrs_ListerErrorKeepsPreviousSnapshot(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + fail := false + l := NewLocalAddrs() + l.now = func() time.Time { return now } + l.lister = func() ([]net.Addr, error) { + if fail { + return nil, errors.New("boom") + } + return []net.Addr{ipnet(t, "10.9.9.9/24")}, nil + } + assert.True(t, l.Contains(net.ParseIP("10.9.9.9"))) + + fail = true + now = now.Add(time.Hour) + assert.True(t, l.Contains(net.ParseIP("10.9.9.9"))) + assert.False(t, l.Contains(net.ParseIP("10.9.9.10"))) +} + +func TestLocalAddrs_ListerErrorWithoutSnapshotDeniesNonLoopback(t *testing.T) { + l := NewLocalAddrs() + l.lister = func() ([]net.Addr, error) { return nil, errors.New("boom") } + assert.False(t, l.Contains(net.ParseIP("10.9.9.9"))) + assert.True(t, l.Contains(net.ParseIP("127.0.0.1"))) +} + +func TestLocalAddrs_IsLocalHost(t *testing.T) { + t.Setenv("HOSTNAME", "Charon-Box") + l := NewLocalAddrs() + l.lister = fakeLister(ipnet(t, "172.18.0.5/16")) + + for _, host := range []string{"localhost", "LOCALHOST", "127.0.0.1", "::1", "[::1]", "0.0.0.0", "::", "172.18.0.5", "charon-box", "localhost."} { + assert.True(t, l.IsLocalHost(host), host) + } + for _, host := range []string{"", "example.com", "172.18.0.6", "203.0.113.1", "charon-box.evil.example", "orthrus:abc"} { + assert.False(t, l.IsLocalHost(host), host) + } +} + +func TestNewSecret_EntropyFailure(t *testing.T) { + orig := randRead + randRead = func([]byte) (int, error) { return 0, errors.New("no entropy") } + t.Cleanup(func() { randRead = orig }) + + s, err := NewSecret() + require.Error(t, err) + assert.Nil(t, s) +} From 7535a169e5a00c5f5ed92a2fde3e081b9567fada Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 02:53:43 +0000 Subject: [PATCH 02/57] fix(security): harden input validation in the API layer Tighten validation in a few API handlers and surface persistence errors. --- backend/cmd/api/main.go | 2 +- .../api/handlers/emergency_handler.go | 35 ++++++ .../api/handlers/emergency_handler_test.go | 96 ++++++++++++++ .../internal/api/handlers/security_handler.go | 22 ++-- .../handlers/security_handler_disable_test.go | 90 +++++++++++++ .../internal/api/handlers/system_handler.go | 53 ++------ .../api/handlers/system_handler_test.go | 119 ++++++++---------- backend/internal/api/routes/routes.go | 2 +- backend/internal/server/emergency_server.go | 26 ++-- .../internal/server/emergency_server_test.go | 60 +++++++++ .../services/emergency_token_service.go | 10 +- .../services/emergency_token_service_test.go | 26 ++++ 12 files changed, 407 insertions(+), 134 deletions(-) create mode 100644 backend/internal/api/handlers/security_handler_disable_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index ba8fece4f..d2e21f7f1 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -329,7 +329,7 @@ func main() { } // Initialize emergency server (Tier 2 break glass) - emergencyServer := server.NewEmergencyServerWithDeps(db, cfg.Emergency, caddyManager, cerb, gate) + emergencyServer := server.NewEmergencyServerWithDeps(db, cfg.Emergency, caddyManager, cerb, gate).WithManagementCIDRs(cfg.Security.ManagementCIDRs) if err := emergencyServer.Start(); err != nil { logger.Log().WithError(err).Fatal("Failed to start emergency server") } diff --git a/backend/internal/api/handlers/emergency_handler.go b/backend/internal/api/handlers/emergency_handler.go index 669ee5fd9..9c440ae84 100644 --- a/backend/internal/api/handlers/emergency_handler.go +++ b/backend/internal/api/handlers/emergency_handler.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "net" "net/http" "os" "strings" @@ -36,6 +37,7 @@ type EmergencyHandler struct { tokenService *services.EmergencyTokenService caddyManager CaddyConfigManager cerberus CacheInvalidator + managementNets []*net.IPNet } // NewEmergencyHandler creates a new EmergencyHandler @@ -99,6 +101,22 @@ func (h *EmergencyHandler) SecurityReset(c *gin.Context) { return } + // The direct path enforces the same management-network requirement as the + // middleware before any token is evaluated. + if !h.isManagementClient(clientIP) { + h.logEnhancedAudit(clientIP, "emergency_reset_forbidden_source", "Request source outside management network", false, time.Since(startTime)) + log.WithFields(log.Fields{ + "ip": util.SanitizeForLog(clientIP), + "action": "emergency_reset_forbidden_source", + }).Warn("Emergency reset rejected: source outside management network") + + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Request source is not permitted.", + }) + return + } + // Fallback: Legacy direct token validation (deprecated - use middleware) // This path is kept for backward compatibility but will be removed in future versions log.WithFields(log.Fields{ @@ -514,3 +532,20 @@ func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) { "new_expires_at": expiresAt, }) } + +// WithManagementCIDRs sets the networks allowed to use the direct reset path. +// An empty list selects the default private and loopback ranges. +func (h *EmergencyHandler) WithManagementCIDRs(cidrs []string) *EmergencyHandler { + h.managementNets = middleware.ParseManagementNets(cidrs) + return h +} + +// isManagementClient reports whether the canonical client address is inside the +// management networks. +func (h *EmergencyHandler) isManagementClient(clientIP string) bool { + nets := h.managementNets + if nets == nil { + nets = middleware.ParseManagementNets(nil) + } + return middleware.IsManagementIP(nets, net.ParseIP(clientIP)) +} diff --git a/backend/internal/api/handlers/emergency_handler_test.go b/backend/internal/api/handlers/emergency_handler_test.go index 7ba07d264..f67278642 100644 --- a/backend/internal/api/handlers/emergency_handler_test.go +++ b/backend/internal/api/handlers/emergency_handler_test.go @@ -18,6 +18,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" ) @@ -93,6 +94,11 @@ func setupEmergencyTestDB(t *testing.T) *gorm.DB { func setupEmergencyRouter(handler *EmergencyHandler) *gin.Engine { router := gin.New() _ = router.SetTrustedProxies(nil) + // Requests originate inside the management network unless a test says otherwise. + router.Use(func(c *gin.Context) { + c.Request.RemoteAddr = "127.0.0.1:40000" + c.Next() + }) router.POST("/api/v1/emergency/security-reset", handler.SecurityReset) return router } @@ -707,3 +713,93 @@ func TestUpdateTokenExpiration_InvalidDays(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "Expiration days must be between 0 and 365") } + +func newEmergencyRouterFrom(handler *EmergencyHandler, remoteAddr string) *gin.Engine { + router := gin.New() + _ = router.SetTrustedProxies(nil) + router.Use(func(c *gin.Context) { + c.Request.RemoteAddr = remoteAddr + c.Next() + }) + router.POST("/api/v1/emergency/security-reset", handler.SecurityReset) + return router +} + +func TestEmergencySecurityReset_DirectPathEnforcesManagementNetwork(t *testing.T) { + validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum" + t.Setenv(EmergencyTokenEnvVar, validToken) + + tests := []struct { + name string + cidrs []string + remoteAddr string + wantStatus int + }{ + {"default networks allow private address", nil, "192.168.1.20:5000", http.StatusOK}, + {"default networks allow loopback", nil, "127.0.0.1:5000", http.StatusOK}, + {"default networks reject public address", nil, "203.0.113.9:5000", http.StatusForbidden}, + {"custom networks reject private address outside list", []string{"203.0.113.0/24"}, "10.0.0.5:5000", http.StatusForbidden}, + {"custom networks allow listed address", []string{"203.0.113.0/24"}, "203.0.113.9:5000", http.StatusOK}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := setupEmergencyTestDB(t) + handler := NewEmergencyHandler(db) + t.Cleanup(handler.Close) + if tc.cidrs != nil { + handler.WithManagementCIDRs(tc.cidrs) + } + router := newEmergencyRouterFrom(handler, tc.remoteAddr) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", http.NoBody) + req.Header.Set(EmergencyTokenHeader, validToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, tc.wantStatus, w.Code) + if tc.wantStatus == http.StatusForbidden { + assert.NotContains(t, w.Body.String(), validToken) + } + }) + } +} + +func TestEmergencySecurityReset_OutOfNetworkRejectedBeforeTokenCheck(t *testing.T) { + t.Setenv(EmergencyTokenEnvVar, "this-is-a-valid-emergency-token-with-32-chars-minimum") + db := setupEmergencyTestDB(t) + handler := NewEmergencyHandler(db) + t.Cleanup(handler.Close) + router := newEmergencyRouterFrom(handler, "203.0.113.9:5000") + + // Neither a missing nor a wrong token reveals anything beyond the refusal. + for _, token := range []string{"", "wrong-token"} { + req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", http.NoBody) + if token != "" { + req.Header.Set(EmergencyTokenHeader, token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + } +} + +func TestEmergencySecurityReset_MiddlewareValidatedRequestSkipsDirectCheck(t *testing.T) { + db := setupEmergencyTestDB(t) + handler := NewEmergencyHandler(db) + t.Cleanup(handler.Close) + + router := gin.New() + _ = router.SetTrustedProxies(nil) + router.Use(func(c *gin.Context) { + c.Request.RemoteAddr = "203.0.113.9:5000" + c.Set(middleware.EmergencyBypassContextKey, true) + c.Next() + }) + router.POST("/api/v1/emergency/security-reset", handler.SecurityReset) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index 419e7d520..ca40d61bb 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -564,11 +564,7 @@ func (h *SecurityHandler) Disable(c *gin.Context) { } else { cfg.Enabled = false } - _ = h.svc.Upsert(cfg) - if h.caddyManager != nil { - _ = h.caddyManager.ApplyConfig(c.Request.Context()) - } - c.JSON(http.StatusOK, gin.H{"enabled": false}) + h.persistDisabled(c, cfg) return } cfg, err := h.svc.Get() @@ -586,9 +582,21 @@ func (h *SecurityHandler) Disable(c *gin.Context) { return } cfg.Enabled = false - _ = h.svc.Upsert(cfg) + h.persistDisabled(c, cfg) +} + +// persistDisabled stores cfg and applies the proxy configuration, answering 500 +// when either step fails so the response never reports a state that was not saved. +func (h *SecurityHandler) persistDisabled(c *gin.Context, cfg *models.SecurityConfig) { + if err := h.svc.Upsert(cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable Cerberus"}) + return + } if h.caddyManager != nil { - _ = h.caddyManager.ApplyConfig(c.Request.Context()) + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "saved, but applying the configuration failed"}) + return + } } c.JSON(http.StatusOK, gin.H{"enabled": false}) } diff --git a/backend/internal/api/handlers/security_handler_disable_test.go b/backend/internal/api/handlers/security_handler_disable_test.go new file mode 100644 index 000000000..97675f854 --- /dev/null +++ b/backend/internal/api/handlers/security_handler_disable_test.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/caddy" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +type noopCaddyClient struct{} + +func (noopCaddyClient) Load(context.Context, *caddy.Config) error { return nil } +func (noopCaddyClient) Ping(context.Context) error { return nil } +func (noopCaddyClient) GetConfig(context.Context) (*caddy.Config, error) { return &caddy.Config{}, nil } + +func serveDisable(t *testing.T, h *SecurityHandler, remoteAddr, body string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.POST("/security/disable", h.Disable) + req := httptest.NewRequest(http.MethodPost, "/security/disable", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = remoteAddr + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestSecurityHandler_Disable_LocalSuccessPersists(t *testing.T) { + db := setupTestDB(t) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true}).Error) + h := NewSecurityHandler(config.SecurityConfig{}, db, nil) + t.Cleanup(h.Close) + + w := serveDisable(t, h, "127.0.0.1:5000", `{}`) + assert.Equal(t, http.StatusOK, w.Code) + + var stored models.SecurityConfig + require.NoError(t, db.Where("name = ?", "default").First(&stored).Error) + assert.False(t, stored.Enabled) +} + +func TestSecurityHandler_Disable_ReportsPersistenceFailure(t *testing.T) { + db := setupTestDB(t) + h := NewSecurityHandler(config.SecurityConfig{}, db, nil) + t.Cleanup(h.Close) + require.NoError(t, db.Migrator().DropTable(&models.SecurityConfig{})) + + w := serveDisable(t, h, "127.0.0.1:5000", `{}`) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "failed to disable Cerberus") + assert.NotContains(t, w.Body.String(), `"enabled":false`) +} + +func TestSecurityHandler_Disable_ReportsApplyFailure(t *testing.T) { + db := setupTestDB(t) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true}).Error) + // The proxy host tables are absent, so generating the configuration fails. + mgr := caddy.NewManager(noopCaddyClient{}, db, t.TempDir(), "", false, config.SecurityConfig{}) + h := NewSecurityHandler(config.SecurityConfig{}, db, mgr) + t.Cleanup(h.Close) + + w := serveDisable(t, h, "127.0.0.1:5000", `{}`) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "saved, but applying the configuration failed") +} + +func TestSecurityHandler_Disable_TokenPathReportsApplyFailure(t *testing.T) { + db := setupTestDB(t) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true}).Error) + mgr := caddy.NewManager(noopCaddyClient{}, db, t.TempDir(), "", false, config.SecurityConfig{}) + h := NewSecurityHandler(config.SecurityConfig{}, db, mgr) + t.Cleanup(h.Close) + token, err := h.svc.GenerateBreakGlassToken("default") + require.NoError(t, err) + + w := serveDisable(t, h, "198.51.100.7:5000", `{"break_glass_token":"`+token+`"}`) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "saved, but applying the configuration failed") +} diff --git a/backend/internal/api/handlers/system_handler.go b/backend/internal/api/handlers/system_handler.go index 00a7632d9..09e8ba958 100644 --- a/backend/internal/api/handlers/system_handler.go +++ b/backend/internal/api/handlers/system_handler.go @@ -1,8 +1,8 @@ package handlers import ( + "net" "net/http" - "strings" "github.com/gin-gonic/gin" ) @@ -18,20 +18,15 @@ type MyIPResponse struct { Source string `json:"source"` } -// GetMyIP returns the client's public IP address +// GetMyIP returns the client's IP address as resolved by the server. +// Forwarded-address headers are honored only through the engine's trusted-proxy +// configuration (c.ClientIP), never read directly from the request. func (h *SystemHandler) GetMyIP(c *gin.Context) { - // Try to get the real IP from various headers (in order of preference) - // This handles proxies, load balancers, and CDNs - ip := getClientIP(c.Request) + ip := c.ClientIP() source := "direct" - switch { - case c.GetHeader("X-Forwarded-For") != "": - source = "X-Forwarded-For" - case c.GetHeader("X-Real-IP") != "": - source = "X-Real-IP" - case c.GetHeader("CF-Connecting-IP") != "": - source = "Cloudflare" + if peer, _, err := net.SplitHostPort(c.Request.RemoteAddr); err != nil || peer != ip { + source = "forwarded" } c.JSON(http.StatusOK, MyIPResponse{ @@ -39,37 +34,3 @@ func (h *SystemHandler) GetMyIP(c *gin.Context) { Source: source, }) } - -// getClientIP extracts the real client IP from the request -// Checks headers in order of trust/reliability -func getClientIP(r *http.Request) string { - // Cloudflare - if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { - return ip - } - - // Other CDNs/proxies - if ip := r.Header.Get("X-Real-IP"); ip != "" { - return ip - } - - // Standard proxy header (can be a comma-separated list) - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - // Take the first IP in the list (client IP) - ips := strings.Split(forwarded, ",") - if len(ips) > 0 { - return strings.TrimSpace(ips[0]) - } - } - - // Fallback to RemoteAddr (format: "IP:port") - if ip := r.RemoteAddr; ip != "" { - // Remove port if present - if idx := strings.LastIndex(ip, ":"); idx != -1 { - return ip[:idx] - } - return ip - } - - return "unknown" -} diff --git a/backend/internal/api/handlers/system_handler_test.go b/backend/internal/api/handlers/system_handler_test.go index 3e873ecfb..40109d4b0 100644 --- a/backend/internal/api/handlers/system_handler_test.go +++ b/backend/internal/api/handlers/system_handler_test.go @@ -1,90 +1,71 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) { - // Cloudflare header should win - req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req.Header.Set("CF-Connecting-IP", "5.6.7.8") - ip := getClientIP(req) - if ip != "5.6.7.8" { - t.Fatalf("expected 5.6.7.8 got %s", ip) +func getMyIP(t *testing.T, r *gin.Engine, remoteAddr string, headers map[string]string) MyIPResponse { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody) + req.RemoteAddr = remoteAddr + for k, v := range headers { + req.Header.Set(k, v) } + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var resp MyIPResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + return resp +} - // X-Real-IP should be preferred over RemoteAddr - req2 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req2.Header.Set("X-Real-IP", "10.0.0.4") - req2.RemoteAddr = "1.2.3.4:5678" - ip2 := getClientIP(req2) - if ip2 != "10.0.0.4" { - t.Fatalf("expected 10.0.0.4 got %s", ip2) - } +func newMyIPRouter(t *testing.T, trustedProxies []string) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(trustedProxies)) + r.GET("/myip", NewSystemHandler().GetMyIP) + return r +} - // X-Forwarded-For returns first in list - req3 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req3.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2") - ip3 := getClientIP(req3) - if ip3 != "192.168.0.1" { - t.Fatalf("expected 192.168.0.1 got %s", ip3) - } +func TestGetMyIP_IgnoresClientSuppliedForwardingHeaders(t *testing.T) { + r := newMyIPRouter(t, nil) - // Fallback to remote addr port trimmed - req4 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req4.RemoteAddr = "7.7.7.7:8888" - ip4 := getClientIP(req4) - if ip4 != "7.7.7.7" { - t.Fatalf("expected 7.7.7.7 got %s", ip4) + for _, headers := range []map[string]string{ + {"CF-Connecting-IP": "5.6.7.8"}, + {"X-Real-IP": "8.8.8.8"}, + {"X-Forwarded-For": "9.9.9.9"}, + {"X-Forwarded-For": "9.9.9.9", "X-Real-IP": "8.8.8.8", "CF-Connecting-IP": "5.6.7.8"}, + } { + resp := getMyIP(t, r, "7.7.7.7:9999", headers) + assert.Equal(t, "7.7.7.7", resp.IP) + assert.Equal(t, "direct", resp.Source) } } -func TestGetMyIPHandler(t *testing.T) { - r := gin.New() - handler := NewSystemHandler() - r.GET("/myip", handler.GetMyIP) - - t.Run("with CF header", func(t *testing.T) { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody) - req.Header.Set("CF-Connecting-IP", "5.6.7.8") - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d", w.Code) - } - }) +func TestGetMyIP_DirectConnection(t *testing.T) { + r := newMyIPRouter(t, nil) + resp := getMyIP(t, r, "7.7.7.7:9999", nil) + assert.Equal(t, "7.7.7.7", resp.IP) + assert.Equal(t, "direct", resp.Source) +} - t.Run("with X-Forwarded-For header", func(t *testing.T) { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody) - req.Header.Set("X-Forwarded-For", "9.9.9.9") - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d", w.Code) - } - }) +func TestGetMyIP_HonorsTrustedProxyForwarding(t *testing.T) { + r := newMyIPRouter(t, []string{"10.0.0.0/8"}) - t.Run("with X-Real-IP header", func(t *testing.T) { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody) - req.Header.Set("X-Real-IP", "8.8.8.8") - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d", w.Code) - } - }) + resp := getMyIP(t, r, "10.0.0.2:4000", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + assert.Equal(t, "203.0.113.5", resp.IP) + assert.Equal(t, "forwarded", resp.Source) - t.Run("direct connection", func(t *testing.T) { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody) - req.RemoteAddr = "7.7.7.7:9999" - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d", w.Code) - } - }) + // Same header from a peer outside the trusted set is ignored. + resp = getMyIP(t, r, "198.51.100.9:4000", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + assert.Equal(t, "198.51.100.9", resp.IP) + assert.Equal(t, "direct", resp.Source) } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index b010a7ce6..cc8093282 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -325,7 +325,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg }) // Emergency endpoint - emergencyHandler := handlers.NewEmergencyHandlerWithDeps(db, caddyManager, cerb) + emergencyHandler := handlers.NewEmergencyHandlerWithDeps(db, caddyManager, cerb).WithManagementCIDRs(cfg.Security.ManagementCIDRs) emergency := router.Group("/api/v1/emergency") // Emergency endpoints must stay responsive and should not be rate limited. emergency.POST("/security-reset", emergencyHandler.SecurityReset) diff --git a/backend/internal/server/emergency_server.go b/backend/internal/server/emergency_server.go index a2c8f2cdc..fe04b4733 100644 --- a/backend/internal/server/emergency_server.go +++ b/backend/internal/server/emergency_server.go @@ -38,13 +38,14 @@ import ( // - Caddy itself is down or misconfigured // - Emergency access when main application port is unreachable type EmergencyServer struct { - server *http.Server - listener net.Listener - db *gorm.DB - cfg config.EmergencyConfig - cerberus handlers.CacheInvalidator - caddy handlers.CaddyConfigManager - gate *dbmaint.Gate + server *http.Server + listener net.Listener + db *gorm.DB + cfg config.EmergencyConfig + cerberus handlers.CacheInvalidator + caddy handlers.CaddyConfigManager + gate *dbmaint.Gate + mgmtCIDRs []string } // NewEmergencyServer creates a new emergency server instance @@ -65,6 +66,13 @@ func NewEmergencyServerWithDeps(db *gorm.DB, cfg config.EmergencyConfig, caddyMa } } +// WithManagementCIDRs sets the networks allowed to use the reset endpoint. +// An empty list selects the default private and loopback ranges. +func (s *EmergencyServer) WithManagementCIDRs(cidrs []string) *EmergencyServer { + s.mgmtCIDRs = cidrs + return s +} + // Start initializes and starts the emergency server func (s *EmergencyServer) Start() error { if !s.cfg.Enabled { @@ -99,6 +107,8 @@ func (s *EmergencyServer) Start() error { // Configure Gin for minimal logging (not production mode to preserve logs) router := gin.New() + // Decisions use the connection address only; forwarding headers are never honored here. + _ = router.SetTrustedProxies(nil) // Middleware 1: Recovery (panic handler) router.Use(gin.Recovery()) @@ -125,7 +135,7 @@ func (s *EmergencyServer) Start() error { }) // Emergency endpoints only - emergencyHandler := handlers.NewEmergencyHandlerWithDeps(s.db, s.caddy, s.cerberus) + emergencyHandler := handlers.NewEmergencyHandlerWithDeps(s.db, s.caddy, s.cerberus).WithManagementCIDRs(s.mgmtCIDRs) // GET /health - Health check endpoint (NO AUTH - must be accessible for monitoring) router.GET("/health", func(c *gin.Context) { diff --git a/backend/internal/server/emergency_server_test.go b/backend/internal/server/emergency_server_test.go index ac0658be7..841f4100d 100644 --- a/backend/internal/server/emergency_server_test.go +++ b/backend/internal/server/emergency_server_test.go @@ -437,3 +437,63 @@ func TestEmergencyServer_TokenRedaction(t *testing.T) { }) } } + +func TestEmergencyServer_SecurityReset_HonorsManagementCIDRs(t *testing.T) { + db := setupTestDB(t) + + emergencyToken := "test-emergency-token-for-testing-32chars" + t.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken) + + // The loopback test client is outside the configured network. + server := NewEmergencyServer(db, config.EmergencyConfig{Enabled: true, BindAddress: "127.0.0.1:0"}). + WithManagementCIDRs([]string{"203.0.113.0/24"}) + require.NoError(t, server.Start()) + defer func() { _ = server.Stop(context.Background()) }() + time.Sleep(100 * time.Millisecond) + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", server.GetAddr()), http.NoBody) + require.NoError(t, err) + req.Header.Set("X-Emergency-Token", emergencyToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestEmergencyServer_IgnoresRequestOriginHeaders(t *testing.T) { + emergencyToken := "test-emergency-token-for-testing-32chars" + hopHeaders := map[string]string{ + "X-Charon-Self-Hop": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "X-Charon-Self-Hop-Client": "203.0.113.9", + "X-Forwarded-For": "203.0.113.9", + } + + run := func(t *testing.T, cidrs []string) int { + t.Helper() + db := setupTestDB(t) + t.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken) + + server := NewEmergencyServer(db, config.EmergencyConfig{Enabled: true, BindAddress: "127.0.0.1:0"}).WithManagementCIDRs(cidrs) + require.NoError(t, server.Start()) + t.Cleanup(func() { _ = server.Stop(context.Background()) }) + time.Sleep(100 * time.Millisecond) + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", server.GetAddr()), http.NoBody) + require.NoError(t, err) + req.Header.Set("X-Emergency-Token", emergencyToken) + for k, v := range hopHeaders { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode + } + + // The address used for decisions is the connection's own address: a claimed + // client address inside the allowed network does not help a loopback peer... + assert.Equal(t, http.StatusForbidden, run(t, []string{"203.0.113.0/24"})) + // ...and a claimed outside address does not hurt one that is allowed. + assert.Equal(t, http.StatusOK, run(t, []string{"127.0.0.0/8"})) +} diff --git a/backend/internal/services/emergency_token_service.go b/backend/internal/services/emergency_token_service.go index c24efae2b..b65beaadc 100644 --- a/backend/internal/services/emergency_token_service.go +++ b/backend/internal/services/emergency_token_service.go @@ -3,6 +3,7 @@ package services import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/hex" "fmt" "os" @@ -175,7 +176,7 @@ func (s *EmergencyTokenService) Validate(token string) (*models.EmergencyToken, } // If DB token doesn't match, allow explicit environment token as break-glass fallback. - if hasValidEnvToken && envToken == token { + if hasValidEnvToken && tokensEqual(envToken, token) { logger.Log().Debug("Emergency token validated from environment variable while database token exists") return nil, nil } @@ -193,7 +194,7 @@ func (s *EmergencyTokenService) Validate(token string) (*models.EmergencyToken, } // Simple string comparison for env var token (no bcrypt for legacy) - if envToken != token { + if !tokensEqual(envToken, token) { return nil, fmt.Errorf("invalid token") } @@ -308,3 +309,8 @@ func (s *EmergencyTokenService) UpdateExpiration(expirationDays int) (*time.Time return expiresAt, nil } + +// tokensEqual compares two tokens in constant time. +func tokensEqual(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/backend/internal/services/emergency_token_service_test.go b/backend/internal/services/emergency_token_service_test.go index 033593ad2..4877e97e8 100644 --- a/backend/internal/services/emergency_token_service_test.go +++ b/backend/internal/services/emergency_token_service_test.go @@ -469,3 +469,29 @@ func TestEmergencyToken_DaysUntilExpiration(t *testing.T) { }) } } + +func TestTokensEqual(t *testing.T) { + assert.True(t, tokensEqual("same-token-value", "same-token-value")) + assert.False(t, tokensEqual("same-token-value", "same-token-valuf")) + assert.False(t, tokensEqual("same-token-value", "same-token-value-longer")) + assert.False(t, tokensEqual("same-token-value", "")) +} + +func TestEmergencyTokenService_Validate_EnvironmentTokenMismatch(t *testing.T) { + db := setupEmergencyTokenTestDB(t) + svc := NewEmergencyTokenService(db) + + envToken := "this-is-a-long-test-token-for-environment-fallback-validation" + t.Setenv(EmergencyTokenEnvVar, envToken) + + _, err := svc.Validate(envToken[:len(envToken)-1] + "x") + assert.Error(t, err) + _, err = svc.Validate(envToken + "x") + assert.Error(t, err) + + // With a database token present, a wrong value is also refused. + _, err = svc.Generate(GenerateRequest{ExpirationDays: 90}) + require.NoError(t, err) + _, err = svc.Validate(envToken + "x") + assert.Error(t, err) +} From 8510cb6aa8d70fd9efb3ae023400abbd1805ab70 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 04:07:39 +0000 Subject: [PATCH 03/57] refactor: add shared request middleware Add an engine-wide middleware chain constructor and a request context record used by the auth helpers. --- backend/cmd/api/main.go | 16 +- backend/internal/api/handlers/auth_handler.go | 12 + .../api/handlers/auth_handler_origin_test.go | 99 ++++ .../internal/api/handlers/system_handler.go | 6 +- .../api/handlers/system_handler_test.go | 28 + .../internal/api/middleware/request_origin.go | 170 ++++++ .../api/middleware/request_origin_test.go | 487 ++++++++++++++++++ backend/internal/server/router_chain_test.go | 42 ++ backend/internal/server/server.go | 3 +- 9 files changed, 855 insertions(+), 8 deletions(-) create mode 100644 backend/internal/api/handlers/auth_handler_origin_test.go create mode 100644 backend/internal/api/middleware/request_origin.go create mode 100644 backend/internal/api/middleware/request_origin_test.go create mode 100644 backend/internal/server/router_chain_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index d2e21f7f1..fce0f95ec 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -25,6 +25,8 @@ import ( "github.com/Wikid82/charon/backend/internal/dbmaint" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/charon/backend/internal/security/selfhop" "github.com/Wikid82/charon/backend/internal/server" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/version" @@ -294,12 +296,14 @@ func main() { // Initialize structured logger with same writer as stdlib log so both capture logs logger.Init(cfg.Debug, mw) logStartupWarnings(logger.Log(), cfg.StartupWarnings) - // Request ID middleware must run before recovery so the recover logs include the request id - router.Use(middleware.RequestID()) - // Log requests with request-scoped logger - router.Use(middleware.RequestLogger()) - // Attach a recovery middleware that logs stack traces when debug is enabled - router.Use(middleware.Recovery(cfg.Debug)) + // Per-process secret that lets the API recognise requests forwarded by its own proxy. + hopSecret, err := selfhop.NewSecret() + if err != nil { + log.Fatalf("init request origin secret: %v", err) + } + // Request-origin resolution, request id, request logging and recovery, in that order: + // the origin must be resolved before anything reads the client address. + router.Use(middleware.BaseChain(hopSecret, security.NewTrustedProxyMatcher(cfg.Security.TrustedProxies), cfg.Debug)...) // The gate goes before everything that touches the database (EmergencyBypass, // RateLimit), which RegisterWithDeps installs afterwards. router.Use(gate.Middleware(handlers.HealthHandler)) diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 46569f5b7..7077e919c 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" @@ -40,7 +41,13 @@ func isTrustedPeer(c *gin.Context, trustedProxies security.TrustedProxyMatcher) return trustedProxies.ContainsIP(normalizeHost(c.Request.RemoteAddr)) } +// requestScheme resolves the scheme the client used. A request carrying a verified +// origin record from Charon's own proxy uses the scheme recorded there; otherwise +// forwarded headers count only from a configured trusted peer. func requestScheme(c *gin.Context, trustedProxies security.TrustedProxyMatcher) string { + if origin, ok := middleware.RequestOriginFrom(c); ok && origin.Scheme != "" { + return origin.Scheme + } if isTrustedPeer(c, trustedProxies) { if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { // Honor first entry in a comma-separated header @@ -115,6 +122,11 @@ func isLocalRequest(c *gin.Context, trustedProxies security.TrustedProxyMatcher) return false } + // A verified origin record carries the real client address. + if origin, ok := middleware.RequestOriginFrom(c); ok { + return isLocalOrPrivateHost(origin.Addr) + } + if isTrustedPeer(c, trustedProxies) { candidates := []string{normalizeHost(c.Request.Host)} if c.Request.URL != nil { diff --git a/backend/internal/api/handlers/auth_handler_origin_test.go b/backend/internal/api/handlers/auth_handler_origin_test.go new file mode 100644 index 000000000..c8ce31e6f --- /dev/null +++ b/backend/internal/api/handlers/auth_handler_origin_test.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/charon/backend/internal/security/selfhop" +) + +func cookieViaOrigin(t *testing.T, secret *selfhop.Secret, headers map[string]string) *http.Cookie { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(middleware.SelfHop(secret, security.TrustedProxyMatcher{})) + r.GET("/cookie", func(c *gin.Context) { + setSecureCookie(c, "auth_token", "v", 60, security.TrustedProxyMatcher{}) + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "/cookie", http.NoBody) + req.RemoteAddr = "127.0.0.1:4000" + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + return cookies[0] +} + +func TestSetSecureCookie_UsesVerifiedOrigin(t *testing.T) { + secret, err := selfhop.NewSecret() + require.NoError(t, err) + hop := func(client string, extra map[string]string) map[string]string { + h := map[string]string{selfhop.HeaderSecret: secret.Reveal(), selfhop.HeaderClient: client} + for k, v := range extra { + h[k] = v + } + return h + } + + t.Run("direct loopback request over http keeps working", func(t *testing.T) { + c := cookieViaOrigin(t, secret, nil) + assert.False(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) + }) + + t.Run("public client over http gets a secure cookie", func(t *testing.T) { + c := cookieViaOrigin(t, secret, hop("198.51.100.23", map[string]string{"X-Forwarded-Proto": "http"})) + assert.True(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) + }) + + t.Run("public client over https gets a strict secure cookie", func(t *testing.T) { + c := cookieViaOrigin(t, secret, hop("198.51.100.23", map[string]string{"X-Forwarded-Proto": "https"})) + assert.True(t, c.Secure) + assert.Equal(t, http.SameSiteStrictMode, c.SameSite) + }) + + t.Run("private client over http stays usable", func(t *testing.T) { + c := cookieViaOrigin(t, secret, hop("192.168.1.50", map[string]string{"X-Forwarded-Proto": "http"})) + assert.False(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) + }) + + for name, client := range map[string]string{ + "zoned link-local client": "fe80::1%eth0", + "unparsable client value": "not-an-ip", + "empty client value": "", + } { + t.Run(name+" is not treated as local", func(t *testing.T) { + c := cookieViaOrigin(t, secret, hop(client, map[string]string{"X-Forwarded-Proto": "http"})) + assert.True(t, c.Secure) + }) + } + + t.Run("missing client header is not treated as local", func(t *testing.T) { + c := cookieViaOrigin(t, secret, map[string]string{selfhop.HeaderSecret: secret.Reveal(), "X-Forwarded-Proto": "http"}) + assert.True(t, c.Secure) + }) + + t.Run("unverified proof does not change the result", func(t *testing.T) { + c := cookieViaOrigin(t, secret, map[string]string{ + selfhop.HeaderSecret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + selfhop.HeaderClient: "198.51.100.23", + }) + assert.False(t, c.Secure) + }) +} diff --git a/backend/internal/api/handlers/system_handler.go b/backend/internal/api/handlers/system_handler.go index 09e8ba958..41ceb54c3 100644 --- a/backend/internal/api/handlers/system_handler.go +++ b/backend/internal/api/handlers/system_handler.go @@ -5,6 +5,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/Wikid82/charon/backend/internal/api/middleware" ) type SystemHandler struct{} @@ -25,7 +27,9 @@ func (h *SystemHandler) GetMyIP(c *gin.Context) { ip := c.ClientIP() source := "direct" - if peer, _, err := net.SplitHostPort(c.Request.RemoteAddr); err != nil || peer != ip { + if _, viaProxy := middleware.RequestOriginFrom(c); viaProxy { + source = "forwarded" + } else if peer, _, err := net.SplitHostPort(c.Request.RemoteAddr); err != nil || peer != ip { source = "forwarded" } diff --git a/backend/internal/api/handlers/system_handler_test.go b/backend/internal/api/handlers/system_handler_test.go index 40109d4b0..2efcd9c5a 100644 --- a/backend/internal/api/handlers/system_handler_test.go +++ b/backend/internal/api/handlers/system_handler_test.go @@ -9,6 +9,10 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/charon/backend/internal/security/selfhop" ) func getMyIP(t *testing.T, r *gin.Engine, remoteAddr string, headers map[string]string) MyIPResponse { @@ -69,3 +73,27 @@ func TestGetMyIP_HonorsTrustedProxyForwarding(t *testing.T) { assert.Equal(t, "198.51.100.9", resp.IP) assert.Equal(t, "direct", resp.Source) } + +func TestGetMyIP_ThroughOwnProxyReportsClient(t *testing.T) { + secret, err := selfhop.NewSecret() + require.NoError(t, err) + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(middleware.SelfHop(secret, security.TrustedProxyMatcher{})) + r.GET("/myip", NewSystemHandler().GetMyIP) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody) + req.RemoteAddr = "127.0.0.1:4000" + req.Header.Set(selfhop.HeaderSecret, secret.Reveal()) + req.Header.Set(selfhop.HeaderClient, "198.51.100.23") + req.Header.Set("X-Forwarded-For", "6.6.6.6") + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp MyIPResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "198.51.100.23", resp.IP) + assert.Equal(t, "forwarded", resp.Source) +} diff --git a/backend/internal/api/middleware/request_origin.go b/backend/internal/api/middleware/request_origin.go new file mode 100644 index 000000000..460a8e380 --- /dev/null +++ b/backend/internal/api/middleware/request_origin.go @@ -0,0 +1,170 @@ +package middleware + +import ( + "net" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/ratelimit" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/charon/backend/internal/security/selfhop" + "github.com/Wikid82/charon/backend/internal/util" +) + +// requestOriginKey is the gin context key holding the RequestOrigin record. +const requestOriginKey = "request_origin" + +// RequestOrigin describes a request that reached the API through Charon's own +// reverse proxy and presented a verified proof of that hop. +type RequestOrigin struct { + // Addr is the client address reported by the proxy (the raw peer when the + // proxy did not supply a usable one). + Addr string + // Peer is the address of the TCP peer that delivered the request. + Peer string + // Scheme is the scheme the client used ("http" or "https"), or empty. + Scheme string + // Host is the host the client addressed, or empty. + Host string +} + +// RequestOriginFrom returns the verified origin record, if the request carried one. +func RequestOriginFrom(c *gin.Context) (RequestOrigin, bool) { + v, ok := c.Get(requestOriginKey) + if !ok { + return RequestOrigin{}, false + } + origin, ok := v.(RequestOrigin) + return origin, ok +} + +// peerChecker decides whether a TCP peer is allowed to present hop proof. +type peerChecker interface { + Contains(ip net.IP) bool +} + +// SelfHop recognises requests forwarded by Charon's own reverse proxy. +// +// The proxy sets selfhop.HeaderSecret and selfhop.HeaderClient on requests whose +// upstream is Charon. When the peer is loopback or an address of this host and +// the secret verifies, the client address replaces the connection address for +// the rest of the chain (so c.ClientIP() reports the real client) and a +// RequestOrigin record is stored. In every other case the request is left as is. +// Both headers are always removed before the next handler runs. +// +// It must be the first middleware on the engine. +func SelfHop(secret *selfhop.Secret, trusted security.TrustedProxyMatcher) gin.HandlerFunc { + return selfHopHandler(secret, trusted, selfhop.NewLocalAddrs(), ratelimit.NewWarnBudget(3, time.Minute, nil)) +} + +func selfHopHandler(secret *selfhop.Secret, trusted security.TrustedProxyMatcher, local peerChecker, warn *ratelimit.WarnBudget) gin.HandlerFunc { + return func(c *gin.Context) { + hdr := c.Request.Header + secretVals, hasSecret := hdr[http.CanonicalHeaderKey(selfhop.HeaderSecret)] + clientVals, hasClient := hdr[http.CanonicalHeaderKey(selfhop.HeaderClient)] + if !hasSecret && !hasClient { + c.Next() + return + } + hdr.Del(selfhop.HeaderSecret) + hdr.Del(selfhop.HeaderClient) + + peerHost, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + peerHost = c.Request.RemoteAddr + } + peerIP := net.ParseIP(peerHost) + peerAddr, _ := netip.AddrFromSlice(peerIP) + peerAddr = peerAddr.Unmap() + + if peerIP == nil || !local.Contains(peerIP) || len(secretVals) != 1 || !secret.Verify(secretVals[0]) { + if ok, suppressed := warn.Take(); ok { + logger.Log().WithFields(map[string]any{ + "peer": util.SanitizeForLog(peerHost), + "suppressed": suppressed, + }).Warn("Ignored unverified forwarding proof on request") + } + c.Next() + return + } + + // An unusable client value must never fall back to the connection address: + // that address is this host's own, which later checks treat as local. It + // resolves to the unspecified address, which no local or management-network + // check accepts. Scheme and host still come from the verified hop. + addr := netip.IPv4Unspecified() + clientOK := false + if len(clientVals) == 1 { + addr, clientOK = parseClientAddr(clientVals[0]) + if !clientOK { + addr = netip.IPv4Unspecified() + } + } + if !clientOK { + if ok, suppressed := warn.Take(); ok { + logger.Log().WithField("suppressed", suppressed).Warn("Verified forwarding proof carried no usable client address") + } + } + origin := RequestOrigin{ + Addr: addr.String(), + Peer: peerIP.String(), + Scheme: forwardedScheme(hdr.Get("X-Forwarded-Proto")), + Host: firstListEntry(hdr.Get("X-Forwarded-Host")), + } + + // A peer the operator configured as a trusted proxy keeps the configured + // forwarded-header handling. Otherwise the reported client address replaces + // the connection address and the client-address headers are dropped. + if trusted.Contains(peerAddr) { + origin.Addr = c.ClientIP() + } else { + c.Request.RemoteAddr = net.JoinHostPort(origin.Addr, "0") + hdr.Del("X-Forwarded-For") + hdr.Del("X-Real-IP") + } + c.Set(requestOriginKey, origin) + c.Next() + } +} + +func firstListEntry(v string) string { + first, _, _ := strings.Cut(v, ",") + return strings.TrimSpace(first) +} + +func forwardedScheme(v string) string { + switch s := strings.ToLower(firstListEntry(v)); s { + case "http", "https": + return s + default: + return "" + } +} + +// BaseChain returns the engine-wide middleware installed before anything else: +// request-origin resolution first, then request id, request logging and panic +// recovery. Both the server entrypoint and tests build the chain through here +// so the order stays in one place. +func BaseChain(secret *selfhop.Secret, trusted security.TrustedProxyMatcher, debug bool) []gin.HandlerFunc { + return []gin.HandlerFunc{ + SelfHop(secret, trusted), + RequestID(), + RequestLogger(), + Recovery(debug), + } +} + +// parseClientAddr parses a client address value. An IPv6 zone is dropped and +// IPv4-mapped addresses are returned in IPv4 form. +func parseClientAddr(v string) (netip.Addr, bool) { + addr, err := netip.ParseAddr(strings.TrimSpace(v)) + if err != nil { + return netip.Addr{}, false + } + return addr.WithZone("").Unmap(), true +} diff --git a/backend/internal/api/middleware/request_origin_test.go b/backend/internal/api/middleware/request_origin_test.go new file mode 100644 index 000000000..2763a9305 --- /dev/null +++ b/backend/internal/api/middleware/request_origin_test.go @@ -0,0 +1,487 @@ +package middleware + +import ( + "bytes" + "net" + "net/http" + "net/http/httptest" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/ratelimit" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/charon/backend/internal/security/selfhop" +) + +type peerFunc func(net.IP) bool + +func (f peerFunc) Contains(ip net.IP) bool { return f(ip) } + +// localPeers accepts loopback and 172.18.0.0/16 as this host's addresses. +var localPeers = peerFunc(func(ip net.IP) bool { + _, n, _ := net.ParseCIDR("172.18.0.0/16") + return ip.IsLoopback() || n.Contains(ip) +}) + +type probeResult struct { + clientIP string + remoteAddr string + origin RequestOrigin + hasOrigin bool + headers http.Header +} + +func newOriginSecret(t *testing.T) *selfhop.Secret { + t.Helper() + s, err := selfhop.NewSecret() + require.NoError(t, err) + return s +} + +func originRouter(t *testing.T, secret *selfhop.Secret, trustedProxies []string, res *probeResult) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(trustedProxies)) + r.Use(selfHopHandler(secret, security.TrustedProxyMatcher{}, localPeers, ratelimit.NewWarnBudget(3, time.Minute, nil))) + r.GET("/probe", func(c *gin.Context) { + res.clientIP = c.ClientIP() + res.remoteAddr = c.Request.RemoteAddr + res.origin, res.hasOrigin = RequestOriginFrom(c) + res.headers = c.Request.Header.Clone() + c.Status(http.StatusNoContent) + }) + return r +} + +func doProbe(r *gin.Engine, remoteAddr string, headers map[string]string) { + req := httptest.NewRequest(http.MethodGet, "/probe", http.NoBody) + req.RemoteAddr = remoteAddr + for k, v := range headers { + req.Header.Set(k, v) + } + r.ServeHTTP(httptest.NewRecorder(), req) +} + +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + logger.Init(false, buf) + return buf +} + +func TestSelfHop_WithoutHeadersChangesNothing(t *testing.T) { + var res probeResult + r := originRouter(t, newOriginSecret(t), nil, &res) + + doProbe(r, "203.0.113.9:4321", map[string]string{"X-Forwarded-For": "6.6.6.6", "X-Forwarded-Proto": "https"}) + + assert.False(t, res.hasOrigin) + assert.Equal(t, "203.0.113.9:4321", res.remoteAddr) + assert.Equal(t, "203.0.113.9", res.clientIP) + assert.Equal(t, "6.6.6.6", res.headers.Get("X-Forwarded-For")) +} + +func TestSelfHop_VerifiedHopResolvesClient(t *testing.T) { + secret := newOriginSecret(t) + var res probeResult + r := originRouter(t, secret, nil, &res) + + doProbe(r, "127.0.0.1:5555", map[string]string{ + selfhop.HeaderSecret: secret.Reveal(), + selfhop.HeaderClient: "198.51.100.23", + "X-Forwarded-Proto": "HTTPS, http", + "X-Forwarded-Host": "charon.example.com, other", + "X-Forwarded-For": "6.6.6.6, 198.51.100.23", + "X-Real-IP": "6.6.6.6", + }) + + require.True(t, res.hasOrigin) + assert.Equal(t, "198.51.100.23", res.clientIP) + assert.Equal(t, "198.51.100.23:0", res.remoteAddr) + assert.Equal(t, RequestOrigin{Addr: "198.51.100.23", Peer: "127.0.0.1", Scheme: "https", Host: "charon.example.com"}, res.origin) + assert.Empty(t, res.headers.Get(selfhop.HeaderSecret)) + assert.Empty(t, res.headers.Get(selfhop.HeaderClient)) + assert.Empty(t, res.headers.Get("X-Forwarded-For")) + assert.Empty(t, res.headers.Get("X-Real-IP")) +} + +func TestSelfHop_VerifiedHopFromOwnInterfaceAddress(t *testing.T) { + secret := newOriginSecret(t) + var res probeResult + r := originRouter(t, secret, nil, &res) + + doProbe(r, "172.18.0.5:5555", map[string]string{selfhop.HeaderSecret: secret.Reveal(), selfhop.HeaderClient: "2001:db8::7"}) + + require.True(t, res.hasOrigin) + assert.Equal(t, "2001:db8::7", res.clientIP) + assert.Equal(t, "[2001:db8::7]:0", res.remoteAddr) +} + +func TestSelfHop_UnusableClientResolvesToNonLocalAddress(t *testing.T) { + secret := newOriginSecret(t) + mgmt := ParseManagementNets(nil) + for _, client := range []string{"", "{http.request.remote.host}", "not-an-ip", "198.51.100.23:80", "1.2.3.4, 5.6.7.8", "[::1]"} { + var res probeResult + r := originRouter(t, secret, nil, &res) + + doProbe(r, "172.18.0.5:5555", map[string]string{ + selfhop.HeaderSecret: secret.Reveal(), + selfhop.HeaderClient: client, + "X-Forwarded-Proto": "https", + }) + + require.True(t, res.hasOrigin, client) + assert.Equal(t, "0.0.0.0", res.clientIP, client) + assert.Equal(t, "https", res.origin.Scheme, client) + assert.Equal(t, "172.18.0.5", res.origin.Peer, client) + ip := net.ParseIP(res.clientIP) + assert.False(t, ip.IsLoopback() || ip.IsPrivate(), client) + assert.False(t, IsManagementIP(mgmt, ip), client) + } +} + +func TestSelfHop_MissingClientHeaderResolvesToNonLocalAddress(t *testing.T) { + secret := newOriginSecret(t) + var res probeResult + r := originRouter(t, secret, nil, &res) + + doProbe(r, "127.0.0.1:5555", map[string]string{selfhop.HeaderSecret: secret.Reveal(), "X-Forwarded-Proto": "http"}) + + require.True(t, res.hasOrigin) + assert.Equal(t, "0.0.0.0", res.clientIP) + assert.Equal(t, "http", res.origin.Scheme) + assert.False(t, IsManagementIP(ParseManagementNets(nil), net.ParseIP(res.clientIP))) +} + +func TestSelfHop_ClientAddressNormalisation(t *testing.T) { + secret := newOriginSecret(t) + tests := map[string]string{ + "fe80::1%eth0": "fe80::1", + "2001:db8::7%en0": "2001:db8::7", + "::ffff:203.0.113.9": "203.0.113.9", + "2001:DB8::A": "2001:db8::a", + } + for in, want := range tests { + var res probeResult + r := originRouter(t, secret, nil, &res) + + doProbe(r, "127.0.0.1:5555", map[string]string{selfhop.HeaderSecret: secret.Reveal(), selfhop.HeaderClient: in}) + + require.True(t, res.hasOrigin, in) + assert.Equal(t, want, res.clientIP, in) + assert.False(t, IsManagementIP(ParseManagementNets(nil), net.ParseIP(res.clientIP)), in) + } +} + +func TestSelfHop_IgnoresUnverifiedProof(t *testing.T) { + secret := newOriginSecret(t) + tests := []struct { + name string + peer string + secret string + }{ + {"wrong secret from loopback", "127.0.0.1:1", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + {"missing secret", "127.0.0.1:1", ""}, + {"correct secret from outside peer", "203.0.113.9:1", secret.Reveal()}, + {"correct secret from other private peer", "10.0.0.8:1", secret.Reveal()}, + {"unparsable peer", "garbage", secret.Reveal()}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + logs := captureLogs(t) + var res probeResult + r := originRouter(t, secret, nil, &res) + + headers := map[string]string{selfhop.HeaderClient: "198.51.100.23"} + if tc.secret != "" { + headers[selfhop.HeaderSecret] = tc.secret + } + doProbe(r, tc.peer, headers) + + assert.False(t, res.hasOrigin) + assert.Equal(t, tc.peer, res.remoteAddr) + assert.NotEqual(t, "198.51.100.23", res.clientIP) + assert.Empty(t, res.headers.Get(selfhop.HeaderSecret)) + assert.Empty(t, res.headers.Get(selfhop.HeaderClient)) + assert.Equal(t, 1, strings.Count(logs.String(), "Ignored unverified forwarding proof")) + assert.NotContains(t, logs.String(), secret.Reveal()) + if tc.secret != "" { + assert.NotContains(t, logs.String(), tc.secret) + } + }) + } +} + +func TestSelfHop_StaleSecretFallsBackToPeerWithOneWarning(t *testing.T) { + logs := captureLogs(t) + staleSecret := newOriginSecret(t) + currentSecret := newOriginSecret(t) + var res probeResult + r := originRouter(t, currentSecret, nil, &res) + + doProbe(r, "127.0.0.1:1", map[string]string{selfhop.HeaderSecret: staleSecret.Reveal(), selfhop.HeaderClient: "198.51.100.23"}) + + assert.False(t, res.hasOrigin) + assert.Equal(t, "127.0.0.1", res.clientIP) + assert.Equal(t, 1, strings.Count(logs.String(), "Ignored unverified forwarding proof")) + assert.NotContains(t, logs.String(), staleSecret.Reveal()) + assert.NotContains(t, logs.String(), currentSecret.Reveal()) +} + +func TestSelfHop_RepeatedSecretHeadersRejected(t *testing.T) { + secret := newOriginSecret(t) + var res probeResult + r := originRouter(t, secret, nil, &res) + + req := httptest.NewRequest(http.MethodGet, "/probe", http.NoBody) + req.RemoteAddr = "127.0.0.1:1" + req.Header.Add(selfhop.HeaderSecret, secret.Reveal()) + req.Header.Add(selfhop.HeaderSecret, secret.Reveal()) + req.Header.Set(selfhop.HeaderClient, "198.51.100.23") + r.ServeHTTP(httptest.NewRecorder(), req) + + assert.False(t, res.hasOrigin) + assert.Equal(t, "127.0.0.1", res.clientIP) +} + +func TestSelfHop_WarningsAreRateLimited(t *testing.T) { + logs := captureLogs(t) + var res probeResult + r := originRouter(t, newOriginSecret(t), nil, &res) + + for range 20 { + doProbe(r, "203.0.113.9:1", map[string]string{selfhop.HeaderSecret: "x", selfhop.HeaderClient: "198.51.100.23"}) + } + assert.LessOrEqual(t, strings.Count(logs.String(), "Ignored unverified forwarding proof"), 3) +} + +func TestSelfHop_NilSecretNeverVerifies(t *testing.T) { + var res probeResult + r := originRouter(t, nil, nil, &res) + + doProbe(r, "127.0.0.1:1", map[string]string{selfhop.HeaderSecret: "anything", selfhop.HeaderClient: "198.51.100.23"}) + assert.False(t, res.hasOrigin) + assert.Equal(t, "127.0.0.1", res.clientIP) +} + +// A client behind a private network can send any X-Forwarded-For value. With the +// engine trusting private peers, the verified address must still be the one +// resolved, never an entry from the client-supplied header. +func TestSelfHop_IgnoresClientSuppliedForwardingHeadersFromPrivatePeer(t *testing.T) { + secret := newOriginSecret(t) + trusted := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"} + var res probeResult + r := originRouter(t, secret, trusted, &res) + + doProbe(r, "127.0.0.1:1", map[string]string{ + selfhop.HeaderSecret: secret.Reveal(), + selfhop.HeaderClient: "192.168.1.50", + "X-Forwarded-For": "6.6.6.6, 192.168.1.50", + "X-Real-IP": "6.6.6.6", + }) + + require.True(t, res.hasOrigin) + assert.Equal(t, "192.168.1.50", res.clientIP) +} + +func TestSelfHop_BaseChainOrder(t *testing.T) { + secret := newOriginSecret(t) + chain := BaseChain(secret, security.TrustedProxyMatcher{}, false) + require.Len(t, chain, 4) + + names := make([]string, len(chain)) + for i, h := range chain { + names[i] = runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name() + } + idx := func(sub string) int { + for i, n := range names { + if strings.Contains(n, sub) { + return i + } + } + return -1 + } + assert.Equal(t, 0, idx("elfHop"), names) + assert.Less(t, idx("elfHop"), idx("RequestID"), names) + assert.Less(t, idx("elfHop"), idx("RequestLogger"), names) + assert.Less(t, idx("elfHop"), idx("Recovery"), names) +} + +// Emergency bypass runs after the chain, so it must evaluate the resolved client +// address: only a verified hop can move the evaluated address. +func TestSelfHop_BaseChainFeedsLaterMiddleware(t *testing.T) { + logger.Init(false, &bytes.Buffer{}) + t.Setenv(EmergencyTokenEnvVar, strings.Repeat("e", MinTokenLength)) + secret := newOriginSecret(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(BaseChain(secret, security.TrustedProxyMatcher{}, false)...) + r.Use(EmergencyBypass([]string{"203.0.113.0/24"}, nil)) + r.GET("/probe", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"bypass": IsEmergencyBypass(c), "client": c.ClientIP()}) + }) + + serve := func(peer string, headers map[string]string) string { + req := httptest.NewRequest(http.MethodGet, "/probe", http.NoBody) + req.RemoteAddr = peer + req.Header.Set(EmergencyTokenHeader, strings.Repeat("e", MinTokenLength)) + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w.Body.String() + } + + // Loopback peer without proof: address stays loopback, outside the network. + assert.Contains(t, serve("127.0.0.1:1", nil), `"bypass":false`) + // Same peer claiming an address without a valid secret: still loopback. + body := serve("127.0.0.1:1", map[string]string{selfhop.HeaderSecret: "nope", selfhop.HeaderClient: "203.0.113.9"}) + assert.Contains(t, body, `"bypass":false`) + assert.Contains(t, body, `"client":"127.0.0.1"`) + // Verified proof: the reported client address is what later middleware sees. + body = serve("127.0.0.1:1", map[string]string{selfhop.HeaderSecret: secret.Reveal(), selfhop.HeaderClient: "203.0.113.9"}) + assert.Contains(t, body, `"bypass":true`) + assert.Contains(t, body, `"client":"203.0.113.9"`) +} + +func TestSelfHop_RealPeerCheckerIsUsedByConstructor(t *testing.T) { + secret := newOriginSecret(t) + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(SelfHop(secret, security.TrustedProxyMatcher{})) + var seen RequestOrigin + var ok bool + r.GET("/probe", func(c *gin.Context) { + seen, ok = RequestOriginFrom(c) + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "/probe", http.NoBody) + req.RemoteAddr = "127.0.0.1:1" + req.Header.Set(selfhop.HeaderSecret, secret.Reveal()) + req.Header.Set(selfhop.HeaderClient, "198.51.100.23") + r.ServeHTTP(httptest.NewRecorder(), req) + + require.True(t, ok) + assert.Equal(t, "198.51.100.23", seen.Addr) + + // A public peer is never accepted, whatever it sends. + ok = false + req = httptest.NewRequest(http.MethodGet, "/probe", http.NoBody) + req.RemoteAddr = "203.0.113.9:1" + req.Header.Set(selfhop.HeaderSecret, secret.Reveal()) + r.ServeHTTP(httptest.NewRecorder(), req) + assert.False(t, ok) +} + +func TestRequestOriginFrom_WrongTypeIsIgnored(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(requestOriginKey, "not-an-origin") + _, ok := RequestOriginFrom(c) + assert.False(t, ok) +} + +func trustedOriginRouter(t *testing.T, secret *selfhop.Secret, configured []string, res *probeResult) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(configured)) + r.Use(selfHopHandler(secret, security.NewTrustedProxyMatcher(configured), localPeers, ratelimit.NewWarnBudget(3, time.Minute, nil))) + r.GET("/probe", func(c *gin.Context) { + res.clientIP = c.ClientIP() + res.remoteAddr = c.Request.RemoteAddr + res.origin, res.hasOrigin = RequestOriginFrom(c) + res.headers = c.Request.Header.Clone() + c.Status(http.StatusNoContent) + }) + return r +} + +// A peer the operator listed as a trusted proxy keeps the configured +// forwarded-header handling; the origin record still carries scheme and host. +func TestSelfHop_ConfiguredTrustedPeerKeepsForwardedHeaderHandling(t *testing.T) { + secret := newOriginSecret(t) + var res probeResult + r := trustedOriginRouter(t, secret, []string{"127.0.0.1/32", "::1/128", "192.168.0.0/16"}, &res) + + // The chain as the proxy forwards it: client, then the front proxy. + doProbe(r, "127.0.0.1:5555", map[string]string{ + selfhop.HeaderSecret: secret.Reveal(), + selfhop.HeaderClient: "192.168.1.9", + "X-Forwarded-For": "203.0.113.50, 192.168.1.9", + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "charon.example.com", + }) + + require.True(t, res.hasOrigin) + assert.Equal(t, "127.0.0.1:5555", res.remoteAddr) + assert.Equal(t, "203.0.113.50, 192.168.1.9", res.headers.Get("X-Forwarded-For")) + assert.Equal(t, "203.0.113.50", res.clientIP) + assert.Equal(t, "203.0.113.50", res.origin.Addr) + assert.Equal(t, "127.0.0.1", res.origin.Peer) + assert.Equal(t, "https", res.origin.Scheme) + assert.Equal(t, "charon.example.com", res.origin.Host) + assert.Empty(t, res.headers.Get(selfhop.HeaderSecret)) + assert.Empty(t, res.headers.Get(selfhop.HeaderClient)) +} + +// With a trusted-proxy list that does not include the peer, the reported +// client address is used and client-supplied forwarding headers are ignored. +func TestSelfHop_PeerOutsideConfiguredListUsesReportedClient(t *testing.T) { + secret := newOriginSecret(t) + var res probeResult + r := trustedOriginRouter(t, secret, []string{"10.0.0.0/8"}, &res) + + doProbe(r, "127.0.0.1:5555", map[string]string{ + selfhop.HeaderSecret: secret.Reveal(), + selfhop.HeaderClient: "10.1.1.1", + "X-Forwarded-For": "6.6.6.6, 10.1.1.1", + "X-Real-IP": "6.6.6.6", + }) + + require.True(t, res.hasOrigin) + assert.Equal(t, "10.1.1.1", res.clientIP) + assert.Equal(t, "10.1.1.1:0", res.remoteAddr) + assert.Empty(t, res.headers.Get("X-Forwarded-For")) + assert.Empty(t, res.headers.Get("X-Real-IP")) +} + +// A verified hop whose client value is unusable must not let later +// management-network checks pass on the strength of the local peer address. +func TestSelfHop_UnusableClientDoesNotSatisfyManagementCheck(t *testing.T) { + logger.Init(false, &bytes.Buffer{}) + t.Setenv(EmergencyTokenEnvVar, strings.Repeat("e", MinTokenLength)) + secret := newOriginSecret(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(BaseChain(secret, security.TrustedProxyMatcher{}, false)...) + r.Use(EmergencyBypass(nil, nil)) // default private and loopback networks + r.GET("/probe", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"bypass": IsEmergencyBypass(c)}) }) + + for _, client := range []string{"fe80::1%eth0", "garbage", ""} { + req := httptest.NewRequest(http.MethodGet, "/probe", http.NoBody) + req.RemoteAddr = "127.0.0.1:1" + req.Header.Set(EmergencyTokenHeader, strings.Repeat("e", MinTokenLength)) + req.Header.Set(selfhop.HeaderSecret, secret.Reveal()) + req.Header.Set(selfhop.HeaderClient, client) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Contains(t, w.Body.String(), `"bypass":false`, client) + } +} diff --git a/backend/internal/server/router_chain_test.go b/backend/internal/server/router_chain_test.go new file mode 100644 index 000000000..91eba7aa1 --- /dev/null +++ b/backend/internal/server/router_chain_test.go @@ -0,0 +1,42 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/charon/backend/internal/security/selfhop" +) + +// The shared chain owns request logging and panic recovery on the router. +func TestNewRouter_BaseChainLogsOnceAndRecoversPanics(t *testing.T) { + gin.SetMode(gin.TestMode) + buf := &bytes.Buffer{} + logger.Init(false, buf) + secret, err := selfhop.NewSecret() + require.NoError(t, err) + + router := NewRouter("", "", nil) + router.Use(middleware.BaseChain(secret, security.TrustedProxyMatcher{}, false)...) + router.GET("/ok", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + router.GET("/boom", func(_ *gin.Context) { panic("boom") }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ok", http.NoBody)) + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, 1, strings.Count(buf.String(), "handled request")) + + w = httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", http.NoBody)) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, buf.String(), "PANIC") +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 482e92e41..7a7bf0314 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -16,7 +16,8 @@ import ( // Gin's own ClientIP() should honor (see docs/configuration/trusted-proxies.md). // Empty/nil trusts nothing, matching Gin's SetTrustedProxies(nil) default. func NewRouter(frontendDir, dataDir string, trustedProxies []string) *gin.Engine { - router := gin.Default() + // Logging and panic recovery are installed by middleware.BaseChain. + router := gin.New() // Gin trusts all proxies by default. In v1.11.x, SetTrustedProxies(nil) disables // trusting forwarded headers entirely, making Context.ClientIP() use the remote // socket address. Only enable trusted proxies via an explicit allow-list. From a1ff310ca9ddc6b0e2e6835908902f697f6c2c34 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 04:08:16 +0000 Subject: [PATCH 04/57] fix(security): harden request handling in the API layer Pass request context details between the proxy and the API for routes that target the API itself. --- backend/cmd/api/main.go | 1 + .../api/middleware/request_origin_test.go | 32 ++ backend/internal/caddy/config.go | 14 +- backend/internal/caddy/config_options.go | 23 ++ backend/internal/caddy/manager.go | 24 +- backend/internal/caddy/self_upstream.go | 115 +++++++ backend/internal/caddy/self_upstream_test.go | 315 ++++++++++++++++++ 7 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 backend/internal/caddy/self_upstream.go create mode 100644 backend/internal/caddy/self_upstream_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index fce0f95ec..74ea59302 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -311,6 +311,7 @@ func main() { // Shared Caddy manager and Cerberus instance for API + emergency server caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) + caddyManager.SetSelfHop(hopSecret, cfg.HTTPPort) cerb := cerberus.New(cfg.Security, db) // Pass config to routes for auth service and certificate service diff --git a/backend/internal/api/middleware/request_origin_test.go b/backend/internal/api/middleware/request_origin_test.go index 2763a9305..e8500a0f1 100644 --- a/backend/internal/api/middleware/request_origin_test.go +++ b/backend/internal/api/middleware/request_origin_test.go @@ -395,6 +395,38 @@ func TestRequestOriginFrom_WrongTypeIsIgnored(t *testing.T) { assert.False(t, ok) } +// The proof must not reach handlers, panic reports, request logs or responses. +func TestSelfHop_ProofDoesNotAppearInLogsOrResponses(t *testing.T) { + logs := captureLogs(t) + secret := newOriginSecret(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(BaseChain(secret, security.TrustedProxyMatcher{}, true)...) + r.GET("/dump", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"headers": c.Request.Header, "client": c.ClientIP()}) + }) + r.GET("/panic", func(c *gin.Context) { + panic(c.Request.Header) + }) + + for _, path := range []string{"/dump", "/panic"} { + req := httptest.NewRequest(http.MethodGet, path, http.NoBody) + req.RemoteAddr = "127.0.0.1:1" + req.Header.Set(selfhop.HeaderSecret, secret.Reveal()) + req.Header.Set(selfhop.HeaderClient, "198.51.100.23") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.NotContains(t, w.Body.String(), secret.Reveal(), path) + assert.NotContains(t, strings.ToLower(w.Body.String()), strings.ToLower(selfhop.HeaderSecret), path) + assert.NotContains(t, w.Header().Get("X-Request-ID"), secret.Reveal(), path) + } + assert.NotContains(t, logs.String(), secret.Reveal()) + assert.Contains(t, logs.String(), "198.51.100.23") +} + func trustedOriginRouter(t *testing.T, secret *selfhop.Secret, configured []string, res *probeResult) *gin.Engine { t.Helper() gin.SetMode(gin.TestMode) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index dca4b710d..5675c70b8 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -557,7 +557,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir locHandlers := append(append([]Handler{}, securityHandlers...), handlers...) // Determine if standard headers should be enabled (default true if nil) enableStdHeaders := host.EnableStandardHeaders == nil || *host.EnableStandardHeaders - locHandlers = append(locHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application, enableStdHeaders)) + locHandlers = append(locHandlers, resolvedOpts.proxyHandler(dial, false, host.WebsocketSupport, host.Application, enableStdHeaders)) locRoute := &Route{ Match: []Match{ { @@ -636,7 +636,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir "/emergency/security-reset", "/emergency/*", } - emergencyHandlers := append(append([]Handler{}, handlers...), ReverseProxyHandler(dial, host.WebsocketSupport, host.Application, enableStdHeaders)) + emergencyHandlers := append(append([]Handler{}, handlers...), resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.WebsocketSupport, host.Application, enableStdHeaders)) emergencyRoute := &Route{ Match: []Match{ { @@ -657,7 +657,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir routes = append(routes, emergencyRoute) mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...) - mainHandlers = append(mainHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application, enableStdHeaders)) + mainHandlers = append(mainHandlers, resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.WebsocketSupport, host.Application, enableStdHeaders)) route := &Route{ Match: []Match{ @@ -1708,6 +1708,9 @@ func dedupeDomains(domains []string) []string { return result } +// orthrusHostPrefix marks a ForwardHost that names an Orthrus agent target. +const orthrusHostPrefix = "orthrus:" + // OrthrusAddrResolver resolves the live proxy address for an Orthrus agent. // This interface breaks the import cycle between caddy and orthrus packages. type OrthrusAddrResolver interface { @@ -1720,14 +1723,13 @@ func resolveOrthrusHosts(hosts []models.ProxyHost, server OrthrusAddrResolver) [ if server == nil { return hosts } - const prefix = "orthrus:" out := make([]models.ProxyHost, len(hosts)) copy(out, hosts) for i, h := range out { - if !strings.HasPrefix(h.ForwardHost, prefix) { + if !strings.HasPrefix(h.ForwardHost, orthrusHostPrefix) { continue } - agentUUID := strings.TrimPrefix(h.ForwardHost, prefix) + agentUUID := strings.TrimPrefix(h.ForwardHost, orthrusHostPrefix) addr, ok := server.GetProxyAddr(agentUUID) if !ok { continue diff --git a/backend/internal/caddy/config_options.go b/backend/internal/caddy/config_options.go index f4d5b1c21..00f98bd7d 100644 --- a/backend/internal/caddy/config_options.go +++ b/backend/internal/caddy/config_options.go @@ -3,6 +3,7 @@ package caddy import ( "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/security/selfhop" ) // GenerateConfigOption configures optional inputs to GenerateConfig that @@ -24,6 +25,8 @@ type GenerateConfigOption func(*generateConfigOptions) type generateConfigOptions struct { encSvc *crypto.EncryptionService redirectHosts []models.RedirectionHost + selfHop *selfHopConfig + remoteHosts map[string]struct{} } // resolveGenerateConfigOptions applies opts in order and returns the @@ -50,3 +53,23 @@ func WithEncryptionService(svc *crypto.EncryptionService) GenerateConfigOption { func WithRedirectionHosts(hosts []models.RedirectionHost) GenerateConfigOption { return func(o *generateConfigOptions) { o.redirectHosts = hosts } } + +// WithSelfHop enables hop headers on proxy routes whose upstream is Charon's own +// API listening on port. Without it no route carries them. +func WithSelfHop(secret *selfhop.Secret, port string) GenerateConfigOption { + return func(o *generateConfigOptions) { + o.selfHop = &selfHopConfig{secret: secret, port: port, local: selfhop.NewLocalAddrs()} + } +} + +// WithRemoteHosts lists proxy host UUIDs whose upstream lives on another +// machine (such as an agent-resolved target). Those routes never receive hop +// headers, whatever address the upstream resolved to. +func WithRemoteHosts(uuids map[string]struct{}) GenerateConfigOption { + return func(o *generateConfigOptions) { o.remoteHosts = uuids } +} + +func (o *generateConfigOptions) isRemoteHost(uuid string) bool { + _, ok := o.remoteHosts[uuid] + return ok +} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index c8b3c6e94..6e9ee046d 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -19,6 +19,7 @@ import ( "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/security/selfhop" ) // Test hooks to allow overriding OS and JSON functions @@ -77,6 +78,8 @@ type Manager struct { securityCfg config.SecurityConfig encSvc *crypto.EncryptionService orthrusSvc OrthrusAddrResolver + hopSecret *selfhop.Secret + hopPort string } // NewManager creates a configuration manager. @@ -101,6 +104,15 @@ func (m *Manager) SetOrthrusServer(s OrthrusAddrResolver) { m.orthrusSvc = s } +// SetSelfHop enables recognition of requests proxied to Charon's own API on port. +// Snapshots written by this manager carry a placeholder instead of the secret. +// The secret changes at every restart, so a persisted copy goes stale, and any +// file Caddy keeps of its own config has the same trust level as the config. +func (m *Manager) SetSelfHop(secret *selfhop.Secret, port string) { + m.hopSecret = secret + m.hopPort = port +} + // ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure. func (m *Manager) ApplyConfig(ctx context.Context) error { m.mu.Lock() @@ -446,9 +458,15 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { } } + remoteHosts := orthrusHostUUIDs(hosts) hosts = resolveOrthrusHosts(hosts, m.orthrusSvc) - generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, WithEncryptionService(m.encSvc), WithRedirectionHosts(redirectHosts)) + genOpts := []GenerateConfigOption{WithEncryptionService(m.encSvc), WithRedirectionHosts(redirectHosts), WithRemoteHosts(remoteHosts)} + if m.hopSecret != nil { + genOpts = append(genOpts, WithSelfHop(m.hopSecret, m.hopPort)) + } + + generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, genOpts...) if err != nil { return fmt.Errorf("generate config: %w", err) } @@ -568,6 +586,8 @@ func (m *Manager) saveSnapshot(conf *Config) (string, error) { return "", fmt.Errorf("marshal config: %w", err) } + configJSON = m.redactHopSecret(configJSON) + if err := writeFileFunc(path, configJSON, 0o644); err != nil { return "", fmt.Errorf("write snapshot: %w", err) } @@ -589,6 +609,8 @@ func (m *Manager) rollback(ctx context.Context) error { return fmt.Errorf("read snapshot: %w", err) } + configJSON = m.restoreHopSecret(configJSON) + var conf Config if err := json.Unmarshal(configJSON, &conf); err != nil { return fmt.Errorf("unmarshal snapshot: %w", err) diff --git a/backend/internal/caddy/self_upstream.go b/backend/internal/caddy/self_upstream.go new file mode 100644 index 000000000..22d02018a --- /dev/null +++ b/backend/internal/caddy/self_upstream.go @@ -0,0 +1,115 @@ +package caddy + +import ( + "bytes" + "net" + "strconv" + "strings" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/security/selfhop" +) + +// selfHopConfig carries what GenerateConfig needs to mark requests whose +// upstream is Charon's own API. +type selfHopConfig struct { + secret *selfhop.Secret + // port is the port the API listens on. + port string + local *selfhop.LocalAddrs +} + +// isSelfUpstream reports whether dial points at this process's own API: a host +// naming this machine on the API's listen port. Upstreams marked remote (for +// example agent-resolved targets) never qualify. +func (s *selfHopConfig) isSelfUpstream(dial string, remote bool) bool { + if s == nil || s.secret == nil || remote { + return false + } + host, port, err := net.SplitHostPort(dial) + if err != nil { + return false + } + if p, err := strconv.Atoi(port); err != nil || strconv.Itoa(p) != s.port { + return false + } + return s.local.IsLocalHost(host) +} + +// selfHopHeaders are the request headers set on a self upstream. They are set +// (never added), so a client-supplied value cannot survive. The client address +// is the connecting peer as the proxy sees it; scheme and host are included so +// the API reads them from a proxy-set source. +func (s *selfHopConfig) selfHopHeaders() map[string][]string { + return map[string][]string{ + selfhop.HeaderSecret: {s.secret.Reveal()}, + selfhop.HeaderClient: {selfhop.ClientPlaceholder}, + "X-Forwarded-Proto": {"{http.request.scheme}"}, + "X-Forwarded-Host": {"{http.request.host}"}, + } +} + +// proxyHandler builds the reverse_proxy handler for dial, adding the hop headers +// only when the upstream is Charon itself. +func (o *generateConfigOptions) proxyHandler(dial string, remote, enableWS bool, application string, enableStandardHeaders bool) Handler { + h := ReverseProxyHandler(dial, enableWS, application, enableStandardHeaders) + if !o.selfHop.isSelfUpstream(dial, remote) { + return h + } + headers, _ := h["headers"].(map[string]any) + if headers == nil { + headers = make(map[string]any) + h["headers"] = headers + } + request, _ := headers["request"].(map[string]any) + if request == nil { + request = make(map[string]any) + headers["request"] = request + } + set, _ := request["set"].(map[string][]string) + if set == nil { + set = make(map[string][]string) + request["set"] = set + } + for k, v := range o.selfHop.selfHopHeaders() { + set[k] = v + } + return h +} + +// snapshotSecretPlaceholder stands in for the hop secret in this manager's snapshots. +const snapshotSecretPlaceholder = "CHARON_SELF_HOP_SECRET_REDACTED" //nolint:gosec // marker text, not a credential + +// orthrusHostUUIDs returns the UUIDs of hosts whose upstream is an agent-resolved +// target. It must be called before the targets are rewritten to addresses. +func orthrusHostUUIDs(hosts []models.ProxyHost) map[string]struct{} { + var out map[string]struct{} + for _, h := range hosts { + if strings.HasPrefix(h.ForwardHost, orthrusHostPrefix) { + if out == nil { + out = make(map[string]struct{}) + } + out[h.UUID] = struct{}{} + } + } + return out +} + +// redactHopSecret replaces the live secret in a serialized snapshot. The secret +// changes at every restart, so a stored value would only go stale. +func (m *Manager) redactHopSecret(configJSON []byte) []byte { + secret := m.hopSecret.Reveal() + if secret == "" { + return configJSON + } + return bytes.ReplaceAll(configJSON, []byte(secret), []byte(snapshotSecretPlaceholder)) +} + +// restoreHopSecret puts the live secret back into a snapshot read from disk. +func (m *Manager) restoreHopSecret(configJSON []byte) []byte { + secret := m.hopSecret.Reveal() + if secret == "" { + return configJSON + } + return bytes.ReplaceAll(configJSON, []byte(snapshotSecretPlaceholder), []byte(secret)) +} diff --git a/backend/internal/caddy/self_upstream_test.go b/backend/internal/caddy/self_upstream_test.go new file mode 100644 index 000000000..370fd53d1 --- /dev/null +++ b/backend/internal/caddy/self_upstream_test.go @@ -0,0 +1,315 @@ +package caddy + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/security/selfhop" +) + +func testHopSecret(t *testing.T) *selfhop.Secret { + t.Helper() + s, err := selfhop.NewSecret() + require.NoError(t, err) + return s +} + +// proxyUse is one reverse_proxy handler found in a generated config. +type proxyUse struct { + dial string + set map[string][]string +} + +func collectProxies(t *testing.T, cfg *Config) []proxyUse { + t.Helper() + raw, err := json.Marshal(cfg) + require.NoError(t, err) + var doc any + require.NoError(t, json.Unmarshal(raw, &doc)) + + var out []proxyUse + var walk func(v any) + walk = func(v any) { + switch x := v.(type) { + case map[string]any: + if x["handler"] == "reverse_proxy" { + use := proxyUse{set: map[string][]string{}} + if ups, ok := x["upstreams"].([]any); ok && len(ups) > 0 { + use.dial, _ = ups[0].(map[string]any)["dial"].(string) + } + if hdrs, ok := x["headers"].(map[string]any); ok { + if req, ok := hdrs["request"].(map[string]any); ok { + if set, ok := req["set"].(map[string]any); ok { + for k, vals := range set { + for _, val := range vals.([]any) { + use.set[k] = append(use.set[k], val.(string)) + } + } + } + } + } + out = append(out, use) + } + for _, child := range x { + walk(child) + } + case []any: + for _, child := range x { + walk(child) + } + } + } + walk(doc) + return out +} + +func generateWithOpts(t *testing.T, hosts []models.ProxyHost, opts ...GenerateConfigOption) *Config { + t.Helper() + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil, opts...) + require.NoError(t, err) + return cfg +} + +func fixtureHosts() []models.ProxyHost { + disabled := false + return []models.ProxyHost{ + { + UUID: "u-self", DomainNames: "charon.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + EnableStandardHeaders: &disabled, + Locations: []models.Location{ + {Path: "/api-local", ForwardHost: "localhost", ForwardPort: 8080}, + {Path: "/other", ForwardHost: "10.0.0.9", ForwardPort: 9000}, + }, + }, + {UUID: "u-third", DomainNames: "app.example.com", ForwardHost: "10.0.0.5", ForwardPort: 3000, Enabled: true}, + {UUID: "u-sameip-otherport", DomainNames: "other.example.com", ForwardHost: "127.0.0.1", ForwardPort: 9090, Enabled: true}, + {UUID: "u-remote", DomainNames: "remote.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}, + } +} + +func TestGenerateConfig_SelfHopHeadersOnlyOnSelfUpstream(t *testing.T) { + secret := testHopSecret(t) + cfg := generateWithOpts(t, fixtureHosts(), + WithSelfHop(secret, "8080"), + WithRemoteHosts(map[string]struct{}{"u-remote": {}}), + ) + + var selfCount, otherCount int + for _, p := range collectProxies(t, cfg) { + _, hasSecret := p.set[selfhop.HeaderSecret] + switch p.dial { + case "127.0.0.1:8080", "localhost:8080": + // u-self (main + emergency + location) and u-remote (never self). + if hasSecret { + selfCount++ + assert.Equal(t, []string{secret.Reveal()}, p.set[selfhop.HeaderSecret]) + assert.Equal(t, []string{selfhop.ClientPlaceholder}, p.set[selfhop.HeaderClient]) + assert.Equal(t, []string{"{http.request.scheme}"}, p.set["X-Forwarded-Proto"]) + assert.Equal(t, []string{"{http.request.host}"}, p.set["X-Forwarded-Host"]) + assert.NotContains(t, p.set, "Add") + } + default: + otherCount++ + assert.False(t, hasSecret, "dial %s must not carry hop headers", p.dial) + assert.NotContains(t, p.set, selfhop.HeaderClient) + } + } + // main route, emergency route and one self location of u-self. + assert.Equal(t, 3, selfCount) + assert.Positive(t, otherCount) + + // The remote host dials the same address:port but never receives proof. + raw, err := json.Marshal(cfg) + require.NoError(t, err) + assert.Equal(t, 3, strings.Count(string(raw), secret.Reveal())) +} + +func TestGenerateConfig_WithoutSelfHopIsUnchanged(t *testing.T) { + baseline := generateWithOpts(t, fixtureHosts()) + withRemote := generateWithOpts(t, fixtureHosts(), WithRemoteHosts(map[string]struct{}{"u-remote": {}})) + + a, err := json.Marshal(baseline) + require.NoError(t, err) + b, err := json.Marshal(withRemote) + require.NoError(t, err) + assert.Equal(t, string(a), string(b)) + assert.NotContains(t, string(a), selfhop.HeaderSecret) + assert.NotContains(t, string(a), selfhop.HeaderClient) +} + +func TestGenerateConfig_NonSelfRoutesIdenticalWithSelfHopEnabled(t *testing.T) { + nonSelf := []models.ProxyHost{ + {UUID: "u-third", DomainNames: "app.example.com", ForwardHost: "10.0.0.5", ForwardPort: 3000, Enabled: true}, + {UUID: "u-port", DomainNames: "other.example.com", ForwardHost: "127.0.0.1", ForwardPort: 9090, Enabled: true}, + {UUID: "u-name", DomainNames: "named.example.com", ForwardHost: "media", ForwardPort: 8080, Enabled: true}, + } + a, err := json.Marshal(generateWithOpts(t, nonSelf)) + require.NoError(t, err) + b, err := json.Marshal(generateWithOpts(t, nonSelf, WithSelfHop(testHopSecret(t), "8080"))) + require.NoError(t, err) + assert.Equal(t, string(a), string(b)) +} + +func TestSelfHopConfig_IsSelfUpstream(t *testing.T) { + t.Setenv("HOSTNAME", "charon-box") + local := selfhop.NewLocalAddrs() + cfg := &selfHopConfig{secret: testHopSecret(t), port: "8080", local: local} + + tests := []struct { + dial string + remote bool + want bool + }{ + {"127.0.0.1:8080", false, true}, + {"localhost:8080", false, true}, + {"[::1]:8080", false, true}, + {"0.0.0.0:8080", false, true}, + {"charon-box:8080", false, true}, + {"127.0.0.1:08080", false, true}, + {"127.0.0.1:8081", false, false}, + {"127.0.0.1:80", false, false}, + {"10.99.99.99:8080", false, false}, + {"example.com:8080", false, false}, + {"127.0.0.1:8080", true, false}, + {"127.0.0.1", false, false}, + {"127.0.0.1:abc", false, false}, + {"", false, false}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, cfg.isSelfUpstream(tc.dial, tc.remote), "dial=%q remote=%v", tc.dial, tc.remote) + } + + var nilCfg *selfHopConfig + assert.False(t, nilCfg.isSelfUpstream("127.0.0.1:8080", false)) + assert.False(t, (&selfHopConfig{port: "8080", local: local}).isSelfUpstream("127.0.0.1:8080", false)) +} + +func TestSelfHopConfig_OwnInterfaceAddressIsSelf(t *testing.T) { + addrs, err := net.InterfaceAddrs() + require.NoError(t, err) + var own string + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil && !ipn.IP.IsLoopback() { + own = ipn.IP.String() + break + } + } + if own == "" { + t.Skip("no non-loopback IPv4 interface address") + } + cfg := &selfHopConfig{secret: testHopSecret(t), port: "8080", local: selfhop.NewLocalAddrs()} + assert.True(t, cfg.isSelfUpstream(own+":8080", false)) +} + +func TestOrthrusHostUUIDs(t *testing.T) { + assert.Nil(t, orthrusHostUUIDs([]models.ProxyHost{{UUID: "a", ForwardHost: "10.0.0.1"}})) + got := orthrusHostUUIDs([]models.ProxyHost{ + {UUID: "a", ForwardHost: "orthrus:agent-1"}, + {UUID: "b", ForwardHost: "10.0.0.1"}, + }) + assert.Equal(t, map[string]struct{}{"a": {}}, got) +} + +type captureClient struct { + loaded []*Config +} + +func (c *captureClient) Load(_ context.Context, cfg *Config) error { + c.loaded = append(c.loaded, cfg) + return nil +} +func (c *captureClient) Ping(context.Context) error { return nil } +func (c *captureClient) GetConfig(context.Context) (*Config, error) { return &Config{}, nil } + +type stubOrthrus struct{ addr string } + +func (s stubOrthrus) GetProxyAddr(string) (string, bool) { return s.addr, true } + +func newHopManager(t *testing.T) (*Manager, *captureClient, *gorm.DB, string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) + dir := t.TempDir() + client := &captureClient{} + return NewManager(client, db, dir, "", false, config.SecurityConfig{}), client, db, dir +} + +func TestManager_ApplyConfig_SelfHopWiringAndSnapshotRedaction(t *testing.T) { + m, client, db, dir := newHopManager(t) + secret := testHopSecret(t) + m.SetSelfHop(secret, "8080") + m.SetOrthrusServer(stubOrthrus{addr: "127.0.0.1:8080"}) + require.NoError(t, db.Create(&models.ProxyHost{UUID: "u-self", DomainNames: "charon.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}).Error) + require.NoError(t, db.Create(&models.ProxyHost{UUID: "u-agent", DomainNames: "agent.example.com", ForwardHost: "orthrus:agent-1", ForwardPort: 1, Enabled: true}).Error) + + require.NoError(t, m.ApplyConfig(context.Background())) + require.Len(t, client.loaded, 1) + + // Live config: the self route carries the proof, the agent route (resolved to + // the same address) does not. + withProof, without := 0, 0 + for _, p := range collectProxies(t, client.loaded[0]) { + if p.dial != "127.0.0.1:8080" { + continue + } + if len(p.set[selfhop.HeaderSecret]) == 1 && p.set[selfhop.HeaderSecret][0] == secret.Reveal() { + withProof++ + } else { + without++ + } + } + assert.Equal(t, 2, withProof) // main + emergency route of the self host + assert.Equal(t, 2, without) // main + emergency route of the agent host + + // Snapshots on disk never hold the secret. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + snapshots := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) != ".json" { + continue + } + snapshots++ + body, readErr := os.ReadFile(filepath.Join(dir, e.Name())) + require.NoError(t, readErr) + assert.NotContains(t, string(body), secret.Reveal()) + assert.Contains(t, string(body), snapshotSecretPlaceholder) + } + assert.Equal(t, 1, snapshots) + + // Rolling back restores the live secret. + require.NoError(t, m.rollback(context.Background())) + require.Len(t, client.loaded, 2) + raw, err := json.Marshal(client.loaded[1]) + require.NoError(t, err) + assert.Contains(t, string(raw), secret.Reveal()) + assert.NotContains(t, string(raw), snapshotSecretPlaceholder) +} + +func TestManager_ApplyConfig_WithoutSelfHopAddsNoProof(t *testing.T) { + m, client, db, _ := newHopManager(t) + require.NoError(t, db.Create(&models.ProxyHost{UUID: "u-self", DomainNames: "charon.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}).Error) + + require.NoError(t, m.ApplyConfig(context.Background())) + raw, err := json.Marshal(client.loaded[0]) + require.NoError(t, err) + assert.NotContains(t, string(raw), selfhop.HeaderSecret) + + // Redaction helpers are no-ops without a secret. + assert.Equal(t, []byte("x"), m.redactHopSecret([]byte("x"))) + assert.Equal(t, []byte(snapshotSecretPlaceholder), m.restoreHopSecret([]byte(snapshotSecretPlaceholder))) +} From 58d5b30dc6333d02f7cb309989fbeb7b187db820 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 05:53:07 +0000 Subject: [PATCH 05/57] fix(security): harden account management in the API layer Tighten credential handling and session lifecycle in the authentication service and user handlers, with accompanying tests. --- .../api/handlers/account_credentials_test.go | 240 ++++++++++ backend/internal/api/handlers/auth_handler.go | 15 +- backend/internal/api/handlers/user_handler.go | 22 +- .../routes/password_guard_inventory_test.go | 3 +- backend/internal/services/auth_service.go | 172 ++++++- .../services/auth_service_login_test.go | 444 ++++++++++++++++++ .../internal/services/auth_service_test.go | 3 +- 7 files changed, 873 insertions(+), 26 deletions(-) create mode 100644 backend/internal/api/handlers/account_credentials_test.go create mode 100644 backend/internal/services/auth_service_login_test.go diff --git a/backend/internal/api/handlers/account_credentials_test.go b/backend/internal/api/handlers/account_credentials_test.go new file mode 100644 index 000000000..709f430fc --- /dev/null +++ b/backend/internal/api/handlers/account_credentials_test.go @@ -0,0 +1,240 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "sync/atomic" + "testing" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type denyGuard struct{ calls int } + +func (g *denyGuard) AllowPasswordAttempt(c *gin.Context) bool { + g.calls++ + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts"}) + return false +} + +type credentialsEnv struct { + r *gin.Engine + db *gorm.DB + auth *services.AuthService +} + +// newCredentialsEnv wires the real authentication middleware, service and handlers. +func newCredentialsEnv(t *testing.T) *credentialsEnv { + t.Helper() + gin.SetMode(gin.TestMode) + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})) + auth := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"}) + authHandler := NewAuthHandler(auth, nil) + userHandler := NewUserHandler(db, auth) + + r := gin.New() + r.POST("/auth/login", authHandler.Login) + protected := r.Group("/", middleware.AuthMiddleware(auth)) + protected.POST("/auth/change-password", authHandler.ChangePassword) + protected.PUT("/users/:id", userHandler.UpdateUser) + return &credentialsEnv{r: r, db: db, auth: auth} +} + +func (e *credentialsEnv) do(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(method, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + e.r.ServeHTTP(w, req) + return w +} + +func (e *credentialsEnv) user(t *testing.T, email string, admin bool) (*models.User, string) { + t.Helper() + u, err := e.auth.Register(email, "password123", "User") + require.NoError(t, err) + if admin { + require.NoError(t, e.db.Model(u).Update("role", models.RoleAdmin).Error) + } else { + require.NoError(t, e.db.Model(u).Update("role", models.RoleUser).Error) + } + token, err := e.auth.Login(email, "password123") + require.NoError(t, err) + return u, token +} + +func (e *credentialsEnv) status(t *testing.T, token string) int { + t.Helper() + return e.do(t, http.MethodPut, "/users/0", token, map[string]any{}).Code +} + +func TestLoginHandler_UniformFailureResponse(t *testing.T) { + e := newCredentialsEnv(t) + e.user(t, "known@example.com", true) + + bodies := make([]string, 0, 2) + for _, email := range []string{"known@example.com", "unknown@example.com"} { + w := e.do(t, http.MethodPost, "/auth/login", "", map[string]string{"email": email, "password": "wrong-password"}) + assert.Equal(t, http.StatusUnauthorized, w.Code) + bodies = append(bodies, w.Body.String()) + } + assert.Equal(t, bodies[0], bodies[1]) + assert.JSONEq(t, `{"error":"invalid credentials"}`, bodies[0]) +} + +func TestLoginHandler_InternalErrorReportedAsUnavailable(t *testing.T) { + e := newCredentialsEnv(t) + sqlDB, err := e.db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + w := e.do(t, http.MethodPost, "/auth/login", "", map[string]string{"email": "a@example.com", "password": "password123"}) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.JSONEq(t, `{"error":"login unavailable"}`, w.Body.String()) +} + +func TestChangePasswordHandler_KeepsCallerSignedInAndEndsOtherSessions(t *testing.T) { + e := newCredentialsEnv(t) + _, oldToken := e.user(t, "self@example.com", true) + require.NotEqual(t, http.StatusUnauthorized, e.status(t, oldToken)) + + w := e.do(t, http.MethodPost, "/auth/change-password", oldToken, map[string]string{ + "old_password": "password123", "new_password": "another-password", + }) + require.Equal(t, http.StatusOK, w.Code) + + var fresh string + for _, ck := range w.Result().Cookies() { + if ck.Name == "auth_token" { + fresh = ck.Value + } + } + require.NotEmpty(t, fresh, "a fresh session cookie must be issued") + assert.NotEqual(t, http.StatusUnauthorized, e.status(t, fresh)) + assert.Equal(t, http.StatusUnauthorized, e.status(t, oldToken)) +} + +func TestUpdateUser_OwnPasswordRequiresCurrentPassword(t *testing.T) { + cases := map[string]struct { + current string + code int + changed bool + }{ + "missing": {"", http.StatusBadRequest, false}, + "incorrect": {"not-the-password", http.StatusUnauthorized, false}, + "correct": {"password123", http.StatusOK, true}, + } + for name, tc := range cases { + for _, admin := range []bool{false, true} { + t.Run(fmt.Sprintf("%s_admin_%t", name, admin), func(t *testing.T) { + e := newCredentialsEnv(t) + u, token := e.user(t, "self@example.com", admin) + + body := map[string]any{"password": "brand-new-password"} + if tc.current != "" { + body["current_password"] = tc.current + } + w := e.do(t, http.MethodPut, "/users/"+strconv.FormatUint(uint64(u.ID), 10), token, body) + assert.Equal(t, tc.code, w.Code, w.Body.String()) + + var after models.User + require.NoError(t, e.db.First(&after, u.ID).Error) + assert.Equal(t, tc.changed, after.CheckPassword("brand-new-password")) + if tc.changed { + assert.Equal(t, u.SessionVersion+1, after.SessionVersion) + assert.Equal(t, http.StatusUnauthorized, e.status(t, token), "prior sessions end") + } else { + assert.Equal(t, u.SessionVersion, after.SessionVersion) + } + }) + } + } +} + +func TestUpdateUser_OwnPasswordChangeIsThrottled(t *testing.T) { + e := newCredentialsEnv(t) + guard := &denyGuard{} + // Rebuild the route with a guard attached. + userHandler := NewUserHandler(e.db, e.auth) + userHandler.SetPasswordAttemptGuard(guard) + r := gin.New() + r.PUT("/users/:id", middleware.AuthMiddleware(e.auth), userHandler.UpdateUser) + e.r = r + + u, token := e.user(t, "self@example.com", false) + w := e.do(t, http.MethodPut, "/users/"+strconv.FormatUint(uint64(u.ID), 10), token, map[string]any{ + "password": "brand-new-password", "current_password": "password123", + }) + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, 1, guard.calls) +} + +func TestUpdateUser_AdminResetOfAnotherUserEndsTheirSessions(t *testing.T) { + e := newCredentialsEnv(t) + _, adminToken := e.user(t, "admin@example.com", true) + target, targetToken := e.user(t, "target@example.com", false) + require.NoError(t, e.db.Model(target).Updates(map[string]any{"failed_login_attempts": 3}).Error) + + w := e.do(t, http.MethodPut, "/users/"+strconv.FormatUint(uint64(target.ID), 10), adminToken, map[string]any{"password": "reset-password-1"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + assert.Equal(t, http.StatusUnauthorized, e.status(t, targetToken)) + assert.NotEqual(t, http.StatusUnauthorized, e.status(t, adminToken), "admin session is unaffected") + + var after models.User + require.NoError(t, e.db.First(&after, target.ID).Error) + assert.True(t, after.CheckPassword("reset-password-1")) + assert.Equal(t, 0, after.FailedLoginAttempts) + assert.Equal(t, target.SessionVersion+1, after.SessionVersion) +} + +func TestUpdateUser_NonPasswordChangesDoNotRequireCurrentPassword(t *testing.T) { + e := newCredentialsEnv(t) + u, token := e.user(t, "self@example.com", false) + w := e.do(t, http.MethodPut, "/users/"+strconv.FormatUint(uint64(u.ID), 10), token, map[string]any{"name": "New Name"}) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var after models.User + require.NoError(t, e.db.First(&after, u.ID).Error) + assert.Equal(t, "New Name", after.Name) + assert.Equal(t, u.SessionVersion, after.SessionVersion) +} + +func TestChangePasswordHandler_SessionRefreshFailureIsReported(t *testing.T) { + e := newCredentialsEnv(t) + _, token := e.user(t, "self@example.com", true) + + // After the password update succeeds, make every further lookup fail. + var failLookups atomic.Bool + require.NoError(t, e.db.Callback().Update().After("gorm:update").Register("test:arm", func(*gorm.DB) { + failLookups.Store(true) + })) + require.NoError(t, e.db.Callback().Query().Before("gorm:query").Register("test:fail", func(tx *gorm.DB) { + if failLookups.Load() { + _ = tx.AddError(errors.New("lookup unavailable")) + } + })) + + w := e.do(t, http.MethodPost, "/auth/change-password", token, map[string]string{ + "old_password": "password123", "new_password": "another-password", + }) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 46569f5b7..587329a33 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "net" "net/http" "strconv" @@ -214,7 +215,11 @@ func (h *AuthHandler) Login(c *gin.Context) { token, err := h.authService.Login(req.Email, req.Password) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + if errors.Is(err, services.ErrInvalidLogin) { + c.JSON(http.StatusUnauthorized, gin.H{"error": services.ErrInvalidLogin.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": services.ErrLoginUnavailable.Error()}) return } @@ -319,6 +324,14 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { return } + // Other sessions ended with the password change; keep the caller signed in. + token, err := h.authService.TokenForUser(userID.(uint)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh session"}) + return + } + setSecureCookie(c, "auth_token", token, 3600*24, h.trustedProxies) + c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) } diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 8c3a1482d..a027ab326 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -739,8 +739,10 @@ type UpdateUserRequest struct { Name string `json:"name"` Email string `json:"email"` Password *string `json:"password" binding:"omitempty,min=8"` - Role string `json:"role"` - Enabled *bool `json:"enabled"` + // CurrentPassword must accompany a password change on the caller's own account. + CurrentPassword string `json:"current_password"` + Role string `json:"role"` + Enabled *bool `json:"enabled"` } // UpdateUser updates an existing user (admin only for management fields, self-service for name/password). @@ -834,6 +836,20 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { } if req.Password != nil { + // Changing one's own password requires proof of the current one. + if isSelf { + if req.CurrentPassword == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change your password"}) + return + } + if !allowPasswordAttempt(h.passwordGuard, c) { + return + } + if !user.CheckPassword(req.CurrentPassword) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) + return + } + } if hashErr := user.SetPassword(*req.Password); hashErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return @@ -841,6 +857,8 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { updates["password_hash"] = user.PasswordHash updates["failed_login_attempts"] = 0 updates["locked_until"] = nil + // Advance the session version atomically with the new hash so existing sessions end. + updates["session_version"] = gorm.Expr("session_version + 1") } if req.Enabled != nil && *req.Enabled != user.Enabled { diff --git a/backend/internal/api/routes/password_guard_inventory_test.go b/backend/internal/api/routes/password_guard_inventory_test.go index 1a546c853..8555a5971 100644 --- a/backend/internal/api/routes/password_guard_inventory_test.go +++ b/backend/internal/api/routes/password_guard_inventory_test.go @@ -17,9 +17,10 @@ import ( // password, and whether it must call the sign-in throttle guard first. Login // and ChangePassword are throttled by the /api/v1/auth route group instead. var passwordCheckAllowlist = map[string]bool{ - "AuthService.Login": false, + "checkPasswordUniformCost": false, // used by AuthService.Login "AuthService.ChangePassword": false, "UserHandler.UpdateProfile": true, + "UserHandler.UpdateUser": true, "CertificateHandler.reauthenticateForKeyExport": true, } diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index eb15b03de..420d1ad35 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -2,13 +2,17 @@ package services import ( "errors" + "fmt" "strings" + "sync" "time" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -67,37 +71,126 @@ func (s *AuthService) Register(email, password, name string) (*models.User, erro return user, nil } +// Sign-in policy constants. +const ( + // MaxFailedLoginAttempts is the number of consecutive failed sign-ins that locks an account. + MaxFailedLoginAttempts = 5 + // LockDuration is how long an account stays locked once the threshold is reached. + LockDuration = 15 * time.Minute +) + +var ( + // ErrInvalidLogin is returned for every sign-in failure cause so callers + // present one uniform message. + ErrInvalidLogin = errors.New("invalid credentials") + // ErrLoginUnavailable is returned when sign-in cannot be evaluated because of an internal error. + ErrLoginUnavailable = errors.New("login unavailable") +) + +var ( + dummyHashOnce sync.Once + dummyHash []byte +) + +// placeholderHash returns a bcrypt hash used to keep password-check cost uniform +// when no stored hash applies. It is generated at bcrypt.DefaultCost, the same +// cost models.User.SetPassword uses; if stored hashes ever move to another cost, +// this must follow. +func placeholderHash() []byte { + dummyHashOnce.Do(func() { + h, err := bcrypt.GenerateFromPassword([]byte(uuid.New().String()), bcrypt.DefaultCost) + if err != nil { + panic(fmt.Errorf("generate placeholder hash: %w", err)) + } + dummyHash = h + }) + return dummyHash +} + +// failedLoginState is the row state returned by the atomic failure update. +type failedLoginState struct { + FailedLoginAttempts int + LockedUntil *time.Time +} + +// recordFailedLogin atomically records one failed attempt for an enabled, +// currently-unlocked account. The counter restarts at 1 once a previous lock has +// expired, and the lock is set in the same statement when the threshold is reached. +// Times are compared with julianday() so stored values in any offset or precision +// compare correctly. It returns the number of rows affected (0 when the account +// was locked or disabled in the meantime). +func (s *AuthService) recordFailedLogin(userID uint, now time.Time) (int64, error) { + now = now.UTC() + lockUntil := now.Add(LockDuration) + const q = `UPDATE users SET + failed_login_attempts = CASE WHEN locked_until IS NOT NULL AND julianday(locked_until) <= julianday(?) THEN 1 ELSE failed_login_attempts + 1 END, + locked_until = CASE WHEN (CASE WHEN locked_until IS NOT NULL AND julianday(locked_until) <= julianday(?) THEN 1 ELSE failed_login_attempts + 1 END) >= ? THEN ? ELSE NULL END + WHERE id = ? AND enabled = ? AND (locked_until IS NULL OR julianday(locked_until) <= julianday(?)) + RETURNING failed_login_attempts, locked_until` + var state failedLoginState + res := s.db.Raw(q, now, now, MaxFailedLoginAttempts, lockUntil, userID, true, now).Scan(&state) + if res.Error != nil { + return 0, fmt.Errorf("record failed login: %w", res.Error) + } + return res.RowsAffected, nil +} + +// checkPasswordUniformCost verifies the password against the stored hash. Accounts +// without a stored hash (for example, invitations not yet accepted) are compared +// against the placeholder hash instead and never match, so each call performs one +// full-cost comparison. +func checkPasswordUniformCost(user *models.User, password string) bool { + if user.PasswordHash == "" { + _ = bcrypt.CompareHashAndPassword(placeholderHash(), []byte(password)) + return false + } + return user.CheckPassword(password) +} + +// Login verifies credentials and returns a session token. All sign-in failures +// returns ErrInvalidLogin and performs exactly one bcrypt comparison. func (s *AuthService) Login(email, password string) (string, error) { email = strings.ToLower(email) var user models.User if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { - return "", errors.New("invalid credentials") + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = bcrypt.CompareHashAndPassword(placeholderHash(), []byte(password)) + return "", ErrInvalidLogin + } + logger.Log().WithError(err).Error("login: user lookup failed") + return "", ErrLoginUnavailable } - if !user.Enabled { - return "", errors.New("account disabled") - } + passwordOK := checkPasswordUniformCost(&user, password) + now := time.Now().UTC() - if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) { - return "", errors.New("account locked") + if !user.Enabled || (user.LockedUntil != nil && user.LockedUntil.After(now)) { + return "", ErrInvalidLogin } - if !user.CheckPassword(password) { - user.FailedLoginAttempts++ - if user.FailedLoginAttempts >= 5 { - lockTime := time.Now().Add(15 * time.Minute) - user.LockedUntil = &lockTime + if !passwordOK { + if _, err := s.recordFailedLogin(user.ID, now); err != nil { + logger.Log().WithError(err).Error("login: failed to record attempt") + return "", ErrLoginUnavailable } - s.db.Save(&user) - return "", errors.New("invalid credentials") + // Zero rows means the account was locked or disabled concurrently. + return "", ErrInvalidLogin } - // Reset failed attempts - user.FailedLoginAttempts = 0 - user.LockedUntil = nil - now := time.Now() - user.LastLogin = &now - s.db.Save(&user) + res := s.db.Model(&models.User{}). + Where("id = ? AND enabled = ?", user.ID, true). + Updates(map[string]any{ + "failed_login_attempts": 0, + "locked_until": nil, + "last_login": now, + }) + if res.Error != nil { + logger.Log().WithError(res.Error).Error("login: failed to record success") + return "", ErrLoginUnavailable + } + if res.RowsAffected == 0 { + return "", ErrInvalidLogin + } return s.GenerateToken(&user) } @@ -118,6 +211,10 @@ func (s *AuthService) GenerateToken(user *models.User) (string, error) { return token.SignedString([]byte(s.config.JWTSecret)) } +// ChangePassword verifies the current password and stores the new one. In the same +// transaction it advances the session version (ending other sessions) and clears +// failed-attempt and lock state. Callers that keep the user signed in must issue a +// fresh token afterwards. func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { var user models.User if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { @@ -129,10 +226,34 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin } if err := user.SetPassword(newPassword); err != nil { - return err + return fmt.Errorf("hash password: %w", err) } - return s.db.Save(&user).Error + if err := s.db.Transaction(func(tx *gorm.DB) error { + return applyPasswordChange(tx, userID, user.PasswordHash) + }); err != nil { + return fmt.Errorf("change password: %w", err) + } + return nil +} + +// applyPasswordChange stores a new password hash for the user and, in the same +// statement, advances the session version and clears failed-attempt and lock state. +// Every password change (self-service or administrative) goes through it. +func applyPasswordChange(tx *gorm.DB, userID uint, passwordHash string) error { + res := tx.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]any{ + "password_hash": passwordHash, + "session_version": gorm.Expr("session_version + 1"), + "failed_login_attempts": 0, + "locked_until": nil, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("user not found") + } + return nil } func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { @@ -192,3 +313,12 @@ func (s *AuthService) GetUserByID(id uint) (*models.User, error) { } return &user, nil } + +// TokenForUser issues a session token reflecting the user's current session version. +func (s *AuthService) TokenForUser(userID uint) (string, error) { + user, err := s.GetUserByID(userID) + if err != nil { + return "", fmt.Errorf("load user: %w", err) + } + return s.GenerateToken(user) +} diff --git a/backend/internal/services/auth_service_login_test.go b/backend/internal/services/auth_service_login_test.go new file mode 100644 index 000000000..e198bbd80 --- /dev/null +++ b/backend/internal/services/auth_service_login_test.go @@ -0,0 +1,444 @@ +package services + +import ( + "path/filepath" + "sort" + "sync" + "testing" + "time" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +const loginTestPassword = "password123" + +// setupLoginTestDB opens a file-backed database with the same SQLite driver the +// application uses. +func setupLoginTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := "file:" + filepath.Join(t.TempDir(), "login.db") + "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.User{})) + t.Cleanup(func() { + if sqlDB, e := db.DB(); e == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func newLoginTestService(t *testing.T) (*AuthService, *gorm.DB, *models.User) { + t.Helper() + db := setupLoginTestDB(t) + svc := NewAuthService(db, config.Config{JWTSecret: "test-secret"}) + user, err := svc.Register("user@example.com", loginTestPassword, "User") + require.NoError(t, err) + return svc, db, user +} + +func reloadUser(t *testing.T, db *gorm.DB, id uint) models.User { + t.Helper() + var u models.User + require.NoError(t, db.First(&u, id).Error) + return u +} + +func wrongLogins(t *testing.T, svc *AuthService, n int) { + t.Helper() + for i := 0; i < n; i++ { + _, err := svc.Login("user@example.com", "wrong-password") + require.ErrorIs(t, err, ErrInvalidLogin) + } +} + +func TestLogin_UniformFailureResponse(t *testing.T) { + svc, db, user := newLoginTestService(t) + + disabled, err := svc.Register("disabled@example.com", loginTestPassword, "Disabled") + require.NoError(t, err) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", disabled.ID).Update("enabled", false).Error) + + locked, err := svc.Register("locked@example.com", loginTestPassword, "Locked") + require.NoError(t, err) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", locked.ID). + Update("locked_until", time.Now().UTC().Add(time.Hour)).Error) + + cases := map[string]struct{ email, password string }{ + "unknown account": {"nobody@example.com", loginTestPassword}, + "wrong password": {user.Email, "wrong-password"}, + "disabled account": {"disabled@example.com", loginTestPassword}, + "locked correct pw": {"locked@example.com", loginTestPassword}, + "locked wrong pw": {"locked@example.com", "wrong-password"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + token, err := svc.Login(tc.email, tc.password) + assert.Empty(t, token) + require.ErrorIs(t, err, ErrInvalidLogin) + assert.Equal(t, "invalid credentials", err.Error()) + }) + } +} + +func TestLogin_UnknownAccountRunsPasswordCheck(t *testing.T) { + if testing.Short() { + t.Skip("duration comparison skipped in short mode") + } + svc, _, _ := newLoginTestService(t) + placeholderHash() // generate outside the measured section + + median := func(email string) time.Duration { + const n = 5 + d := make([]time.Duration, 0, n) + for i := 0; i < n; i++ { + start := time.Now() + _, _ = svc.Login(email, "wrong-password") + d = append(d, time.Since(start)) + } + sort.Slice(d, func(i, j int) bool { return d[i] < d[j] }) + return d[n/2] + } + // Use a separate account for the wrong-password path so the first account's + // lock state does not influence the comparison. + _, err := svc.Register("second@example.com", loginTestPassword, "Second") + require.NoError(t, err) + + known := median("second@example.com") + unknown := median("nobody@example.com") + // The unknown path skips one small database write; it must still be dominated + // by a bcrypt comparison. + assert.GreaterOrEqual(t, float64(unknown), 0.5*float64(known), + "unknown=%v known=%v", unknown, known) +} + +func TestPlaceholderHash_MatchesStoredHashCost(t *testing.T) { + var u models.User + require.NoError(t, u.SetPassword("anything")) + stored, err := bcrypt.Cost([]byte(u.PasswordHash)) + require.NoError(t, err) + dummy, err := bcrypt.Cost(placeholderHash()) + require.NoError(t, err) + assert.Equal(t, stored, dummy) +} + +func TestLogin_LockAfterMaxAttempts(t *testing.T) { + svc, db, user := newLoginTestService(t) + + wrongLogins(t, svc, MaxFailedLoginAttempts-1) + u := reloadUser(t, db, user.ID) + assert.Equal(t, MaxFailedLoginAttempts-1, u.FailedLoginAttempts) + assert.Nil(t, u.LockedUntil) + + wrongLogins(t, svc, 1) + u = reloadUser(t, db, user.ID) + assert.Equal(t, MaxFailedLoginAttempts, u.FailedLoginAttempts) + require.NotNil(t, u.LockedUntil) + assert.WithinDuration(t, time.Now().Add(LockDuration), *u.LockedUntil, 10*time.Second) + + // Correct password during the lock still fails and changes nothing. + _, err := svc.Login(user.Email, loginTestPassword) + require.ErrorIs(t, err, ErrInvalidLogin) + after := reloadUser(t, db, user.ID) + assert.Equal(t, u.FailedLoginAttempts, after.FailedLoginAttempts) + assert.True(t, u.LockedUntil.Equal(*after.LockedUntil)) +} + +func TestLogin_AttemptsWhileLockedDoNotCountOrExtend(t *testing.T) { + svc, db, user := newLoginTestService(t) + wrongLogins(t, svc, MaxFailedLoginAttempts) + locked := reloadUser(t, db, user.ID) + require.NotNil(t, locked.LockedUntil) + + wrongLogins(t, svc, 3) + after := reloadUser(t, db, user.ID) + assert.Equal(t, locked.FailedLoginAttempts, after.FailedLoginAttempts) + assert.True(t, locked.LockedUntil.Equal(*after.LockedUntil), "lock must not be extended") +} + +func TestLogin_CounterRestartsAfterLockExpiry(t *testing.T) { + svc, db, user := newLoginTestService(t) + wrongLogins(t, svc, MaxFailedLoginAttempts) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID). + Update("locked_until", time.Now().UTC().Add(-time.Minute)).Error) + + wrongLogins(t, svc, 1) + u := reloadUser(t, db, user.ID) + assert.Equal(t, 1, u.FailedLoginAttempts) + assert.Nil(t, u.LockedUntil) + + wrongLogins(t, svc, MaxFailedLoginAttempts-2) + assert.Nil(t, reloadUser(t, db, user.ID).LockedUntil) + wrongLogins(t, svc, 1) + assert.NotNil(t, reloadUser(t, db, user.ID).LockedUntil) +} + +func TestLogin_LockTimeIsZoneIndependent(t *testing.T) { + zones := map[string]*time.Location{ + "ahead": time.FixedZone("ahead", 9*3600), + "behind": time.FixedZone("behind", -8*3600), + } + for name, loc := range zones { + t.Run(name+"/future lock is honored", func(t *testing.T) { + svc, db, user := newLoginTestService(t) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID). + Update("locked_until", time.Now().In(loc).Add(30*time.Minute)).Error) + _, err := svc.Login(user.Email, loginTestPassword) + require.ErrorIs(t, err, ErrInvalidLogin) + wrongLogins(t, svc, 2) + assert.Equal(t, 0, reloadUser(t, db, user.ID).FailedLoginAttempts) + }) + t.Run(name+"/past lock is expired", func(t *testing.T) { + svc, db, user := newLoginTestService(t) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{ + "locked_until": time.Now().In(loc).Add(-30 * time.Minute), + "failed_login_attempts": MaxFailedLoginAttempts, + }).Error) + wrongLogins(t, svc, 1) + u := reloadUser(t, db, user.ID) + assert.Equal(t, 1, u.FailedLoginAttempts) + assert.Nil(t, u.LockedUntil) + token, err := svc.Login(user.Email, loginTestPassword) + require.NoError(t, err) + assert.NotEmpty(t, token) + }) + } +} + +func TestLogin_LockIsStoredInUTC(t *testing.T) { + svc, db, user := newLoginTestService(t) + wrongLogins(t, svc, MaxFailedLoginAttempts) + var raw string + require.NoError(t, db.Raw("SELECT locked_until FROM users WHERE id = ?", user.ID).Scan(&raw).Error) + parsed, err := time.Parse(time.RFC3339Nano, raw) + require.NoError(t, err, "stored value %q", raw) + _, offset := parsed.Zone() + assert.Zero(t, offset, "stored value %q", raw) +} + +func TestLogin_SuccessResetsStateWithTargetedUpdate(t *testing.T) { + svc, db, user := newLoginTestService(t) + wrongLogins(t, svc, 2) + + // Concurrent changes to other columns must survive a successful sign-in. + require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID). + Update("name", "Renamed").Error) + + token, err := svc.Login(user.Email, loginTestPassword) + require.NoError(t, err) + assert.NotEmpty(t, token) + + u := reloadUser(t, db, user.ID) + assert.Equal(t, 0, u.FailedLoginAttempts) + assert.Nil(t, u.LockedUntil) + assert.NotNil(t, u.LastLogin) + assert.Equal(t, "Renamed", u.Name) +} + +func TestLogin_DisabledDuringSignInIsRefused(t *testing.T) { + svc, db, user := newLoginTestService(t) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).Update("enabled", false).Error) + + // Wrong-password failure update must not touch a disabled account. + rows, err := svc.recordFailedLogin(user.ID, time.Now()) + require.NoError(t, err) + assert.Zero(t, rows) + assert.Equal(t, 0, reloadUser(t, db, user.ID).FailedLoginAttempts) +} + +func TestLogin_DatabaseErrorIsReportedAsUnavailable(t *testing.T) { + svc, db, user := newLoginTestService(t) + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + _, err = svc.Login(user.Email, loginTestPassword) + require.ErrorIs(t, err, ErrLoginUnavailable) +} + +func TestLogin_FailureRecordingErrorIsReportedAsUnavailable(t *testing.T) { + svc, db, user := newLoginTestService(t) + // Break only the write path: drop the counter column. + require.NoError(t, db.Exec("ALTER TABLE users DROP COLUMN failed_login_attempts").Error) + + _, err := svc.Login(user.Email, "wrong-password") + require.ErrorIs(t, err, ErrLoginUnavailable) +} + +func TestLogin_SuccessRecordingErrorIsReportedAsUnavailable(t *testing.T) { + svc, db, user := newLoginTestService(t) + require.NoError(t, db.Exec("ALTER TABLE users DROP COLUMN last_login").Error) + + _, err := svc.Login(user.Email, loginTestPassword) + require.ErrorIs(t, err, ErrLoginUnavailable) +} + +func TestLogin_ReturningSupportedBySQLite(t *testing.T) { + db := setupLoginTestDB(t) + var version string + require.NoError(t, db.Raw("SELECT sqlite_version()").Scan(&version).Error) + t.Logf("sqlite %s", version) + + user := models.User{UUID: "u-1", Email: "r@example.com", Enabled: true} + require.NoError(t, db.Create(&user).Error) + var out failedLoginState + res := db.Raw("UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE id = ? RETURNING failed_login_attempts, locked_until", user.ID).Scan(&out) + require.NoError(t, res.Error) + assert.Equal(t, int64(1), res.RowsAffected) + assert.Equal(t, 1, out.FailedLoginAttempts) +} + +func TestLogin_ConcurrentFailuresAreCountedAtomically(t *testing.T) { + svc, db, user := newLoginTestService(t) + + const workers = 24 + var wg sync.WaitGroup + start := make(chan struct{}) + errs := make(chan error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := svc.Login(user.Email, "wrong-password") + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + require.ErrorIs(t, err, ErrInvalidLogin) + } + + u := reloadUser(t, db, user.ID) + // Attempts that raced past the lock check may not all be recorded, but the + // counter never exceeds the threshold and the account ends up locked. + assert.Equal(t, MaxFailedLoginAttempts, u.FailedLoginAttempts) + require.NotNil(t, u.LockedUntil) + assert.True(t, u.LockedUntil.After(time.Now())) +} + +func TestChangePassword_EndsOtherSessionsAndClearsLock(t *testing.T) { + svc, db, user := newLoginTestService(t) + oldToken, err := svc.Login(user.Email, loginTestPassword) + require.NoError(t, err) + wrongLogins(t, svc, MaxFailedLoginAttempts) + require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).Update("locked_until", nil).Error) + before := reloadUser(t, db, user.ID) + + require.NoError(t, svc.ChangePassword(user.ID, loginTestPassword, "new-password-1")) + + after := reloadUser(t, db, user.ID) + assert.Equal(t, before.SessionVersion+1, after.SessionVersion) + assert.Equal(t, 0, after.FailedLoginAttempts) + assert.Nil(t, after.LockedUntil) + + _, _, err = svc.AuthenticateToken(oldToken) + assert.Error(t, err, "sessions issued before the change must end") + + token, err := svc.Login(user.Email, "new-password-1") + require.NoError(t, err) + _, _, err = svc.AuthenticateToken(token) + assert.NoError(t, err) +} + +func TestChangePassword_WrongCurrentPasswordChangesNothing(t *testing.T) { + svc, db, user := newLoginTestService(t) + before := reloadUser(t, db, user.ID) + + require.Error(t, svc.ChangePassword(user.ID, "wrong", "new-password-1")) + + after := reloadUser(t, db, user.ID) + assert.Equal(t, before.PasswordHash, after.PasswordHash) + assert.Equal(t, before.SessionVersion, after.SessionVersion) +} + +func TestApplyPasswordChange_UnknownUser(t *testing.T) { + _, db, _ := newLoginTestService(t) + require.Error(t, applyPasswordChange(db, 9999, "hash")) +} + +func TestLogin_AccountDisabledDuringSignInIsRefused(t *testing.T) { + for _, password := range []string{loginTestPassword, "wrong-password"} { + svc, db, user := newLoginTestService(t) + // Disable the account right after the sign-in loads it. + require.NoError(t, db.Callback().Query().After("gorm:query").Register("test:disable", func(tx *gorm.DB) { + _ = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE users SET enabled = ?", false).Error + })) + + token, err := svc.Login(user.Email, password) + assert.Empty(t, token) + require.ErrorIs(t, err, ErrInvalidLogin) + require.NoError(t, db.Callback().Query().Remove("test:disable")) + } +} + +func TestChangePassword_RejectsUnhashablePassword(t *testing.T) { + svc, db, user := newLoginTestService(t) + before := reloadUser(t, db, user.ID) + tooLong := string(make([]byte, 80)) // bcrypt rejects inputs beyond 72 bytes + require.Error(t, svc.ChangePassword(user.ID, loginTestPassword, tooLong)) + assert.Equal(t, before.PasswordHash, reloadUser(t, db, user.ID).PasswordHash) +} + +func TestChangePassword_StorageFailureIsReported(t *testing.T) { + svc, db, user := newLoginTestService(t) + require.NoError(t, db.Exec("ALTER TABLE users DROP COLUMN session_version").Error) + require.Error(t, svc.ChangePassword(user.ID, loginTestPassword, "new-password-1")) +} + +func TestTokenForUser(t *testing.T) { + svc, _, user := newLoginTestService(t) + token, err := svc.TokenForUser(user.ID) + require.NoError(t, err) + _, _, err = svc.AuthenticateToken(token) + require.NoError(t, err) + + _, err = svc.TokenForUser(9999) + require.Error(t, err) +} + +func TestLogin_AccountWithoutStoredPasswordNeverSignsIn(t *testing.T) { + svc, db, _ := newLoginTestService(t) + pending := models.User{UUID: "pending-1", Email: "pending@example.com", Enabled: true} + require.NoError(t, db.Create(&pending).Error) + + for _, password := range []string{"", "anything", loginTestPassword} { + token, err := svc.Login(pending.Email, password) + assert.Empty(t, token) + require.ErrorIs(t, err, ErrInvalidLogin) + } + assert.False(t, checkPasswordUniformCost(&pending, "")) +} + +func TestLogin_AccountWithoutStoredPasswordRunsFullCostCheck(t *testing.T) { + if testing.Short() { + t.Skip("duration comparison skipped in short mode") + } + svc, db, user := newLoginTestService(t) + pending := models.User{UUID: "pending-2", Email: "pending2@example.com", Enabled: true} + require.NoError(t, db.Create(&pending).Error) + placeholderHash() + + median := func(email string) time.Duration { + const n = 5 + d := make([]time.Duration, 0, n) + for i := 0; i < n; i++ { + start := time.Now() + _, _ = svc.Login(email, "x") + d = append(d, time.Since(start)) + } + sort.Slice(d, func(i, j int) bool { return d[i] < d[j] }) + return d[n/2] + } + stored := median(user.Email) + empty := median(pending.Email) + assert.GreaterOrEqual(t, float64(empty), 0.5*float64(stored), "empty=%v stored=%v", empty, stored) +} diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index 2cca4daff..7b44a9f50 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -77,7 +77,8 @@ func TestAuthService_Login(t *testing.T) { // Try login with correct password while locked _, err = service.Login("test@example.com", "password123") assert.Error(t, err) - assert.Equal(t, "account locked", err.Error()) + assert.ErrorIs(t, err, ErrInvalidLogin) + assert.Equal(t, "invalid credentials", err.Error()) } func TestAuthService_ChangePassword(t *testing.T) { From e9a6c55f8ee792d2deea926cb480dbdfbe17eb1b Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 09:43:31 +0000 Subject: [PATCH 06/57] fix(security): harden request validation in the API layer Read caller identity through shared accessors and tighten request validation. Adds guard and regression tests. --- backend/internal/api/handlers/audit_actor.go | 23 ++ .../internal/api/handlers/audit_actor_test.go | 77 +++++ .../api/handlers/auth_forward_auth_test.go | 144 ++++++++++ backend/internal/api/handlers/auth_handler.go | 129 +++++---- .../api/handlers/auth_handler_test.go | 12 +- backend/internal/api/handlers/auth_helpers.go | 18 +- .../internal/api/handlers/backup_handler.go | 6 +- .../api/handlers/coverage_helpers_test.go | 27 -- .../internal/api/handlers/crowdsec_handler.go | 11 +- .../crowdsec_handler_comprehensive_test.go | 35 --- .../api/handlers/crowdsec_handler_test.go | 16 -- .../api/handlers/emergency_handler.go | 3 +- .../api/handlers/encryption_handler.go | 24 +- .../api/handlers/encryption_handler_test.go | 77 +---- .../api/handlers/manual_challenge_handler.go | 71 ++--- .../handlers/manual_challenge_handler_test.go | 30 +- .../handlers/manual_challenge_scoping_test.go | 212 ++++++++++++++ .../internal/api/handlers/orthrus_handler.go | 2 +- .../api/handlers/permission_helpers.go | 12 +- .../api/handlers/permission_helpers_test.go | 4 +- .../internal/api/handlers/pr_coverage_test.go | 25 -- .../handlers/security_event_intake_test.go | 4 +- .../internal/api/handlers/security_handler.go | 30 +- .../handlers/system_permissions_handler.go | 7 +- backend/internal/api/handlers/user_handler.go | 33 +-- .../api/handlers/user_handler_test.go | 45 ++- backend/internal/api/middleware/auth.go | 10 +- backend/internal/api/middleware/ctxkeys.go | 47 ++++ .../internal/api/middleware/ctxkeys_test.go | 262 ++++++++++++++++++ .../internal/api/middleware/optional_auth.go | 5 +- backend/internal/api/routes/routes.go | 4 + backend/internal/cerberus/cerberus.go | 23 +- backend/internal/services/backup_service.go | 2 +- .../services/manual_challenge_migration.go | 58 ++++ .../manual_challenge_migration_test.go | 95 +++++++ backend/internal/services/proxyhost_match.go | 164 +++++++++++ .../internal/services/proxyhost_match_test.go | 166 +++++++++++ backend/internal/services/security_service.go | 15 +- .../services/security_service_test.go | 25 ++ 39 files changed, 1489 insertions(+), 464 deletions(-) create mode 100644 backend/internal/api/handlers/audit_actor.go create mode 100644 backend/internal/api/handlers/audit_actor_test.go create mode 100644 backend/internal/api/handlers/auth_forward_auth_test.go create mode 100644 backend/internal/api/handlers/manual_challenge_scoping_test.go create mode 100644 backend/internal/api/middleware/ctxkeys.go create mode 100644 backend/internal/api/middleware/ctxkeys_test.go create mode 100644 backend/internal/services/manual_challenge_migration.go create mode 100644 backend/internal/services/manual_challenge_migration_test.go create mode 100644 backend/internal/services/proxyhost_match.go create mode 100644 backend/internal/services/proxyhost_match_test.go diff --git a/backend/internal/api/handlers/audit_actor.go b/backend/internal/api/handlers/audit_actor.go new file mode 100644 index 000000000..e6b567743 --- /dev/null +++ b/backend/internal/api/handlers/audit_actor.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "fmt" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/gin-gonic/gin" +) + +// auditActor returns the identity recorded as the actor of an audit entry: +// "user:" for a signed-in user, "emergency" for the emergency path (which +// has no user record), and the client address when no identity is present. +func auditActor(c *gin.Context) string { + id, ok := middleware.CallerID(c) + switch { + case !ok: + return c.ClientIP() + case id == 0: + return "emergency" + default: + return fmt.Sprintf("user:%d", id) + } +} diff --git a/backend/internal/api/handlers/audit_actor_test.go b/backend/internal/api/handlers/audit_actor_test.go new file mode 100644 index 000000000..8009ac6d7 --- /dev/null +++ b/backend/internal/api/handlers/audit_actor_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +func TestAuditActor(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + newCtx := func() *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req.RemoteAddr = "198.51.100.10:1234" + c.Request = req + return c + } + + t.Run("signed-in user", func(t *testing.T) { + c := newCtx() + middleware.SetCaller(c, 42, "admin") + assert.Equal(t, "user:42", auditActor(c)) + }) + t.Run("emergency path", func(t *testing.T) { + c := newCtx() + middleware.SetCaller(c, 0, "admin") + assert.Equal(t, "emergency", auditActor(c)) + }) + t.Run("no identity uses client address", func(t *testing.T) { + assert.Equal(t, "198.51.100.10", auditActor(newCtx())) + }) + t.Run("unexpected value type uses client address", func(t *testing.T) { + c := newCtx() + c.Set(middleware.UserIDKey, "42") + assert.Equal(t, "198.51.100.10", auditActor(c)) + }) +} + +func TestSecurityHandler_AuditEntriesRecordCaller(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityAudit{})) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + t.Cleanup(handler.Close) + router := gin.New() + router.Use(func(c *gin.Context) { + middleware.SetCaller(c, 42, "admin") + c.Next() + }) + router.POST("/security/decisions", handler.CreateDecision) + + body, _ := json.Marshal(map[string]any{"ip": "10.0.0.1", "action": "block", "details": "manual"}) + req := httptest.NewRequest(http.MethodPost, "/security/decisions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + handler.svc.Flush() + var audit models.SecurityAudit + require.NoError(t, db.Where("action = ?", "create_decision").First(&audit).Error) + assert.Equal(t, "user:42", audit.Actor) +} diff --git a/backend/internal/api/handlers/auth_forward_auth_test.go b/backend/internal/api/handlers/auth_forward_auth_test.go new file mode 100644 index 000000000..42d0c5383 --- /dev/null +++ b/backend/internal/api/handlers/auth_forward_auth_test.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +func forwardAuthRequest(t *testing.T, handler *AuthHandler, token string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + r := gin.New() + r.GET("/verify", handler.Verify) + req := httptest.NewRequest(http.MethodGet, "/verify", http.NoBody) + req.Header.Set("Authorization", "Bearer "+token) + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func seedForwardAuthUser(t *testing.T, handler *AuthHandler, db *gorm.DB, mode models.PermissionMode, permitted ...*models.ProxyHost) string { + t.Helper() + user := &models.User{ + UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: uuid.NewString() + "@example.com", Name: "U", + Role: models.RoleUser, Enabled: true, PermissionMode: mode, + } + require.NoError(t, user.SetPassword("password123")) + for _, h := range permitted { + user.PermittedHosts = append(user.PermittedHosts, *h) + } + require.NoError(t, db.Create(user).Error) + token, err := handler.authService.GenerateToken(user) + require.NoError(t, err) + return token +} + +func seedForwardAuthHost(t *testing.T, db *gorm.DB, domains string, forwardAuth bool) *models.ProxyHost { + t.Helper() + h := &models.ProxyHost{UUID: uuid.NewString(), Name: domains, DomainNames: domains, ForwardAuthEnabled: forwardAuth, Enabled: true} + require.NoError(t, db.Create(h).Error) + return h +} + +func TestForwardAuth_ExactHostMatch(t *testing.T) { + t.Parallel() + handler, db := setupAuthHandlerWithDB(t) + require.NoError(t, db.AutoMigrate(&models.User{}, &models.ProxyHost{})) + + protected := seedForwardAuthHost(t, db, "example.com", true) + seedForwardAuthHost(t, db, "open.test", false) + seedForwardAuthHost(t, db, "*.wild.test", true) + seedForwardAuthHost(t, db, "multi.test, second.test:8443", false) + + // A user that is denied by default and only permitted on nothing: every + // forward-auth-enabled host must refuse them. + denied := seedForwardAuthUser(t, handler, db, models.PermissionModeDenyAll) + // A user permitted on the protected host. + allowed := seedForwardAuthUser(t, handler, db, models.PermissionModeDenyAll, protected) + + tests := []struct { + name string + token string + headers map[string]string + want int + }{ + {"permitted user, exact host", allowed, map[string]string{"X-Forwarded-Host": "example.com"}, http.StatusOK}, + {"denied user, exact host", denied, map[string]string{"X-Forwarded-Host": "example.com"}, http.StatusForbidden}, + {"denied user, host with port and case", denied, map[string]string{"X-Forwarded-Host": "EXAMPLE.com:443"}, http.StatusForbidden}, + {"near miss prefix is a different host", denied, map[string]string{"X-Forwarded-Host": "notexample.com"}, http.StatusForbidden}, + {"near miss subdomain is a different host", denied, map[string]string{"X-Forwarded-Host": "a.example.com"}, http.StatusForbidden}, + {"percent is rejected", allowed, map[string]string{"X-Forwarded-Host": "%"}, http.StatusForbidden}, + {"percent pattern is rejected", allowed, map[string]string{"X-Forwarded-Host": "%example%"}, http.StatusForbidden}, + {"underscore matches nothing", allowed, map[string]string{"X-Forwarded-Host": "_"}, http.StatusForbidden}, + {"wildcard character is rejected", allowed, map[string]string{"X-Forwarded-Host": "*.wild.test"}, http.StatusForbidden}, + {"no host header", allowed, nil, http.StatusForbidden}, + {"unknown host", allowed, map[string]string{"X-Forwarded-Host": "unknown.test"}, http.StatusForbidden}, + {"host without forward auth allows any signed-in user", denied, map[string]string{"X-Forwarded-Host": "open.test"}, http.StatusOK}, + {"stored list, first entry", denied, map[string]string{"X-Forwarded-Host": "multi.test"}, http.StatusOK}, + {"stored list, entry with port", denied, map[string]string{"X-Forwarded-Host": "second.test"}, http.StatusOK}, + {"wildcard covers one label and enforces", denied, map[string]string{"X-Forwarded-Host": "a.wild.test"}, http.StatusForbidden}, + {"wildcard does not cover two labels", denied, map[string]string{"X-Forwarded-Host": "a.b.wild.test"}, http.StatusForbidden}, + {"first forwarded entry is used", denied, map[string]string{"X-Forwarded-Host": "open.test, example.com"}, http.StatusOK}, + {"original host used only when forwarded host is absent", denied, map[string]string{"X-Original-Host": "open.test"}, http.StatusOK}, + {"forwarded host wins over original host", denied, map[string]string{"X-Forwarded-Host": "example.com", "X-Original-Host": "open.test"}, http.StatusForbidden}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := forwardAuthRequest(t, handler, tt.token, tt.headers) + assert.Equal(t, tt.want, w.Code) + if tt.want == http.StatusOK { + assert.NotEmpty(t, w.Header().Get("X-Forwarded-User")) + } else { + assert.Empty(t, w.Header().Get("X-Forwarded-User")) + } + }) + } +} + +func TestForwardAuth_LookupFailureDenies(t *testing.T) { + t.Parallel() + handler, db := setupAuthHandlerWithDB(t) + seedForwardAuthHost(t, db, "open.test", false) + token := seedForwardAuthUser(t, handler, db, models.PermissionModeAllowAll) + + require.NoError(t, db.Migrator().DropTable(&models.ProxyHost{})) + + w := forwardAuthRequest(t, handler, token, map[string]string{"X-Forwarded-Host": "open.test"}) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Empty(t, w.Header().Get("X-Forwarded-User")) +} + +func TestForwardAuth_NoDatabaseDenies(t *testing.T) { + t.Parallel() + handler, db := setupAuthHandlerWithDB(t) + token := seedForwardAuthUser(t, handler, db, models.PermissionModeAllowAll) + handler.db = nil + + w := forwardAuthRequest(t, handler, token, map[string]string{"X-Forwarded-Host": "open.test"}) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestForwardAuth_PermissionLoadFailureDenies(t *testing.T) { + t.Parallel() + handler, db := setupAuthHandlerWithDB(t) + seedForwardAuthHost(t, db, "app.test", true) + token := seedForwardAuthUser(t, handler, db, models.PermissionModeAllowAll) + + // Token validation reads the user row first; removing the join table makes + // only the permitted-host preload fail. + require.NoError(t, db.Exec("DROP TABLE IF EXISTS user_permitted_hosts").Error) + + w := forwardAuthRequest(t, handler, token, map[string]string{"X-Forwarded-Host": "app.test"}) + assert.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 46569f5b7..36e92cd65 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" @@ -225,12 +227,10 @@ func (h *AuthHandler) Login(c *gin.Context) { } func (h *AuthHandler) Logout(c *gin.Context) { - if userIDValue, exists := c.Get("userID"); exists { - if userID, ok := userIDValue.(uint); ok && userID > 0 { - if err := h.authService.InvalidateSessions(userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate session"}) - return - } + if userID, ok := middleware.CallerID(c); ok && userID > 0 { + if err := h.authService.InvalidateSessions(userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate session"}) + return } } @@ -242,13 +242,12 @@ func (h *AuthHandler) Logout(c *gin.Context) { // Must be called with a valid existing token. // Supports long-running test sessions by allowing token refresh before expiry. func (h *AuthHandler) Refresh(c *gin.Context) { - userID, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + userID, ok := requireUserID(c) + if !ok { return } - user, err := h.authService.GetUserByID(userID.(uint)) + user, err := h.authService.GetUserByID(userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return @@ -267,19 +266,12 @@ func (h *AuthHandler) Refresh(c *gin.Context) { } func (h *AuthHandler) Me(c *gin.Context) { - userIDValue, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - userID, ok := userIDValue.(uint) + userID, ok := requireUserID(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } - role, _ := c.Get("role") + role := middleware.CallerRole(c) u, err := h.authService.GetUserByID(userID) if err != nil { @@ -308,13 +300,12 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { return } - userID, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + userID, ok := requireUserID(c) + if !ok { return } - if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil { + if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -367,31 +358,11 @@ func (h *AuthHandler) Verify(c *gin.Context) { return } - // Get the forwarded host from Caddy - forwardedHost := c.GetHeader("X-Forwarded-Host") - if forwardedHost == "" { - forwardedHost = c.GetHeader("X-Original-Host") - } - - // If we have a database reference and a forwarded host, check permissions - if h.db != nil && forwardedHost != "" { - // Find the proxy host for this domain - var proxyHost models.ProxyHost - err := h.db.Where("domain_names LIKE ?", "%"+forwardedHost+"%").First(&proxyHost).Error - - if err == nil && proxyHost.ForwardAuthEnabled { - // Load user's permitted hosts for permission check - var userWithHosts models.User - if err := h.db.Preload("PermittedHosts").First(&userWithHosts, user.ID).Error; err == nil { - // Check if user can access this host - if !userWithHosts.CanAccessHost(proxyHost.ID) { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ - "error": "Access denied to this application", - }) - return - } - } - } + if !h.authorizeForwardedHost(c, user) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "Access denied to this application", + }) + return } // Set headers for downstream services @@ -403,6 +374,58 @@ func (h *AuthHandler) Verify(c *gin.Context) { c.Status(http.StatusOK) } +// forwardedHost returns the host the proxy received the original request for: +// the first X-Forwarded-Host entry, or X-Original-Host when that header is absent. +func forwardedHost(c *gin.Context) string { + host := c.GetHeader("X-Forwarded-Host") + if host == "" { + host = c.GetHeader("X-Original-Host") + } + first, _, _ := strings.Cut(host, ",") + return strings.TrimSpace(first) +} + +// authorizeForwardedHost reports whether user may access the proxy host the +// request was forwarded for. It fails closed: an unusable host, an unknown +// host, or any lookup error denies access. A host without forward auth enabled +// accepts any authenticated user. +func (h *AuthHandler) authorizeForwardedHost(c *gin.Context, user *models.User) bool { + if h.db == nil { + logger.Log().Warn("forward auth check unavailable: database not configured") + return false + } + + hosts, err := services.FindProxyHostsByDomain(h.db, forwardedHost(c)) + if err != nil { + logger.Log().WithError(err).Debug("forward auth host lookup failed") + return false + } + if len(hosts) == 0 { + logger.Log().Debug("forward auth request for an unknown host") + return false + } + + var permitted *models.User + for i := range hosts { + if !hosts[i].ForwardAuthEnabled { + logger.Log().WithField("host_id", hosts[i].ID).Debug("forward auth not enabled for host; allowing authenticated user") + continue + } + if permitted == nil { + var loaded models.User + if err := h.db.Preload("PermittedHosts").First(&loaded, user.ID).Error; err != nil { + logger.Log().WithError(err).Debug("forward auth permission load failed") + return false + } + permitted = &loaded + } + if !permitted.CanAccessHost(hosts[i].ID) { + return false + } + } + return true +} + // VerifyStatus returns the current auth status without triggering a redirect. // Useful for frontend to check if user is logged in. func (h *AuthHandler) VerifyStatus(c *gin.Context) { @@ -447,9 +470,8 @@ func (h *AuthHandler) VerifyStatus(c *gin.Context) { // GetAccessibleHosts returns the list of proxy hosts the authenticated user can access. func (h *AuthHandler) GetAccessibleHosts(c *gin.Context) { - userID, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + userID, ok := requireUserID(c) + if !ok { return } @@ -492,9 +514,8 @@ func (h *AuthHandler) GetAccessibleHosts(c *gin.Context) { // CheckHostAccess checks if the current user can access a specific host. func (h *AuthHandler) CheckHostAccess(c *gin.Context) { - userID, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + userID, ok := requireUserID(c) + if !ok { return } diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index b8cac7588..7b42c3205 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -749,8 +749,7 @@ func TestAuthHandler_Me(t *testing.T) { r := gin.New() // Simulate middleware r.Use(func(c *gin.Context) { - c.Set("userID", user.ID) - c.Set("role", user.Role) + middleware.SetCaller(c, user.ID, string(user.Role)) c.Next() }) r.GET("/me", handler.Me) @@ -784,8 +783,7 @@ func TestAuthHandler_Me_ChangelogOptOutFalse(t *testing.T) { r := gin.New() r.Use(func(c *gin.Context) { - c.Set("userID", user.ID) - c.Set("role", user.Role) + middleware.SetCaller(c, user.ID, string(user.Role)) c.Next() }) r.GET("/me", handler.Me) @@ -976,11 +974,14 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) { // Generate token token, _ := handler.authService.GenerateToken(user) + require.NoError(t, db.Create(&models.ProxyHost{UUID: uuid.NewString(), Name: "App", DomainNames: "app.example.com", Enabled: true}).Error) + r := gin.New() r.GET("/verify", handler.Verify) req := httptest.NewRequest("GET", "/verify", http.NoBody) req.AddCookie(&http.Cookie{Name: "auth_token", Value: token, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + req.Header.Set("X-Forwarded-Host", "app.example.com") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -1005,11 +1006,14 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) { token, _ := handler.authService.GenerateToken(user) + require.NoError(t, db.Create(&models.ProxyHost{UUID: uuid.NewString(), Name: "App", DomainNames: "app.example.com", Enabled: true}).Error) + r := gin.New() r.GET("/verify", handler.Verify) req := httptest.NewRequest("GET", "/verify", http.NoBody) req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-Host", "app.example.com") w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/backend/internal/api/handlers/auth_helpers.go b/backend/internal/api/handlers/auth_helpers.go index b4a15d07f..7a668395e 100644 --- a/backend/internal/api/handlers/auth_helpers.go +++ b/backend/internal/api/handlers/auth_helpers.go @@ -3,23 +3,15 @@ package handlers import ( "net/http" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/gin-gonic/gin" ) -// requireUserID extracts and type-asserts the authenticated user's ID set -// by the auth middleware (c.Set("userID", uint(...))). On failure it -// writes a 401 response itself and returns false — callers should return -// immediately without further writes to c. Consolidates a pattern -// previously duplicated across GetProfile/UpdateProfile/RegenerateAPIKey -// (and now ChangelogHandler); see auth_handler.go's Me()/ChangePassword -// for other call sites left as-is (out of scope for this DRY pass). +// requireUserID returns the authenticated user's ID recorded by the auth +// middleware. On failure it writes a 401 response itself and returns false; +// callers should return immediately without further writes to c. func requireUserID(c *gin.Context) (uint, bool) { - v, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return 0, false - } - userID, ok := v.(uint) + userID, ok := middleware.CallerID(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return 0, false diff --git a/backend/internal/api/handlers/backup_handler.go b/backend/internal/api/handlers/backup_handler.go index 640349c86..cd8f1d14b 100644 --- a/backend/internal/api/handlers/backup_handler.go +++ b/backend/internal/api/handlers/backup_handler.go @@ -134,12 +134,8 @@ type createBackupRequest struct { // captured synchronously here while c still exists (this plan's §3.3.1/ // §3.5). func buildRequestAuditInfo(c *gin.Context) services.RequestAuditInfo { - actor := "unknown" - if userID, ok := c.Get("userID"); ok { // identical pattern to logPermissionAudit - actor = fmt.Sprintf("%v", userID) - } return services.RequestAuditInfo{ - Actor: actor, + Actor: auditActor(c), IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent(), } diff --git a/backend/internal/api/handlers/coverage_helpers_test.go b/backend/internal/api/handlers/coverage_helpers_test.go index cde20263f..4dec18db8 100644 --- a/backend/internal/api/handlers/coverage_helpers_test.go +++ b/backend/internal/api/handlers/coverage_helpers_test.go @@ -127,33 +127,6 @@ func Test_mapCrowdsecStatus(t *testing.T) { } } -// Test actorFromContext helper function -func Test_actorFromContext(t *testing.T) { - - t.Run("with userID in context", func(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Set("userID", 123) - - result := actorFromContext(c) - assert.Equal(t, "user:123", result) - }) - - t.Run("without userID in context", func(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - - result := actorFromContext(c) - assert.Equal(t, "unknown", result) - }) - - t.Run("with string userID", func(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Set("userID", "admin") - - result := actorFromContext(c) - assert.Equal(t, "user:admin", result) - }) -} - // Test hubEndpoints helper function func Test_hubEndpoints(t *testing.T) { diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 1d5889e46..1d9073850 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -450,13 +450,6 @@ func (h *CrowdsecHandler) isConsoleEnrollmentEnabled() bool { return false } -func actorFromContext(c *gin.Context) string { - if id, ok := c.Get("userID"); ok { - return fmt.Sprintf("user:%v", id) - } - return "unknown" -} - func (h *CrowdsecHandler) hubEndpoints() []string { if h.Hub == nil { return nil @@ -1325,7 +1318,7 @@ func (h *CrowdsecHandler) ConsoleEnroll(c *gin.Context) { } logger.Log().WithError(err).WithField("tenant", util.SanitizeForLog(payload.Tenant)).WithField("agent", util.SanitizeForLog(payload.AgentName)).WithField("correlation_id", status.CorrelationID).Warn("crowdsec console enrollment failed") if h.Security != nil { - _ = h.Security.LogAudit(&models.SecurityAudit{Actor: actorFromContext(c), Action: "crowdsec_console_enroll_failed", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, payload.Tenant, payload.AgentName, status.CorrelationID)}) + _ = h.Security.LogAudit(&models.SecurityAudit{Actor: auditActor(c), Action: "crowdsec_console_enroll_failed", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, payload.Tenant, payload.AgentName, status.CorrelationID)}) } resp := gin.H{"error": err.Error(), "status": status.Status} if status.CorrelationID != "" { @@ -1336,7 +1329,7 @@ func (h *CrowdsecHandler) ConsoleEnroll(c *gin.Context) { } if h.Security != nil { - _ = h.Security.LogAudit(&models.SecurityAudit{Actor: actorFromContext(c), Action: "crowdsec_console_enroll_succeeded", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, status.Tenant, status.AgentName, status.CorrelationID)}) + _ = h.Security.LogAudit(&models.SecurityAudit{Actor: auditActor(c), Action: "crowdsec_console_enroll_succeeded", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, status.Tenant, status.AgentName, status.CorrelationID)}) } c.JSON(http.StatusOK, status) diff --git a/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go b/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go index 38ca0826d..2b39353c6 100644 --- a/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go @@ -165,41 +165,6 @@ func TestIsConsoleEnrollmentEnabled(t *testing.T) { } } -// TestActorFromContext tests the actorFromContext helper -func TestActorFromContext(t *testing.T) { - tests := []struct { - name string - setupCtx func(*gin.Context) - want string - }{ - { - name: "with userID", - setupCtx: func(c *gin.Context) { - c.Set("userID", 123) - }, - want: "user:123", - }, - { - name: "without userID", - setupCtx: func(c *gin.Context) { - // No userID set - }, - want: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - tt.setupCtx(c) - - got := actorFromContext(c) - assert.Equal(t, tt.want, got) - }) - } -} - // TestHubEndpoints tests the hubEndpoints helper func TestHubEndpoints(t *testing.T) { db := OpenTestDB(t) diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index 49c9bbc41..6120e980d 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -2291,22 +2291,6 @@ func TestCrowdsecHandler_Stop_UpdatesSecurityConfig(t *testing.T) { require.False(t, updatedCfg.Enabled) } -// TestCrowdsecHandler_ActorFromContext tests actor extraction from Gin context -func TestCrowdsecHandler_ActorFromContext(t *testing.T) { - t.Parallel() - - // Test with userID present - c1, _ := gin.CreateTestContext(httptest.NewRecorder()) - c1.Set("userID", 123) - actor1 := actorFromContext(c1) - require.Equal(t, "user:123", actor1) - - // Test without userID - c2, _ := gin.CreateTestContext(httptest.NewRecorder()) - actor2 := actorFromContext(c2) - require.Equal(t, "unknown", actor2) -} - // TestCrowdsecHandler_IsCerberusEnabled_EnvVar tests Cerberus feature flag via environment variable func TestCrowdsecHandler_IsCerberusEnabled_EnvVar(t *testing.T) { // Note: Cannot use t.Parallel() with t.Setenv in subtests diff --git a/backend/internal/api/handlers/emergency_handler.go b/backend/internal/api/handlers/emergency_handler.go index 669ee5fd9..cf4ccddf9 100644 --- a/backend/internal/api/handlers/emergency_handler.go +++ b/backend/internal/api/handlers/emergency_handler.go @@ -389,9 +389,8 @@ func (h *EmergencyHandler) GenerateToken(c *gin.Context) { } // Get user ID from context - userID, _ := c.Get("userID") var userIDPtr *uint - if id, ok := userID.(uint); ok { + if id, ok := middleware.CallerID(c); ok { userIDPtr = &id } diff --git a/backend/internal/api/handlers/encryption_handler.go b/backend/internal/api/handlers/encryption_handler.go index d145af338..2d49c7324 100644 --- a/backend/internal/api/handlers/encryption_handler.go +++ b/backend/internal/api/handlers/encryption_handler.go @@ -56,7 +56,7 @@ func (h *EncryptionHandler) Rotate(c *gin.Context) { // Log rotation start if err := h.securityService.LogAudit(&models.SecurityAudit{ - Actor: getActorFromGinContext(c), + Actor: auditActor(c), Action: "encryption_key_rotation_started", EventCategory: "encryption", Details: "{}", @@ -74,7 +74,7 @@ func (h *EncryptionHandler) Rotate(c *gin.Context) { "error": err.Error(), }) _ = h.securityService.LogAudit(&models.SecurityAudit{ - Actor: getActorFromGinContext(c), + Actor: auditActor(c), Action: "encryption_key_rotation_failed", EventCategory: "encryption", Details: string(detailsJSON), @@ -96,7 +96,7 @@ func (h *EncryptionHandler) Rotate(c *gin.Context) { "new_key_version": result.NewKeyVersion, }) _ = h.securityService.LogAudit(&models.SecurityAudit{ - Actor: getActorFromGinContext(c), + Actor: auditActor(c), Action: "encryption_key_rotation_completed", EventCategory: "encryption", Details: string(detailsJSON), @@ -164,7 +164,7 @@ func (h *EncryptionHandler) Validate(c *gin.Context) { "error": err.Error(), }) _ = h.securityService.LogAudit(&models.SecurityAudit{ - Actor: getActorFromGinContext(c), + Actor: auditActor(c), Action: "encryption_key_validation_failed", EventCategory: "encryption", Details: string(detailsJSON), @@ -181,7 +181,7 @@ func (h *EncryptionHandler) Validate(c *gin.Context) { // Log validation success _ = h.securityService.LogAudit(&models.SecurityAudit{ - Actor: getActorFromGinContext(c), + Actor: auditActor(c), Action: "encryption_key_validation_success", EventCategory: "encryption", Details: "{}", @@ -194,17 +194,3 @@ func (h *EncryptionHandler) Validate(c *gin.Context) { "message": "All encryption keys are valid", }) } - -// getActorFromGinContext extracts the user ID from Gin context for audit logging. -func getActorFromGinContext(c *gin.Context) string { - // Auth middleware sets "userID" (not "user_id") - if userID, exists := c.Get("userID"); exists { - if id, ok := userID.(uint); ok { - return strconv.FormatUint(uint64(id), 10) - } - if id, ok := userID.(string); ok { - return id - } - } - return "system" -} diff --git a/backend/internal/api/handlers/encryption_handler_test.go b/backend/internal/api/handlers/encryption_handler_test.go index ec404f62b..038aa1609 100644 --- a/backend/internal/api/handlers/encryption_handler_test.go +++ b/backend/internal/api/handlers/encryption_handler_test.go @@ -553,7 +553,7 @@ func TestEncryptionHandler_IntegrationFlow(t *testing.T) { }) } -// TestEncryptionHandler_HelperFunctions tests the isAdmin and getActorFromGinContext helpers +// TestEncryptionHandler_HelperFunctions tests the isAdmin helper func TestEncryptionHandler_HelperFunctions(t *testing.T) { t.Run("isAdmin with invalid role type", func(t *testing.T) { @@ -576,59 +576,6 @@ func TestEncryptionHandler_HelperFunctions(t *testing.T) { assert.Equal(t, http.StatusForbidden, w.Code) }) - - t.Run("getActorFromGinContext with string user_id", func(t *testing.T) { - router := gin.New() - var capturedActor string - router.Use(func(c *gin.Context) { - c.Set("userID", "user-string-123") - c.Next() - }) - router.GET("/test", func(c *gin.Context) { - capturedActor = getActorFromGinContext(c) - c.Status(http.StatusOK) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", http.NoBody) - router.ServeHTTP(w, req) - - assert.Equal(t, "user-string-123", capturedActor) - }) - - t.Run("getActorFromGinContext with uint user_id", func(t *testing.T) { - router := gin.New() - var capturedActor string - router.Use(func(c *gin.Context) { - c.Set("userID", uint(42)) - c.Next() - }) - router.GET("/test", func(c *gin.Context) { - capturedActor = getActorFromGinContext(c) - c.Status(http.StatusOK) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", http.NoBody) - router.ServeHTTP(w, req) - - assert.Equal(t, "42", capturedActor) - }) - - t.Run("getActorFromGinContext without user_id returns system", func(t *testing.T) { - router := gin.New() - var capturedActor string - router.GET("/test", func(c *gin.Context) { - capturedActor = getActorFromGinContext(c) - c.Status(http.StatusOK) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", http.NoBody) - router.ServeHTTP(w, req) - - assert.Equal(t, "system", capturedActor) - }) } // TestEncryptionHandler_RefreshKey_RotatesCredentials tests key rotation for credentials @@ -781,28 +728,6 @@ func TestEncryptionHandler_RefreshKey_InvalidOldKey(t *testing.T) { assert.Greater(t, result.FailureCount, 0) } -// TestEncryptionHandler_GetActorFromGinContext_InvalidType tests getActorFromGinContext with invalid type -func TestEncryptionHandler_GetActorFromGinContext_InvalidType(t *testing.T) { - - router := gin.New() - var capturedActor string - router.Use(func(c *gin.Context) { - c.Set("userID", int64(999)) // int64 instead of uint or string - c.Next() - }) - router.GET("/test", func(c *gin.Context) { - capturedActor = getActorFromGinContext(c) - c.Status(http.StatusOK) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", http.NoBody) - router.ServeHTTP(w, req) - - // Invalid type should return "system" as fallback - assert.Equal(t, "system", capturedActor) -} - // TestEncryptionHandler_RotateWithPartialFailures tests rotation that has some successes and failures func TestEncryptionHandler_RotateWithPartialFailures(t *testing.T) { db := setupEncryptionTestDB(t) diff --git a/backend/internal/api/handlers/manual_challenge_handler.go b/backend/internal/api/handlers/manual_challenge_handler.go index c6db55c49..1206888f6 100644 --- a/backend/internal/api/handlers/manual_challenge_handler.go +++ b/backend/internal/api/handlers/manual_challenge_handler.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" @@ -135,8 +136,10 @@ func (h *ManualChallengeHandler) GetChallenge(c *gin.Context) { return } - // Get user ID from context (set by auth middleware) - userID := getUserIDFromContext(c) + userID, ok := requireChallengeCaller(c) + if !ok { + return + } // Verify provider exists and user has access provider, err := h.providerService.Get(c.Request.Context(), providerID) @@ -225,8 +228,10 @@ func (h *ManualChallengeHandler) VerifyChallenge(c *gin.Context) { return } - // Get user ID from context - userID := getUserIDFromContext(c) + userID, ok := requireChallengeCaller(c) + if !ok { + return + } // Verify provider exists and is manual type provider, err := h.providerService.Get(c.Request.Context(), providerID) @@ -335,7 +340,10 @@ func (h *ManualChallengeHandler) PollChallenge(c *gin.Context) { return } - userID := getUserIDFromContext(c) + userID, ok := requireChallengeCaller(c) + if !ok { + return + } // Verify provider exists provider, err := h.providerService.Get(c.Request.Context(), providerID) @@ -403,7 +411,10 @@ func (h *ManualChallengeHandler) ListChallenges(c *gin.Context) { return } - userID := getUserIDFromContext(c) + userID, ok := requireChallengeCaller(c) + if !ok { + return + } // Verify provider exists and is manual type provider, err := h.providerService.Get(c.Request.Context(), providerID) @@ -472,7 +483,10 @@ func (h *ManualChallengeHandler) DeleteChallenge(c *gin.Context) { return } - userID := getUserIDFromContext(c) + userID, ok := requireChallengeCaller(c) + if !ok { + return + } // Verify provider exists provider, err := h.providerService.Get(c.Request.Context(), providerID) @@ -559,7 +573,10 @@ func (h *ManualChallengeHandler) CreateChallenge(c *gin.Context) { return } - userID := getUserIDFromContext(c) + userID, ok := requireChallengeCaller(c) + if !ok { + return + } // Verify provider exists and is manual type provider, err := h.providerService.Get(c.Request.Context(), providerID) @@ -650,29 +667,19 @@ func challengeToResponse(ch *models.ManualChallenge) *ManualChallengeResponse { return resp } -// getUserIDFromContext extracts user ID from gin context. -func getUserIDFromContext(c *gin.Context) uint { - // Try to get user_id from context (set by auth middleware) - if userID, exists := c.Get("user_id"); exists { - switch v := userID.(type) { - case uint: - return v - case int: - // Check for overflow when converting int -> uint - if v < 0 { - return 0 // Invalid negative ID - } - return uint(v) // #nosec G115 -- validated non-negative - case int64: - // Check for overflow when converting int64 -> uint - // Use simple bounds check instead of complex expression - if v < 0 || v > 4294967295 { // Max uint32, safe for most systems - return 0 // Out of valid range - } - return uint(v) // #nosec G115 -- validated range - case uint64: - return uint(v) - } +// requireChallengeCaller returns the ID of the signed-in user making the +// request. Challenges are owned by a specific user, so requests without a real +// user identity (including the emergency path, which carries ID 0) are refused. +// On failure it writes the error response and returns ok=false. +func requireChallengeCaller(c *gin.Context) (uint, bool) { + userID, ok := middleware.CallerID(c) + if !ok { + c.JSON(http.StatusUnauthorized, newErrorResponse("UNAUTHORIZED", "Authentication required", nil)) + return 0, false + } + if userID == 0 { + c.JSON(http.StatusForbidden, newErrorResponse("FORBIDDEN", "A signed-in user is required for this operation", nil)) + return 0, false } - return 0 + return userID, true } diff --git a/backend/internal/api/handlers/manual_challenge_handler_test.go b/backend/internal/api/handlers/manual_challenge_handler_test.go index 598e0b5d6..102215bef 100644 --- a/backend/internal/api/handlers/manual_challenge_handler_test.go +++ b/backend/internal/api/handlers/manual_challenge_handler_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" @@ -108,7 +109,7 @@ func setupChallengeTestRouter() *gin.Engine { //nolint:unparam // userID kept for future test variants func setUserID(c *gin.Context, userID uint) { - c.Set("user_id", userID) + middleware.SetCaller(c, userID, "admin") } func TestNewManualChallengeHandler(t *testing.T) { @@ -683,33 +684,6 @@ func TestManualChallengeHandler_RegisterRoutes(t *testing.T) { assert.True(t, paths["/dns-providers/:id/manual-challenge/:challengeId/poll"]) } -func TestGetUserIDFromContext(t *testing.T) { - tests := []struct { - name string - value interface{} - expected uint - }{ - {"uint value", uint(42), 42}, - {"int value", int(42), 42}, - {"int64 value", int64(42), 42}, - {"uint64 value", uint64(42), 42}, - {"missing value", nil, 0}, - {"invalid type", "42", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - if tt.value != nil { - c.Set("user_id", tt.value) - } - - result := getUserIDFromContext(c) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestChallengeToResponse(t *testing.T) { now := time.Now() lastCheck := now.Add(-1 * time.Minute) diff --git a/backend/internal/api/handlers/manual_challenge_scoping_test.go b/backend/internal/api/handlers/manual_challenge_scoping_test.go new file mode 100644 index 000000000..4bb2d9db1 --- /dev/null +++ b/backend/internal/api/handlers/manual_challenge_scoping_test.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" +) + +// challengeScopeEnv wires the challenge handlers behind the real auth +// middleware and real services, with two admin users. +type challengeScopeEnv struct { + router *gin.Engine + db *gorm.DB + tokenA string + tokenB string + userA uint + userB uint + provider uint +} + +func newChallengeScopeEnv(t *testing.T) *challengeScopeEnv { + t.Helper() + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ManualChallenge{})) + + authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"}) + mkUser := func(email string) (uint, string) { + u := &models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: email, Name: email, Role: models.RoleAdmin, Enabled: true} + require.NoError(t, u.SetPassword("password123")) + require.NoError(t, db.Create(u).Error) + tok, err := authSvc.GenerateToken(u) + require.NoError(t, err) + return u.ID, tok + } + idA, tokA := mkUser("a@example.com") + idB, tokB := mkUser("b@example.com") + + providers := new(mockDNSProviderServiceForChallenge) + providers.On("Get", mock.Anything, uint(1)).Return(&models.DNSProvider{ID: 1, ProviderType: "manual"}, nil) + + handler := NewManualChallengeHandler(services.NewManualChallengeService(db), providers) + r := gin.New() + // Mirrors the production wiring: an optional emergency flag upstream of the real auth middleware. + r.Use(func(c *gin.Context) { + if c.GetHeader("X-Test-Emergency") == "1" { + c.Set(middleware.EmergencyBypassContextKey, true) + } + c.Next() + }) + r.Use(middleware.AuthMiddleware(authSvc)) + handler.RegisterRoutes(r.Group("/api/v1")) + + return &challengeScopeEnv{router: r, db: db, tokenA: tokA, tokenB: tokB, userA: idA, userB: idB, provider: 1} +} + +func (e *challengeScopeEnv) do(method, path, token string, body any, extra map[string]string) *httptest.ResponseRecorder { + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range extra { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + return w +} + +func (e *challengeScopeEnv) seed(t *testing.T, owner uint, fqdn string) string { + t.Helper() + ch := &models.ManualChallenge{ + ID: uuid.NewString(), ProviderID: e.provider, UserID: owner, FQDN: fqdn, Value: "txt", + Status: models.ChallengeStatusPending, ExpiresAt: time.Now().Add(time.Hour), + } + require.NoError(t, e.db.Create(ch).Error) + return ch.ID +} + +func (e *challengeScopeEnv) path(id, suffix string) string { + return fmt.Sprintf("/api/v1/dns-providers/%d/manual-challenge/%s%s", e.provider, id, suffix) +} + +func TestChallengeHandlers_ScopedToCaller(t *testing.T) { + e := newChallengeScopeEnv(t) + idA := e.seed(t, e.userA, "_acme-challenge.a.example.com") + + t.Run("owner can read, poll and list", func(t *testing.T) { + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, e.path(idA, ""), e.tokenA, nil, nil).Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, e.path(idA, "/poll"), e.tokenA, nil, nil).Code) + + w := e.do(http.MethodGet, fmt.Sprintf("/api/v1/dns-providers/%d/manual-challenges", e.provider), e.tokenA, nil, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), idA) + }) + + t.Run("other user is refused on get", func(t *testing.T) { + w := e.do(http.MethodGet, e.path(idA, ""), e.tokenB, nil, nil) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.NotContains(t, w.Body.String(), "_acme-challenge.a.example.com") + }) + + t.Run("other user is refused on poll", func(t *testing.T) { + assert.Equal(t, http.StatusForbidden, e.do(http.MethodGet, e.path(idA, "/poll"), e.tokenB, nil, nil).Code) + }) + + t.Run("other user is refused on verify", func(t *testing.T) { + assert.Equal(t, http.StatusForbidden, e.do(http.MethodPost, e.path(idA, "/verify"), e.tokenB, nil, nil).Code) + }) + + t.Run("other user does not see the challenge in the list", func(t *testing.T) { + w := e.do(http.MethodGet, fmt.Sprintf("/api/v1/dns-providers/%d/manual-challenges", e.provider), e.tokenB, nil, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.NotContains(t, w.Body.String(), idA) + }) + + t.Run("other user is refused on delete and the challenge remains", func(t *testing.T) { + assert.Equal(t, http.StatusForbidden, e.do(http.MethodDelete, e.path(idA, ""), e.tokenB, nil, nil).Code) + var n int64 + require.NoError(t, e.db.Model(&models.ManualChallenge{}).Where("id = ?", idA).Count(&n).Error) + assert.Equal(t, int64(1), n) + }) + + t.Run("owner can delete", func(t *testing.T) { + assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, e.path(idA, ""), e.tokenA, nil, nil).Code) + }) +} + +func TestChallengeHandlers_CreateRecordsCaller(t *testing.T) { + e := newChallengeScopeEnv(t) + + w := e.do(http.MethodPost, fmt.Sprintf("/api/v1/dns-providers/%d/manual-challenges", e.provider), e.tokenB, + map[string]string{"fqdn": "_acme-challenge.b.example.com", "token": "tok", "value": "txt"}, nil) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + var ch models.ManualChallenge + require.NoError(t, e.db.Where("fqdn = ?", "_acme-challenge.b.example.com").First(&ch).Error) + assert.Equal(t, e.userB, ch.UserID) + + // The creating user can read it back; the other admin cannot. + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, e.path(ch.ID, ""), e.tokenB, nil, nil).Code) + assert.Equal(t, http.StatusForbidden, e.do(http.MethodGet, e.path(ch.ID, ""), e.tokenA, nil, nil).Code) +} + +func TestChallengeHandlers_RequireSignedInUser(t *testing.T) { + e := newChallengeScopeEnv(t) + owned := e.seed(t, e.userA, "_acme-challenge.owned.example.com") + legacy := e.seed(t, 0, "_acme-challenge.legacy.example.com") + emergency := map[string]string{"X-Test-Emergency": "1"} + listPath := fmt.Sprintf("/api/v1/dns-providers/%d/manual-challenges", e.provider) + + t.Run("no credentials", func(t *testing.T) { + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, e.path(owned, ""), "", nil, nil).Code) + }) + + t.Run("emergency context is refused on every route", func(t *testing.T) { + for _, id := range []string{owned, legacy} { + assert.Equal(t, http.StatusForbidden, e.do(http.MethodGet, e.path(id, ""), "", nil, emergency).Code) + assert.Equal(t, http.StatusForbidden, e.do(http.MethodGet, e.path(id, "/poll"), "", nil, emergency).Code) + assert.Equal(t, http.StatusForbidden, e.do(http.MethodPost, e.path(id, "/verify"), "", nil, emergency).Code) + assert.Equal(t, http.StatusForbidden, e.do(http.MethodDelete, e.path(id, ""), "", nil, emergency).Code) + } + assert.Equal(t, http.StatusForbidden, e.do(http.MethodGet, listPath, "", nil, emergency).Code) + assert.Equal(t, http.StatusForbidden, e.do(http.MethodPost, listPath, "", + map[string]string{"fqdn": "_acme-challenge.x.example.com", "token": "t", "value": "v"}, emergency).Code) + + var n int64 + require.NoError(t, e.db.Model(&models.ManualChallenge{}).Where("id IN ?", []string{owned, legacy}).Count(&n).Error) + assert.Equal(t, int64(2), n, "refused requests leave challenges untouched") + }) + + t.Run("signed-in users cannot reach rows without an owner", func(t *testing.T) { + assert.Equal(t, http.StatusForbidden, e.do(http.MethodGet, e.path(legacy, ""), e.tokenA, nil, nil).Code) + assert.Equal(t, http.StatusForbidden, e.do(http.MethodDelete, e.path(legacy, ""), e.tokenB, nil, nil).Code) + }) +} + +func TestRequireChallengeCaller_UnexpectedValueType(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/", http.NoBody) + c.Set(middleware.UserIDKey, "7") + + _, ok := requireChallengeCaller(c) + assert.False(t, ok) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/backend/internal/api/handlers/orthrus_handler.go b/backend/internal/api/handlers/orthrus_handler.go index 7d69539b6..d26148860 100644 --- a/backend/internal/api/handlers/orthrus_handler.go +++ b/backend/internal/api/handlers/orthrus_handler.go @@ -154,7 +154,7 @@ func (h *OrthrusHandler) Patch(c *gin.Context) { action = "orthrus_write_enabled" } _ = h.securityService.LogAudit(&models.SecurityAudit{ - Actor: actorFromContext(c), + Actor: auditActor(c), Action: action, EventCategory: "orthrus_write", ResourceUUID: uuid, diff --git a/backend/internal/api/handlers/permission_helpers.go b/backend/internal/api/handlers/permission_helpers.go index 2d20f3706..067c311ff 100644 --- a/backend/internal/api/handlers/permission_helpers.go +++ b/backend/internal/api/handlers/permission_helpers.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" @@ -25,7 +26,7 @@ func requireAdmin(c *gin.Context) bool { } func requireAuthenticatedAdmin(c *gin.Context) bool { - if _, exists := c.Get("userID"); !exists { + if _, ok := middleware.CallerID(c); !ok { c.JSON(http.StatusUnauthorized, gin.H{ "error": "Authorization header required", }) @@ -36,7 +37,7 @@ func requireAuthenticatedAdmin(c *gin.Context) bool { } func isAdmin(c *gin.Context) bool { - return c.GetString("role") == string(models.RoleAdmin) + return middleware.CallerRole(c) == string(models.RoleAdmin) } func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool { @@ -103,13 +104,8 @@ func logPermissionAudit(securityService *services.SecurityService, c *gin.Contex } detailsJSON, _ := json.Marshal(details) - actor := "unknown" - if userID, ok := c.Get("userID"); ok { - actor = fmt.Sprintf("%v", userID) - } - _ = securityService.LogAudit(&models.SecurityAudit{ - Actor: actor, + Actor: auditActor(c), Action: action, EventCategory: "permissions", Details: string(detailsJSON), diff --git a/backend/internal/api/handlers/permission_helpers_test.go b/backend/internal/api/handlers/permission_helpers_test.go index f9d4fd774..7c1fb8beb 100644 --- a/backend/internal/api/handlers/permission_helpers_test.go +++ b/backend/internal/api/handlers/permission_helpers_test.go @@ -130,7 +130,7 @@ func TestRespondPermissionError_AdminWithAudit(t *testing.T) { var audits []models.SecurityAudit require.NoError(t, db.Find(&audits).Error) require.NotEmpty(t, audits) - assert.Equal(t, "77", audits[0].Actor) + assert.Equal(t, "user:77", audits[0].Actor) assert.Equal(t, "settings_save_failed", audits[0].Action) assert.Equal(t, "permissions", audits[0].EventCategory) } @@ -163,7 +163,7 @@ func TestLogPermissionAudit_ActorFallback(t *testing.T) { var audit models.SecurityAudit require.NoError(t, db.First(&audit).Error) - assert.Equal(t, "unknown", audit.Actor) + assert.Equal(t, ctx.ClientIP(), audit.Actor) assert.Equal(t, "backup_create_failed", audit.Action) assert.Equal(t, "permissions", audit.EventCategory) assert.Contains(t, audit.Details, fmt.Sprintf("\"admin\":%v", false)) diff --git a/backend/internal/api/handlers/pr_coverage_test.go b/backend/internal/api/handlers/pr_coverage_test.go index b9f0ee304..7e58a5b79 100644 --- a/backend/internal/api/handlers/pr_coverage_test.go +++ b/backend/internal/api/handlers/pr_coverage_test.go @@ -499,31 +499,6 @@ func TestAuditLogHandler_ListByProvider_InvalidProviderID(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -// ============================================================================= -// getActorFromGinContext Additional Coverage -// ============================================================================= - -func TestGetActorFromGinContext_InvalidUserIDType(t *testing.T) { - - router := gin.New() - var capturedActor string - router.Use(func(c *gin.Context) { - c.Set("user_id", 123.45) // float - invalid type - c.Next() - }) - router.GET("/test", func(c *gin.Context) { - capturedActor = getActorFromGinContext(c) - c.Status(http.StatusOK) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", http.NoBody) - router.ServeHTTP(w, req) - - // Should fall back to "system" for invalid type - assert.Equal(t, "system", capturedActor) -} - // ============================================================================= // isAdmin Additional Coverage // ============================================================================= diff --git a/backend/internal/api/handlers/security_event_intake_test.go b/backend/internal/api/handlers/security_event_intake_test.go index 7d90adfce..df961f7a9 100644 --- a/backend/internal/api/handlers/security_event_intake_test.go +++ b/backend/internal/api/handlers/security_event_intake_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" @@ -318,8 +319,7 @@ func TestSecurityEventIntakeR6Intact(t *testing.T) { // Add auth middleware that sets user context router.Use(func(c *gin.Context) { - c.Set("user_id", adminUser.ID) - c.Set("role", "admin") + middleware.SetCaller(c, adminUser.ID, "admin") c.Next() }) diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index 419e7d520..701a1d398 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -385,10 +385,7 @@ func (h *SecurityHandler) CreateDecision(c *gin.Context) { return } // Record an audit entry - actor := c.GetString("user_id") - if actor == "" { - actor = c.ClientIP() - } + actor := auditActor(c) _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details}) c.JSON(http.StatusOK, gin.H{"decision": payload}) } @@ -429,10 +426,7 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) { } } // Create an audit event - actor := c.GetString("user_id") - if actor == "" { - actor = c.ClientIP() - } + actor := auditActor(c) _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name}) c.JSON(http.StatusOK, gin.H{"ruleset": payload}) } @@ -467,10 +461,7 @@ func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) { return } } - actor := c.GetString("user_id") - if actor == "" { - actor = c.ClientIP() - } + actor := auditActor(c) _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam}) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -672,10 +663,7 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) { } // Log audit event - actor := c.GetString("user_id") - if actor == "" { - actor = c.ClientIP() - } + actor := auditActor(c) _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "reload_geoip", Details: "GeoIP database reloaded successfully"}) c.JSON(http.StatusOK, gin.H{ @@ -825,10 +813,7 @@ func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) { } // Log audit event - actor := c.GetString("user_id") - if actor == "" { - actor = c.ClientIP() - } + actor := auditActor(c) _ = h.svc.LogAudit(&models.SecurityAudit{ Actor: actor, Action: "add_waf_exclusion", @@ -916,10 +901,7 @@ func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) { } // Log audit event - actor := c.GetString("user_id") - if actor == "" { - actor = c.ClientIP() - } + actor := auditActor(c) _ = h.svc.LogAudit(&models.SecurityAudit{ Actor: actor, Action: "delete_waf_exclusion", diff --git a/backend/internal/api/handlers/system_permissions_handler.go b/backend/internal/api/handlers/system_permissions_handler.go index 80abb6786..303173b34 100644 --- a/backend/internal/api/handlers/system_permissions_handler.go +++ b/backend/internal/api/handlers/system_permissions_handler.go @@ -361,13 +361,8 @@ func (h *SystemPermissionsHandler) logAudit(c *gin.Context, action, result, code } payloadJSON, _ := json.Marshal(payload) - actor := "unknown" - if userID, ok := c.Get("userID"); ok { - actor = fmt.Sprintf("%v", userID) - } - _ = h.securityService.LogAudit(&models.SecurityAudit{ - Actor: actor, + Actor: auditActor(c), Action: action, EventCategory: "permissions", Details: string(payloadJSON), diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 8c3a1482d..fa5ad89e4 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -63,13 +63,6 @@ func (h *UserHandler) SetPasswordAttemptGuard(g PasswordAttemptGuard) { h.passwordGuard = g } -func (h *UserHandler) actorFromContext(c *gin.Context) string { - if userID, ok := c.Get("userID"); ok { - return fmt.Sprintf("%v", userID) - } - return c.ClientIP() -} - func (h *UserHandler) logUserAudit(c *gin.Context, action string, user *models.User, details map[string]any) { if h.securitySvc == nil || user == nil { return @@ -81,7 +74,7 @@ func (h *UserHandler) logUserAudit(c *gin.Context, action string, user *models.U } _ = h.securitySvc.LogAudit(&models.SecurityAudit{ - Actor: h.actorFromContext(c), + Actor: auditActor(c), Action: action, EventCategory: "user", ResourceID: &user.ID, @@ -230,7 +223,7 @@ func (h *UserHandler) Setup(c *gin.Context) { // rejectPassthrough aborts with 403 if the caller is a passthrough user. // Returns true if the request was rejected (caller should return). func rejectPassthrough(c *gin.Context, action string) bool { - if c.GetString("role") == string(models.RolePassthrough) { + if middleware.CallerRole(c) == string(models.RolePassthrough) { c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action}) return true } @@ -515,7 +508,10 @@ func (h *UserHandler) InviteUser(c *gin.Context) { return } - inviterID, _ := c.Get("userID") + inviterID, ok := requireUserID(c) + if !ok { + return + } var req InviteUserRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -555,7 +551,6 @@ func (h *UserHandler) InviteUser(c *gin.Context) { // Set invite expiration (48 hours) inviteExpires := time.Now().Add(48 * time.Hour) invitedAt := time.Now() - inviterIDUint := inviterID.(uint) user := models.User{ UUID: uuid.New().String(), @@ -567,7 +562,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) { InviteToken: inviteToken, InviteExpires: &inviteExpires, InvitedAt: &invitedAt, - InvitedBy: &inviterIDUint, + InvitedBy: &inviterID, InviteStatus: "pending", } @@ -745,15 +740,10 @@ type UpdateUserRequest struct { // UpdateUser updates an existing user (admin only for management fields, self-service for name/password). func (h *UserHandler) UpdateUser(c *gin.Context) { - currentRole := c.GetString("role") - currentUserIDRaw, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - currentUserID, ok := currentUserIDRaw.(uint) + currentRole := middleware.CallerRole(c) + currentUserID, ok := middleware.CallerID(c) if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } @@ -945,8 +935,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { return } - currentUserIDRaw, _ := c.Get("userID") - currentUserID, _ := currentUserIDRaw.(uint) + currentUserID, _ := middleware.CallerID(c) idParam := c.Param("id") id, err := strconv.ParseUint(idParam, 10, 32) diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 3e5f6e0ae..d9a2c8ef1 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -38,26 +38,6 @@ func TestMapsKeys(t *testing.T) { assert.Contains(t, keys, "enabled") } -func TestUserHandler_actorFromContext(t *testing.T) { - t.Parallel() - - handler, _ := setupUserHandler(t) - - rec1 := httptest.NewRecorder() - ctx1, _ := gin.CreateTestContext(rec1) - req1 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req1.RemoteAddr = "198.51.100.10:1234" - ctx1.Request = req1 - assert.Equal(t, "198.51.100.10", handler.actorFromContext(ctx1)) - - rec2 := httptest.NewRecorder() - ctx2, _ := gin.CreateTestContext(rec2) - req2 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - ctx2.Request = req2 - ctx2.Set("userID", uint(42)) - assert.Equal(t, "42", handler.actorFromContext(ctx2)) -} - func TestUserHandler_logUserAudit_NoOpBranches(t *testing.T) { t.Parallel() @@ -745,7 +725,7 @@ func TestUserHandler_CreateUser_Admin(t *testing.T) { var audit models.SecurityAudit require.NoError(t, db.Where("action = ? AND event_category = ?", "user_create", "user").First(&audit).Error) - assert.Equal(t, "99", audit.Actor) + assert.Equal(t, "user:99", audit.Actor) } func TestUserHandler_CreateUser_InvalidJSON(t *testing.T) { @@ -2789,8 +2769,8 @@ func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, w.Body.String(), "Invalid session") + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Authentication required") } // --- UpdateUser role/enabled restriction for non-admin self --- @@ -3077,3 +3057,22 @@ func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Contains(t, w.Body.String(), "Failed to invalidate sessions") } + +func TestUserHandler_InviteUser_RequiresSessionUser(t *testing.T) { + handler, _ := setupUserHandler(t) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body, _ := json.Marshal(map[string]string{"email": "invitee@example.com"}) + req := httptest.NewRequest(http.MethodPost, "/users/invite", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index ca0cf1779..04a1460eb 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -12,8 +12,7 @@ import ( func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { return func(c *gin.Context) { if IsEmergencyBypass(c) { - c.Set("role", "admin") - c.Set("userID", uint(0)) + SetCaller(c, 0, "admin") c.Next() return } @@ -35,8 +34,7 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { return } - c.Set("userID", user.ID) - c.Set("role", string(user.Role)) + SetCaller(c, user.ID, string(user.Role)) c.Next() } } @@ -96,7 +94,7 @@ func extractAuthCookieToken(c *gin.Context) string { func RequireRole(role models.UserRole) gin.HandlerFunc { return func(c *gin.Context) { - userRole := c.GetString("role") + userRole := CallerRole(c) if userRole == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return @@ -113,7 +111,7 @@ func RequireRole(role models.UserRole) gin.HandlerFunc { func RequireManagementAccess() gin.HandlerFunc { return func(c *gin.Context) { - role := c.GetString("role") + role := CallerRole(c) if role == string(models.RolePassthrough) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Pass-through users cannot access management features"}) return diff --git a/backend/internal/api/middleware/ctxkeys.go b/backend/internal/api/middleware/ctxkeys.go new file mode 100644 index 000000000..4471d0caf --- /dev/null +++ b/backend/internal/api/middleware/ctxkeys.go @@ -0,0 +1,47 @@ +package middleware + +import "github.com/gin-gonic/gin" + +// Context keys written by the authentication middleware. All reads and writes +// of caller identity go through the accessors below so that the key names and +// value types are defined in exactly one place. +const ( + // UserIDKey holds the authenticated caller's ID (uint). The value is 0 when + // the request was admitted through the emergency path. + UserIDKey = "userID" + // RoleKey holds the authenticated caller's role (string). + RoleKey = "role" +) + +// SetCaller records the caller identity on the request context. +func SetCaller(c *gin.Context, userID uint, role string) { + c.Set(UserIDKey, userID) + c.Set(RoleKey, role) +} + +// CallerID returns the authenticated caller's ID. ok is false when no identity +// is present or the stored value has an unexpected type. +func CallerID(c *gin.Context) (uint, bool) { + v, exists := c.Get(UserIDKey) + if !exists { + return 0, false + } + id, ok := v.(uint) + return id, ok +} + +// CallerRole returns the authenticated caller's role, or "" when absent. +func CallerRole(c *gin.Context) string { + v, exists := c.Get(RoleKey) + if !exists { + return "" + } + role, _ := v.(string) + return role +} + +// HasCallerRole reports whether a role has been recorded on the context. +func HasCallerRole(c *gin.Context) bool { + _, exists := c.Get(RoleKey) + return exists +} diff --git a/backend/internal/api/middleware/ctxkeys_test.go b/backend/internal/api/middleware/ctxkeys_test.go new file mode 100644 index 000000000..c6d0a513b --- /dev/null +++ b/backend/internal/api/middleware/ctxkeys_test.go @@ -0,0 +1,262 @@ +package middleware + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCallerAccessors(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("empty context", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + _, ok := CallerID(c) + assert.False(t, ok) + assert.Equal(t, "", CallerRole(c)) + assert.False(t, HasCallerRole(c)) + }) + + t.Run("recorded identity", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + SetCaller(c, 7, "admin") + id, ok := CallerID(c) + assert.True(t, ok) + assert.Equal(t, uint(7), id) + assert.Equal(t, "admin", CallerRole(c)) + assert.True(t, HasCallerRole(c)) + }) + + t.Run("unexpected value types are ignored", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + c.Set(UserIDKey, "7") + c.Set(RoleKey, 1) + _, ok := CallerID(c) + assert.False(t, ok) + assert.Equal(t, "", CallerRole(c)) + }) +} + +var identityKeys = map[string]bool{"userID": true, "user_id": true, "role": true} + +// identityAccessMethods are the context read/write methods that take a key. +var identityAccessMethods = map[string]bool{ + "Get": true, "GetString": true, "GetUint": true, "GetInt": true, + "MustGet": true, "Set": true, "Value": true, +} + +// collectStringConsts records every string-valued const/var declaration in the +// file by name, so aliases of the identity keys can be resolved. +func collectStringConsts(file *ast.File, out map[string]ast.Expr) { + ast.Inspect(file, func(n ast.Node) bool { + vs, ok := n.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range vs.Names { + if i < len(vs.Values) { + out[name.Name] = vs.Values[i] + } + } + return true + }) +} + +// resolveKey returns the string value an expression denotes when it is a +// literal, or an identifier chain leading to one. The exported key constants +// are reported as their own marker. +func resolveKey(e ast.Expr, consts map[string]ast.Expr, depth int) (string, bool) { + if depth > 8 { + return "", false + } + switch x := e.(type) { + case *ast.BasicLit: + if x.Kind == token.STRING { + v, err := strconv.Unquote(x.Value) + return v, err == nil + } + case *ast.ParenExpr: + return resolveKey(x.X, consts, depth+1) + case *ast.CallExpr: // string(...) conversions + if id, ok := x.Fun.(*ast.Ident); ok && id.Name == "string" && len(x.Args) == 1 { + return resolveKey(x.Args[0], consts, depth+1) + } + case *ast.Ident: + if v, ok := consts[x.Name]; ok { + return resolveKey(v, consts, depth+1) + } + case *ast.SelectorExpr: + if v, ok := consts[x.Sel.Name]; ok { + return resolveKey(v, consts, depth+1) + } + } + return "", false +} + +func isKeyConstRef(e ast.Expr) bool { + switch x := e.(type) { + case *ast.Ident: + return x.Name == "UserIDKey" || x.Name == "RoleKey" + case *ast.SelectorExpr: + return x.Sel.Name == "UserIDKey" || x.Sel.Name == "RoleKey" + } + return false +} + +// identityViolations reports every way a file reads or writes the caller +// identity other than through the accessors in ctxkeys.go. +func identityViolations(fset *token.FileSet, file *ast.File, consts map[string]ast.Expr) []string { + var out []string + report := func(n ast.Node, msg string) { + out = append(out, fset.Position(n.Pos()).String()+": "+msg) + } + isIdentity := func(e ast.Expr) bool { + v, ok := resolveKey(e, consts, 0) + return ok && identityKeys[v] + } + + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.CallExpr: + sel, ok := x.Fun.(*ast.SelectorExpr) + if ok && identityAccessMethods[sel.Sel.Name] && len(x.Args) > 0 && isIdentity(x.Args[0]) { + report(x, "raw identity key; use the accessors in ctxkeys.go") + } + case *ast.IndexExpr: + if sel, ok := x.X.(*ast.SelectorExpr); ok && sel.Sel.Name == "Keys" && isIdentity(x.Index) { + report(x, "raw identity key in Keys map; use the accessors in ctxkeys.go") + } + case *ast.TypeAssertExpr: + if id, ok := x.Type.(*ast.Ident); ok && id.Name == "uint" { + report(x, "type assertion to uint; use middleware.CallerID") + } + case *ast.Ident: + if x.Name == "UserIDKey" || x.Name == "RoleKey" { + report(x, "identity key constant used outside ctxkeys.go") + } + case *ast.SelectorExpr: + if isKeyConstRef(x) && x.Sel.Name != "" { + report(x, "identity key constant used outside ctxkeys.go") + } + } + return true + }) + return out +} + +func parseSnippet(t *testing.T, src string) (*token.FileSet, *ast.File) { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "snippet.go", "package p\n"+src, 0) + require.NoError(t, err) + return fset, file +} + +func TestIdentityGuardFlagsEveryAccessShape(t *testing.T) { + flagged := map[string]string{ + "literal Get": `func f(c *gin.Context) { c.Get("userID") }`, + "literal GetString": `func f(c *gin.Context) { _ = c.GetString("user_id") }`, + "literal role": `func f(c *gin.Context) { _ = c.GetString("role") }`, + "literal Set": `func f(c *gin.Context) { c.Set("role", "admin") }`, + "MustGet": `func f(c *gin.Context) { _ = c.MustGet("userID") }`, + "GetUint": `func f(c *gin.Context) { _ = c.GetUint("userID") }`, + "Value read": `func f(c *gin.Context) { _ = c.Request.Context().Value("user_id") }`, + "aliased const": `const k = "userID"; func f(c *gin.Context) { c.Get(k) }`, + "chained alias": `const a = "role"; const b = a; func f(c *gin.Context) { c.Get(b) }`, + "aliased var": `var k = "user_id"; func f(c *gin.Context) { c.Get(k) }`, + "typed const": `const k string = "role"; func f(c *gin.Context) { c.Get(k) }`, + "string conversion": `func f(c *gin.Context) { c.Get(string("role")) }`, + "Keys index": `func f(c *gin.Context) { _ = c.Keys["userID"] }`, + "Keys index via const": `const k = "role"; func f(c *gin.Context) { _ = c.Keys[k] }`, + "exported const": `func f(c *gin.Context) { c.Get(middleware.UserIDKey) }`, + "bare exported const": `func f(c *gin.Context) { c.Set(RoleKey, "x") }`, + "uint assertion": `func f(v any) { _ = v.(uint) }`, + } + for name, src := range flagged { + t.Run("flags "+name, func(t *testing.T) { + fset, file := parseSnippet(t, src) + consts := map[string]ast.Expr{} + collectStringConsts(file, consts) + assert.NotEmpty(t, identityViolations(fset, file, consts)) + }) + } + + allowed := map[string]string{ + "accessor use": `func f(c *gin.Context) { _, _ = middleware.CallerID(c); _ = middleware.CallerRole(c) }`, + "unrelated key": `func f(c *gin.Context) { c.Get("emergency_bypass") }`, + "json map key": `func f() { m := map[string]any{"role": 1, "user_id": 2}; _ = m["role"] }`, + "gin.H literal": `func f() { _ = gin.H{"user_id": 1, "role": "x"} }`, + "query param": `func f(c *gin.Context) { _ = c.Query("role") }`, + "other keys index": `func f(c *gin.Context) { _ = c.Keys["other"] }`, + "non-uint assertion": `func f(v any) { _, _ = v.(string) }`, + } + for name, src := range allowed { + t.Run("allows "+name, func(t *testing.T) { + fset, file := parseSnippet(t, src) + consts := map[string]ast.Expr{} + collectStringConsts(file, consts) + assert.Empty(t, identityViolations(fset, file, consts)) + }) + } +} + +func importsGin(file *ast.File) bool { + for _, imp := range file.Imports { + if imp.Path.Value == `"github.com/gin-gonic/gin"` { + return true + } + } + return false +} + +// TestCallerIdentityAccessedThroughAccessors keeps request identity access in +// one place: outside ctxkeys.go, no non-test code under internal/ or cmd/ that +// handles gin contexts may +// use raw context keys (literal or aliased), the Keys map, the exported key +// constants, or unchecked unsigned-integer assertions. +func TestCallerIdentityAccessedThroughAccessors(t *testing.T) { + roots := []string{"../../../internal", "../../../cmd"} + fset := token.NewFileSet() + var files []*ast.File + consts := map[string]ast.Expr{} + + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if d.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + return nil + } + if name == "ctxkeys.go" && filepath.Base(filepath.Dir(path)) == "middleware" { + return nil + } + file, perr := parser.ParseFile(fset, path, nil, 0) + require.NoError(t, perr, path) + if !importsGin(file) { + return nil + } + collectStringConsts(file, consts) + files = append(files, file) + return nil + }) + require.NoError(t, err) + } + require.Greater(t, len(files), 50, "guard scanned too few files; check roots") + + for _, file := range files { + for _, v := range identityViolations(fset, file, consts) { + t.Error(v) + } + } +} diff --git a/backend/internal/api/middleware/optional_auth.go b/backend/internal/api/middleware/optional_auth.go index ad3c03b1b..fa5ddc4bf 100644 --- a/backend/internal/api/middleware/optional_auth.go +++ b/backend/internal/api/middleware/optional_auth.go @@ -18,7 +18,7 @@ func OptionalAuth(authService *services.AuthService) gin.HandlerFunc { return } - if _, exists := c.Get("role"); exists { + if HasCallerRole(c) { c.Next() return } @@ -35,8 +35,7 @@ func OptionalAuth(authService *services.AuthService) gin.HandlerFunc { return } - c.Set("userID", user.ID) - c.Set("role", string(user.Role)) + SetCaller(c, user.ID, string(user.Role)) c.Next() } } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index b010a7ce6..acbd5d889 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -882,6 +882,10 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg logger.Log().WithError(err).Warn("Failed to apply uptime retention default migration; will retry next boot") } + if err := services.ExpireOwnerlessChallenges(db); err != nil { + logger.Log().WithError(err).Warn("Failed to close manual DNS challenges without an owner; will retry next boot") + } + // Ensure security header presets exist secHeadersSvc := services.NewSecurityHeadersService(db) if err := secHeadersSvc.EnsurePresetsExist(); err != nil { diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index 7876e6fd0..9f6ad3098 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -277,28 +277,11 @@ func (c *Cerberus) NotifySecurityEvent(ctx *gin.Context, event models.SecurityEv } func (c *Cerberus) isAuthenticatedAdmin(ctx *gin.Context) bool { - role, exists := ctx.Get("role") - if !exists { - return false - } - roleStr, ok := role.(string) - if !ok || roleStr != string(models.RoleAdmin) { - return false - } - userID, exists := ctx.Get("userID") - if !exists { - return false - } - switch id := userID.(type) { - case uint: - return id > 0 - case int: - return id > 0 - case int64: - return id > 0 - default: + if middleware.CallerRole(ctx) != string(models.RoleAdmin) { return false } + id, ok := middleware.CallerID(ctx) + return ok && id > 0 } func (c *Cerberus) adminWhitelistStatus(clientIP string) (configured, allowed bool) { diff --git a/backend/internal/services/backup_service.go b/backend/internal/services/backup_service.go index 9839c4c5c..8c6d79286 100644 --- a/backend/internal/services/backup_service.go +++ b/backend/internal/services/backup_service.go @@ -262,7 +262,7 @@ func (s *BackupService) SetUptimeRehydrator(r UptimeRehydrator) { // needs to write a permission-denied SecurityAudit row from inside the job // (where no gin.Context is available) — captured synchronously by the // handler, from the exact same gin.Context fields -// handlers.logPermissionAudit already reads today (c.Get("userID"), +// handlers.logPermissionAudit already reads today (the caller identity, // c.ClientIP(), c.Request.UserAgent()). Admin is not carried here — // Create/Restore are already requireAdmin-gated before a Start*Job call is // reached, so it is always true in this context. diff --git a/backend/internal/services/manual_challenge_migration.go b/backend/internal/services/manual_challenge_migration.go new file mode 100644 index 000000000..6fb0af502 --- /dev/null +++ b/backend/internal/services/manual_challenge_migration.go @@ -0,0 +1,58 @@ +package services + +import ( + "fmt" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" + "gorm.io/gorm" +) + +// OwnerlessChallengeMigrationMarker is the settings row recording that the +// one-time cleanup of challenges without an owner has run. The "migration." +// prefix keeps it out of the settings API (see isInternalSettingKey). +const OwnerlessChallengeMigrationMarker = "migration.manual_challenge_ownerless_expired" + +// ExpireOwnerlessChallenges marks still-active challenges that have no owning +// user (user_id = 0) as expired. Such rows cannot be reached by any signed-in +// user, so leaving them active would only block new challenges for the same +// domain until they time out. A marker row makes the cleanup one-shot and +// idempotent; rows are only ever moved from an active state to expired. +func ExpireOwnerlessChallenges(db *gorm.DB) error { + var markers int64 + if err := db.Model(&models.Setting{}). + Where("key = ?", OwnerlessChallengeMigrationMarker).Count(&markers).Error; err != nil { + return fmt.Errorf("check challenge cleanup marker: %w", err) + } + if markers > 0 { + return nil + } + + res := db.Model(&models.ManualChallenge{}). + Where("user_id = ? AND status IN ?", 0, []models.ChallengeStatus{ + models.ChallengeStatusCreated, + models.ChallengeStatusPending, + models.ChallengeStatusVerifying, + }). + Updates(map[string]any{ + "status": models.ChallengeStatusExpired, + "error_message": "Challenge closed: no owning user", + }) + if res.Error != nil { + return fmt.Errorf("expire ownerless challenges: %w", res.Error) + } + if res.RowsAffected > 0 { + logger.Log().WithField("count", res.RowsAffected).Info("closed manual DNS challenges that had no owning user") + } + + marker := models.Setting{ + Key: OwnerlessChallengeMigrationMarker, + Value: "done", + Type: "string", + Category: "migration", + } + if err := db.Where(models.Setting{Key: marker.Key}).Attrs(marker).FirstOrCreate(&marker).Error; err != nil { + return fmt.Errorf("write challenge cleanup marker: %w", err) + } + return nil +} diff --git a/backend/internal/services/manual_challenge_migration_test.go b/backend/internal/services/manual_challenge_migration_test.go new file mode 100644 index 000000000..3b541a90b --- /dev/null +++ b/backend/internal/services/manual_challenge_migration_test.go @@ -0,0 +1,95 @@ +package services + +import ( + "testing" + "time" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupChallengeMigrationDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ManualChallenge{}, &models.Setting{})) + return db +} + +func seedChallenge(t *testing.T, db *gorm.DB, id string, userID uint, status models.ChallengeStatus) { + t.Helper() + require.NoError(t, db.Create(&models.ManualChallenge{ + ID: id, ProviderID: 1, UserID: userID, FQDN: id + ".example.com", + Value: "v", Status: status, ExpiresAt: time.Now().Add(time.Hour), + }).Error) +} + +func challengeStatus(t *testing.T, db *gorm.DB, id string) models.ChallengeStatus { + t.Helper() + var ch models.ManualChallenge + require.NoError(t, db.First(&ch, "id = ?", id).Error) + return ch.Status +} + +func TestExpireOwnerlessChallenges(t *testing.T) { + db := setupChallengeMigrationDB(t) + seedChallenge(t, db, "orphan-pending", 0, models.ChallengeStatusPending) + seedChallenge(t, db, "orphan-created", 0, models.ChallengeStatusCreated) + seedChallenge(t, db, "orphan-verifying", 0, models.ChallengeStatusVerifying) + seedChallenge(t, db, "orphan-verified", 0, models.ChallengeStatusVerified) + seedChallenge(t, db, "owned-pending", 7, models.ChallengeStatusPending) + + require.NoError(t, ExpireOwnerlessChallenges(db)) + + assert.Equal(t, models.ChallengeStatusExpired, challengeStatus(t, db, "orphan-pending")) + assert.Equal(t, models.ChallengeStatusExpired, challengeStatus(t, db, "orphan-created")) + assert.Equal(t, models.ChallengeStatusExpired, challengeStatus(t, db, "orphan-verifying")) + assert.Equal(t, models.ChallengeStatusVerified, challengeStatus(t, db, "orphan-verified"), "terminal rows are untouched") + assert.Equal(t, models.ChallengeStatusPending, challengeStatus(t, db, "owned-pending"), "owned rows are untouched") + + var markers int64 + require.NoError(t, db.Model(&models.Setting{}).Where("key = ?", OwnerlessChallengeMigrationMarker).Count(&markers).Error) + assert.Equal(t, int64(1), markers) +} + +func TestExpireOwnerlessChallenges_RunsOnce(t *testing.T) { + db := setupChallengeMigrationDB(t) + require.NoError(t, ExpireOwnerlessChallenges(db)) + + // A row appearing after the first run is left alone: the cleanup is one-shot. + seedChallenge(t, db, "late-orphan", 0, models.ChallengeStatusPending) + require.NoError(t, ExpireOwnerlessChallenges(db)) + + assert.Equal(t, models.ChallengeStatusPending, challengeStatus(t, db, "late-orphan")) + + var markers int64 + require.NoError(t, db.Model(&models.Setting{}).Where("key = ?", OwnerlessChallengeMigrationMarker).Count(&markers).Error) + assert.Equal(t, int64(1), markers) +} + +func TestExpireOwnerlessChallenges_DatabaseError(t *testing.T) { + db := setupChallengeMigrationDB(t) + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + assert.Error(t, ExpireOwnerlessChallenges(db)) +} + +func TestExpireOwnerlessChallenges_UpdateError(t *testing.T) { + db := setupChallengeMigrationDB(t) + require.NoError(t, db.Migrator().DropTable(&models.ManualChallenge{})) + + assert.Error(t, ExpireOwnerlessChallenges(db)) +} + +func TestExpireOwnerlessChallenges_MarkerWriteError(t *testing.T) { + db := setupChallengeMigrationDB(t) + require.NoError(t, db.Exec(`CREATE TRIGGER reject_marker BEFORE INSERT ON settings + BEGIN SELECT RAISE(ABORT, 'rejected'); END`).Error) + + assert.Error(t, ExpireOwnerlessChallenges(db)) +} diff --git a/backend/internal/services/proxyhost_match.go b/backend/internal/services/proxyhost_match.go new file mode 100644 index 000000000..1eb978227 --- /dev/null +++ b/backend/internal/services/proxyhost_match.go @@ -0,0 +1,164 @@ +package services + +import ( + "errors" + "fmt" + "net/netip" + "strings" + "unicode" + + "golang.org/x/net/idna" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// ErrInvalidHostName is returned when a request host cannot be normalized to a +// plain DNS name or IP literal. +var ErrInvalidHostName = errors.New("invalid host name") + +// NormalizeHostName converts a host as seen in a request (optionally with a +// port, bracketed IPv6 literal or trailing dot) into its canonical lowercase, +// ASCII form for exact comparison. Hosts that are empty or contain control +// characters, spaces, '%' or '*' are rejected. +func NormalizeHostName(raw string) (string, error) { + host := strings.TrimSpace(raw) + if host == "" { + return "", ErrInvalidHostName + } + for _, r := range host { + if unicode.IsControl(r) || unicode.IsSpace(r) || r == '%' || r == '*' { + return "", ErrInvalidHostName + } + } + + host = stripPort(host) + host = strings.TrimSuffix(host, ".") + if host == "" { + return "", ErrInvalidHostName + } + + if addr, err := netip.ParseAddr(host); err == nil { + return addr.Unmap().String(), nil + } + if strings.ContainsAny(host, ":[]") { + return "", ErrInvalidHostName + } + + host = strings.ToLower(host) + if !isASCII(host) { + ascii, err := idna.Lookup.ToASCII(host) + if err != nil || ascii == "" { + return "", ErrInvalidHostName + } + host = strings.ToLower(ascii) + } + return host, nil +} + +// stripPort removes a trailing :port and the brackets around IPv6 literals. +func stripPort(host string) string { + if strings.HasPrefix(host, "[") { + end := strings.Index(host, "]") + if end < 0 { + return host + } + rest := host[end+1:] + if rest == "" || strings.HasPrefix(rest, ":") { + return host[1:end] + } + return host + } + // A single colon separates host and port; several colons mean a bare IPv6 literal. + if strings.Count(host, ":") == 1 { + h, _, _ := strings.Cut(host, ":") + return h + } + return host +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { + return false + } + } + return true +} + +// MatchProxyHosts returns the hosts whose domain list covers requestHost. +// Domain lists are comma-separated; each entry is compared exactly after +// normalization. A stored wildcard such as "*.example.com" covers exactly one +// additional label. Exact matches take precedence: wildcard matches are only +// returned when no host matches exactly. +func MatchProxyHosts(hosts []models.ProxyHost, requestHost string) ([]models.ProxyHost, error) { + want, err := NormalizeHostName(requestHost) + if err != nil { + return nil, err + } + + var exact, wildcard []models.ProxyHost + for i := range hosts { + kind := matchDomainList(hosts[i].DomainNames, want) + switch kind { + case matchExact: + exact = append(exact, hosts[i]) + case matchWildcard: + wildcard = append(wildcard, hosts[i]) + } + } + if len(exact) > 0 { + return exact, nil + } + return wildcard, nil +} + +type matchKind int + +const ( + matchNone matchKind = iota + matchWildcard + matchExact +) + +func matchDomainList(domainNames, want string) matchKind { + best := matchNone + for _, entry := range strings.Split(domainNames, ",") { + entry = strings.TrimSpace(entry) + isWildcard := false + if rest, ok := strings.CutPrefix(entry, "*."); ok { + isWildcard = true + entry = rest + } + have, err := NormalizeHostName(entry) + if err != nil { + continue + } + switch { + case !isWildcard && have == want: + return matchExact + case isWildcard && coversOneLabel(have, want): + best = matchWildcard + } + } + return best +} + +// coversOneLabel reports whether want is base with exactly one extra label. +func coversOneLabel(base, want string) bool { + label, ok := strings.CutSuffix(want, "."+base) + return ok && label != "" && !strings.Contains(label, ".") +} + +// FindProxyHostsByDomain loads the proxy hosts whose domain list covers +// requestHost. An empty result with a nil error means no host matches. +func FindProxyHostsByDomain(db *gorm.DB, requestHost string) ([]models.ProxyHost, error) { + if _, err := NormalizeHostName(requestHost); err != nil { + return nil, err + } + var hosts []models.ProxyHost + if err := db.Select("id", "domain_names", "forward_auth_enabled").Find(&hosts).Error; err != nil { + return nil, fmt.Errorf("load proxy hosts: %w", err) + } + return MatchProxyHosts(hosts, requestHost) +} diff --git a/backend/internal/services/proxyhost_match_test.go b/backend/internal/services/proxyhost_match_test.go new file mode 100644 index 000000000..e2859c705 --- /dev/null +++ b/backend/internal/services/proxyhost_match_test.go @@ -0,0 +1,166 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +func TestNormalizeHostName(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want string + wantErr bool + }{ + {in: "example.com", want: "example.com"}, + {in: " Example.COM ", want: "example.com"}, + {in: "example.com.", want: "example.com"}, + {in: "example.com:8443", want: "example.com"}, + {in: "example.com.:443", want: "example.com"}, + {in: "my_host.internal", want: "my_host.internal"}, + {in: "10.0.0.5", want: "10.0.0.5"}, + {in: "10.0.0.5:8080", want: "10.0.0.5"}, + {in: "[::1]", want: "::1"}, + {in: "[::1]:8080", want: "::1"}, + {in: "::1", want: "::1"}, + {in: "[2001:DB8::1]:443", want: "2001:db8::1"}, + {in: "::ffff:10.0.0.5", want: "10.0.0.5"}, + {in: "bücher.example", want: "xn--bcher-kva.example"}, + {in: "a\u0378.example", wantErr: true}, + {in: "example.com:", want: "example.com"}, + {in: "", wantErr: true}, + {in: " ", wantErr: true}, + {in: ".", wantErr: true}, + {in: "%", wantErr: true}, + {in: "%.example.com", wantErr: true}, + {in: "*.example.com", wantErr: true}, + {in: "exa mple.com", wantErr: true}, + {in: "example.com\n", want: "example.com"}, + {in: "exa\x00mple.com", wantErr: true}, + {in: "exa\tmple.com", wantErr: true}, + {in: "fe80::1%eth0", wantErr: true}, + {in: "[::1", wantErr: true}, + {in: "a:b:c", wantErr: true}, + {in: "[::1]x", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + got, err := NormalizeHostName(tt.in) + if tt.wantErr { + assert.ErrorIs(t, err, ErrInvalidHostName) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMatchProxyHosts(t *testing.T) { + t.Parallel() + hosts := []models.ProxyHost{ + {ID: 1, DomainNames: "example.com, www.example.com"}, + {ID: 2, DomainNames: "*.example.com"}, + {ID: 3, DomainNames: "api.example.com"}, + {ID: 4, DomainNames: "notexample.com"}, + {ID: 5, DomainNames: "My_Host.Internal:8443"}, + {ID: 6, DomainNames: "10.0.0.5"}, + {ID: 7, DomainNames: "::1"}, + {ID: 8, DomainNames: "bücher.example"}, + {ID: 9, DomainNames: "*.wild.test"}, + {ID: 10, DomainNames: "bad host, listed.test"}, + } + ids := func(hs []models.ProxyHost) []uint { + out := make([]uint, 0, len(hs)) + for _, h := range hs { + out = append(out, h.ID) + } + return out + } + + tests := []struct { + name string + host string + want []uint + }{ + {"exact", "example.com", []uint{1}}, + {"second entry in list", "www.example.com", []uint{1}}, + {"uppercase and port", "EXAMPLE.com:8443", []uint{1}}, + {"trailing dot", "example.com.", []uint{1}}, + {"near miss prefix", "notexample.com", []uint{4}}, + {"near miss suffix", "example.com.evil.test", nil}, + {"suffix of stored name", "ample.com", nil}, + {"exact beats wildcard", "api.example.com", []uint{3}}, + {"wildcard one label", "a.example.com", []uint{2}}, + {"wildcard two labels", "a.b.example.com", nil}, + {"wildcard base itself", "wild.test", nil}, + {"wildcard only host", "x.wild.test", []uint{9}}, + {"underscore name", "my_host.internal", []uint{5}}, + {"ipv4", "10.0.0.5:80", []uint{6}}, + {"ipv6 literal", "[::1]:8080", []uint{7}}, + {"idna", "BÜCHER.example", []uint{8}}, + {"unknown", "other.test", nil}, + {"unusable stored entry is skipped", "listed.test", []uint{10}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := MatchProxyHosts(hosts, tt.host) + require.NoError(t, err) + if tt.want == nil { + assert.Empty(t, got) + return + } + assert.Equal(t, tt.want, ids(got)) + }) + } + + for _, bad := range []string{"%", "%example%", "_", "*", "", "a b"} { + t.Run("rejected "+bad, func(t *testing.T) { + t.Parallel() + got, err := MatchProxyHosts(hosts, bad) + if bad == "_" { + // Underscore is a legal name character; it simply matches nothing here. + require.NoError(t, err) + assert.Empty(t, got) + return + } + assert.ErrorIs(t, err, ErrInvalidHostName) + assert.Empty(t, got) + }) + } +} + +func TestFindProxyHostsByDomain(t *testing.T) { + t.Parallel() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{})) + require.NoError(t, db.Create(&models.ProxyHost{UUID: "u1", Name: "a", DomainNames: "app.example.com", ForwardAuthEnabled: true}).Error) + + got, err := FindProxyHostsByDomain(db, "app.example.com:443") + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].ForwardAuthEnabled) + + got, err = FindProxyHostsByDomain(db, "example.com") + require.NoError(t, err) + assert.Empty(t, got) + + _, err = FindProxyHostsByDomain(db, "%") + assert.ErrorIs(t, err, ErrInvalidHostName) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + _, err = FindProxyHostsByDomain(db, "app.example.com") + assert.Error(t, err) + assert.NotErrorIs(t, err, ErrInvalidHostName) +} diff --git a/backend/internal/services/security_service.go b/backend/internal/services/security_service.go index 06d8be963..13d778d96 100644 --- a/backend/internal/services/security_service.go +++ b/backend/internal/services/security_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "strconv" "strings" "sync" "sync/atomic" @@ -339,6 +340,18 @@ type AuditLogFilter struct { EndDate *time.Time } +// actorFilterValues returns the stored actor values a filter should match. +// Entries written before the "user:" format recorded the bare numeric ID, +// so a "user:" filter also matches that legacy form. +func actorFilterValues(actor string) []string { + if id, ok := strings.CutPrefix(actor, "user:"); ok && id != "" { + if _, err := strconv.ParseUint(id, 10, 64); err == nil { + return []string{actor, id} + } + } + return []string{actor} +} + // ListAuditLogs retrieves audit logs with pagination and filtering func (s *SecurityService) ListAuditLogs(filter AuditLogFilter, page, limit int) ([]models.SecurityAudit, int64, error) { var audits []models.SecurityAudit @@ -348,7 +361,7 @@ func (s *SecurityService) ListAuditLogs(filter AuditLogFilter, page, limit int) query := s.db.Model(&models.SecurityAudit{}) if filter.Actor != "" { - query = query.Where("actor = ?", filter.Actor) + query = query.Where("actor IN ?", actorFilterValues(filter.Actor)) } if filter.Action != "" { query = query.Where("action = ?", filter.Action) diff --git a/backend/internal/services/security_service_test.go b/backend/internal/services/security_service_test.go index 7f8b12e9c..0cc8f889e 100644 --- a/backend/internal/services/security_service_test.go +++ b/backend/internal/services/security_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -1041,3 +1042,27 @@ func TestSecurityService_ListRuleSets_EdgeCases(t *testing.T) { assert.Len(t, rulesets, 5) }) } + +func TestSecurityService_ListAuditLogs_ActorFormats(t *testing.T) { + db := setupSecurityTestDB(t) + svc := newTestSecurityService(t, db) + + for i, actor := range []string{"user:7", "7", "70", "user:70", "user:abc", "alice"} { + require.NoError(t, db.Create(&models.SecurityAudit{ + UUID: fmt.Sprintf("actor-%d", i), Actor: actor, Action: "a", EventCategory: "c", + }).Error) + } + + count := func(filter string) int { + _, total, err := svc.ListAuditLogs(AuditLogFilter{Actor: filter}, 1, 50) + require.NoError(t, err) + return int(total) + } + + assert.Equal(t, 2, count("user:7"), "matches current and legacy bare numeric forms") + assert.Equal(t, 2, count("user:70")) + assert.Equal(t, 1, count("7"), "bare filter is exact") + assert.Equal(t, 1, count("user:abc"), "non-numeric ids are exact") + assert.Equal(t, 1, count("alice")) + assert.Equal(t, 0, count("user:"), "empty id is exact") +} From 04e07f9779c1d0a7dae6021c00b873a9c473b51b Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 04:09:08 +0000 Subject: [PATCH 07/57] docs: update security configuration notes --- ARCHITECTURE.md | 1 + docs/configuration/emergency-setup.md | 2 ++ docs/configuration/trusted-proxies.md | 10 +++++++++- docs/features/proxy-headers.md | 6 ++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfec9e466..ea5c28ce0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -350,6 +350,7 @@ fork/offline fallback. - **Handlers:** Process HTTP requests, validate input, return responses - **Middleware:** CORS, GZIP, authentication, logging, metrics, panic recovery +- **Request origin:** The `SelfHop` middleware is first in the engine's chain. For proxy hosts whose upstream is Charon itself, Caddy attaches an authenticated per-boot header (`internal/caddy/self_upstream.go`); the middleware verifies it and records the client address, scheme and host (`RequestOrigin`) for later handlers. Shared helpers live in `internal/security/selfhop`. - **Routes:** Route registration and grouping (public, authenticated, and admin-only — see [Management API Authentication & Authorization](#management-api-authentication--authorization)) **Example Endpoints:** diff --git a/docs/configuration/emergency-setup.md b/docs/configuration/emergency-setup.md index c3c204447..c3343815d 100644 --- a/docs/configuration/emergency-setup.md +++ b/docs/configuration/emergency-setup.md @@ -92,6 +92,8 @@ environment: - CHARON_MANAGEMENT_CIDRS=0.0.0.0/0,::/0 ``` +The emergency server uses this same list, with the same defaults as the main Charon API (private ranges plus localhost). If you connect from anywhere else, such as Tailscale (`100.64.0.0/10`) or a public VPN range, add that range here or the emergency server will refuse you. + **Security Notes:** - Be as restrictive as possible diff --git a/docs/configuration/trusted-proxies.md b/docs/configuration/trusted-proxies.md index 764954d2e..a124b2fc1 100644 --- a/docs/configuration/trusted-proxies.md +++ b/docs/configuration/trusted-proxies.md @@ -47,13 +47,21 @@ Avoid ranges such as `10.0.0.0/8` or `192.168.0.0/16`. They trust every device i | Your setup | Add this | | --- | --- | -| A proxy host inside Charon that points to `localhost:8080` | `127.0.0.1/32` and `::1/128` (list both) | +| A proxy host inside Charon that points to `localhost:8080` (or `127.0.0.1:8080`) | Nothing. Charon recognises this automatically (see below) | | A proxy host inside Charon that points to `charon:8080` | Charon's own address on the Docker network | | External nginx or Traefik in the same Docker network | That container's address on the Docker network | | A proxy running on the host itself | The address Charon sees it connect from. Find it on the Security page (see below) | Loopback has two forms. `127.0.0.1` and `::1` are treated as different addresses, so list both if you aren't sure which your proxy uses. +### Proxy hosts that point back at Charon + +If you create a proxy host whose destination is Charon itself (for example `localhost:8080` or `127.0.0.1:8080`, on Charon's own port), Charon spots this on its own. It passes along the real visitor address that Caddy saw, so you no longer need to add `127.0.0.1/32` and `::1/128` to `CHARON_TRUSTED_PROXIES` for this case. If you have set up trusted proxies, they keep working as before. + +Destinations reached by a Docker network name, such as `charon:8080`, are not recognised automatically. Keep listing those in `CHARON_TRUSTED_PROXIES`. + +If another proxy terminates HTTPS in front of Charon and is not in `CHARON_TRUSTED_PROXIES`, Caddy sees plain HTTP, so Charon may not mark the sign-in cookie `Secure`. Add that proxy to `CHARON_TRUSTED_PROXIES` so the original scheme is used. + ### Keep the address from changing Docker can hand out a new address when a container is recreated. Give your proxy a fixed address (a static IP in your Compose network), or put it in its own small network, so the entry stays correct. diff --git a/docs/features/proxy-headers.md b/docs/features/proxy-headers.md index d77730feb..7382f14f1 100644 --- a/docs/features/proxy-headers.md +++ b/docs/features/proxy-headers.md @@ -94,6 +94,12 @@ config.action_dispatch.trusted_proxies = [IPAddr.new('10.0.0.0/8')] protected $proxies = '*'; ``` +### Proxy Hosts That Point Back at Charon + +If a proxy host's destination is Charon itself (on Charon's own port, such as `localhost:8080`), Charon handles the headers for you. The real visitor address, the original web address, and whether the visit used HTTPS all arrive correctly with no extra setup. See [Trusted Proxies](../configuration/trusted-proxies.md). + +Sign-in cookies are marked `Secure` whenever the visitor is on HTTPS or is not on a private network, so browsers only send them over protected connections. + ## When to Enable vs Disable ### Enable When From 0fa49c50b774c5e58395425d6fd9e9dfb19f3b09 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 06:34:30 +0000 Subject: [PATCH 08/57] docs: update account management notes --- docs/api.md | 28 ++++++++++++++++++++++++++++ docs/features/login-protection.md | 3 ++- docs/features/user-accounts.md | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 74b4be654..3903e0527 100644 --- a/docs/api.md +++ b/docs/api.md @@ -582,6 +582,34 @@ resendInvite(42); --- +#### Update User + +Update a user's name, email, role, enabled state, or password. + +```http +PUT /users/:id +Content-Type: application/json +Authorization: Bearer +``` + +**Request Body (all fields optional):** + +```json +{ + "name": "Jane Doe", + "password": "a-new-password", + "current_password": "the-old-password" +} +``` + +**Password changes:** + +- Changing your **own** password requires `current_password`. +- An administrator resetting **another** user's password does not need it. +- Any password change ends that user's existing sessions. If you change your own password this way, you are signed out and need to sign in again. + +--- + #### Test URL Connectivity Test if a URL is reachable from the server with comprehensive SSRF (Server-Side Request Forgery) protection. diff --git a/docs/features/login-protection.md b/docs/features/login-protection.md index da59b39ec..c983fe158 100644 --- a/docs/features/login-protection.md +++ b/docs/features/login-protection.md @@ -13,7 +13,8 @@ Think of a bouncer who lets each visitor knock on the door a limited number of t - Each visitor (each device address) gets its own allowance. - Checking a password is the expensive part, so those requests get the strictest allowance. -- Charon also locks an account for 15 minutes after 5 wrong passwords. Login protection works alongside that lock. +- Charon also locks an account for 15 minutes after 5 wrong passwords. Login protection works alongside that lock. Once the 15 minutes pass, the count of wrong passwords starts again from zero. +- Every failed sign-in shows the same "invalid credentials" message, whatever the reason, and the sign-in screen does not show whether an account is locked. - Emergency recovery access is never slowed down. See [Emergency Access](../configuration/emergency-setup.md). ## What You'll See diff --git a/docs/features/user-accounts.md b/docs/features/user-accounts.md index 876529f2a..b86268cd5 100644 --- a/docs/features/user-accounts.md +++ b/docs/features/user-accounts.md @@ -53,6 +53,24 @@ standard user. --- +## Passwords and Sign-In + +- **Same message every time.** If a sign-in doesn't work, the login page shows + the same "invalid credentials" message, whatever the reason. The sign-in + screen doesn't show an account's lock status. +- **Locks.** After 5 wrong passwords an account is locked for 15 minutes. Once + the lock has expired, the count of wrong tries starts again from zero. +- **Changing your password signs out your other devices.** Use + **Change Password** and Charon ends your other sessions, while you stay signed + in on the device you used. +- **Resetting someone else's password.** An administrator can set a new + password for another person without knowing the old one. That person is + signed out everywhere and signs in with the new password. Changing your own + password through the Users API needs your current password, and signs you out + so you sign in again. + +--- + ## The Very First Account The first time you open a brand-new Charon, it shows a one-time setup screen and From 4033d4888611c0cb552da25a5421cc62cb661f9d Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 10:29:00 +0000 Subject: [PATCH 09/57] docs: update security configuration notes --- ARCHITECTURE.md | 1 + docs/api.md | 4 ++++ docs/features/audit-logging.md | 4 ++-- docs/features/dns-challenge.md | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfec9e466..0700b3014 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -350,6 +350,7 @@ fork/offline fallback. - **Handlers:** Process HTTP requests, validate input, return responses - **Middleware:** CORS, GZIP, authentication, logging, metrics, panic recovery +- **Caller identity:** Handlers read the signed-in caller through shared accessors in `backend/internal/api/middleware/ctxkeys.go`, enforced by a guard test - **Routes:** Route registration and grouping (public, authenticated, and admin-only — see [Management API Authentication & Authorization](#management-api-authentication--authorization)) **Example Endpoints:** diff --git a/docs/api.md b/docs/api.md index 74b4be654..aaae60430 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,6 +23,10 @@ Authorization: Bearer Public routes: `POST /auth/login`, `GET /auth/verify`, `GET /auth/status`, the setup and invite routes. Every other route requires a valid session. Some routes additionally require the `admin` role. +`GET /auth/verify` is the check Caddy calls for forward auth. It is reachable without the normal session middleware, but it checks the caller's session itself (401 when not signed in). It also needs a recognizable forwarded host (`X-Forwarded-Host`, or `X-Original-Host` when that is absent) and returns 403 when the host is missing, malformed, or not one of your proxy hosts. Hosts are matched exactly, ignoring upper/lower case and port numbers. A stored wildcard such as `*.example.com` covers exactly one extra label (`app.example.com`, but not `a.b.example.com`). + +Audit entries record the actor as `user:` for signed-in users, `emergency` for emergency-token requests, and the client address otherwise. + ## Response Format ### Success Response diff --git a/docs/features/audit-logging.md b/docs/features/audit-logging.md index ea05ab942..b268bd478 100644 --- a/docs/features/audit-logging.md +++ b/docs/features/audit-logging.md @@ -137,7 +137,7 @@ The details modal displays: - **Event UUID**: Unique identifier for the event - **Timestamp**: Exact date and time (ISO 8601 format) -- **Actor**: User ID or "system" for automated operations +- **Actor**: `user:` for a signed-in user, `emergency` for emergency-token requests, or the client address when no user is identified. Automated operations show `system`, and some older entries may show `unknown` or `system` - **Action**: Operation performed - **Category**: Event category (dns_provider, certificate, etc.) - **Resource**: DNS provider name and UUID @@ -395,7 +395,7 @@ GET /api/v1/audit-logs - `page` (int, default: 1): Page number - `limit` (int, default: 50, max: 100): Results per page -- `actor` (string): Filter by actor (user ID or "system") +- `actor` (string): Filter by actor (`user:`, `emergency`, a client address, or "system"). Filtering by `user:` also matches older entries recorded with just the numeric ID - `action` (string): Filter by action type - `event_category` (string): Filter by category (dns_provider, certificate, etc.) - `resource_uuid` (string): Filter by resource UUID diff --git a/docs/features/dns-challenge.md b/docs/features/dns-challenge.md index cfbd7e3f3..6311f4e07 100644 --- a/docs/features/dns-challenge.md +++ b/docs/features/dns-challenge.md @@ -385,6 +385,8 @@ Charon displays the required DNS record: └──────────────────────────────────────────────────────────────────────┘ ``` +> **Note**: A manual challenge belongs to the user who started it. Other users cannot see or act on it, and if someone else already has an active challenge for the same name, Charon tells you one is in progress. Challenges left over from earlier versions (with no owner) are closed once at startup, so just start a new one. + #### Step 3: Add Record to DNS Provider Log in to your DNS provider and create the TXT record: From e501538cfaf7bb4616747ae9e89b1c3f6eb2e076 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 04:09:09 +0000 Subject: [PATCH 10/57] test: add coverage for authentication edge cases --- tests/security/self-proxy-requests.spec.ts | 368 +++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 tests/security/self-proxy-requests.spec.ts diff --git a/tests/security/self-proxy-requests.spec.ts b/tests/security/self-proxy-requests.spec.ts new file mode 100644 index 000000000..566295d38 --- /dev/null +++ b/tests/security/self-proxy-requests.spec.ts @@ -0,0 +1,368 @@ +/** + * Requests routed through a proxy host that targets Charon itself. + * + * A proxy host whose upstream is Charon's own listen port is a supported + * configuration. These tests cover how such requests behave end to end: + * + * - the app is reachable through the proxy hop (HTTP and a rendered page), + * - the client address the app resolves for a request that came through the + * hop is the connecting peer, and forwarding headers supplied by the client + * are not reflected, + * - the per-process value the proxy attaches to the hop never shows up in API + * responses, the proxy access log, the container log or the app log file. + * + * The live value is read from the proxy's admin API (published on loopback in + * the E2E compose files) so the log checks compare against the real value. It + * is only ever used inside `includes()` checks: never logged, never passed to + * an assertion message, never attached to the report. + * + * The emergency server (a separate listener) is covered by the last describe + * block. The runner is inside the management networks in this environment, so + * the "outside the management networks" rejection is covered by Go unit tests; + * here we assert the listener ignores forwarding headers and keeps requiring + * credentials. + */ + +import { execFileSync } from 'node:child_process'; + +import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { EMERGENCY_SERVER } from '../fixtures/security'; +import { caddyProxyOrigin, getAuthTokenFromPage } from '../utils/api-helpers'; +import { waitForLoadingComplete } from '../utils/wait-helpers'; + +const CHARON_PORT = 8080; +const ADMIN_API = process.env.PLAYWRIGHT_CADDY_ADMIN_URL || 'http://127.0.0.1:2019'; +const CONTAINER_CANDIDATES = [ + process.env.PLAYWRIGHT_CONTAINER_NAME, + 'charon-e2e', + 'charon-playwright', +].filter((n): n is string => Boolean(n)); + +const HOP_SECRET_HEADER = 'X-Charon-Self-Hop'; +const HOP_CLIENT_HEADER = 'X-Charon-Self-Hop-Client'; + +// Documentation-range addresses that no runner can legitimately have. +const SUPPLIED_FORWARDED_ADDRESS = '203.0.113.77'; +const SUPPLIED_HOP_CLIENT_ADDRESS = '198.51.100.23'; +const SUPPLIED_HOP_SECRET = 'supplied-value-0123456789abcdef0123456789abcdef'; + +function resolveContainer(): string { + for (const name of CONTAINER_CANDIDATES) { + try { + execFileSync('docker', ['inspect', '--format', '{{.Id}}', name], { stdio: 'pipe' }); + return name; + } catch { + // try the next candidate + } + } + throw new Error(`No E2E container found among: ${CONTAINER_CANDIDATES.join(', ')}`); +} + +function dockerLogs(container: string): string { + return execFileSync('docker', ['logs', container], { + stdio: 'pipe', + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }) as string; +} + +function containerFile(container: string, path: string): string { + try { + return execFileSync('docker', ['exec', container, 'cat', path], { + stdio: 'pipe', + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }) as string; + } catch { + return ''; + } +} + +/** Extract the live hop value from the proxy's loaded configuration. */ +async function readLiveHopValue( + request: import('@playwright/test').APIRequestContext +): Promise { + const response = await request.get(`${ADMIN_API}/config/`); + expect(response.ok()).toBeTruthy(); + const config = await response.text(); + const match = new RegExp(`"${HOP_SECRET_HEADER}":\\["([^"]+)"\\]`).exec(config); + return match ? match[1] : ''; +} + +test.describe('Requests through a proxy host that targets Charon @security', () => { + test.describe.configure({ mode: 'serial' }); + + let domain: string; + let token: string; + let origin: string; + + test.beforeEach(async ({ page, adminUser, testData }) => { + await loginUser(page, adminUser); + await waitForLoadingComplete(page); + token = await getAuthTokenFromPage(page); + origin = caddyProxyOrigin(page); + + const host = await testData.createProxyHost({ + domain: `self-target-${Date.now()}.example.test`, + forwardHost: '127.0.0.1', + forwardPort: CHARON_PORT, + name: 'Self target host', + }); + domain = host.domain; + + // Wait until the proxy has loaded the route for the new host. + await expect + .poll( + async () => { + const r = await page.request.get(`${origin}/api/v1/health`, { + headers: { Host: domain }, + failOnStatusCode: false, + }); + return r.status(); + }, + { timeout: 30_000, message: 'proxy host route should become reachable' } + ) + .toBe(200); + }); + + test('serves the health endpoint through a proxy host', async ({ page }) => { + const response = await page.request.get(`${origin}/api/v1/health`, { + headers: { Host: domain }, + }); + expect(response.status()).toBe(200); + await expect(response).toBeOK(); + }); + + test('renders the sign-in page through a proxy host', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + // The browser cannot resolve the test hostname, so each request for it is + // forwarded to the proxy port with the hostname as the Host header. + await page.route(`http://${domain}/**`, async (route) => { + const url = new URL(route.request().url()); + const response = await route.fetch({ + url: `${origin}${url.pathname}${url.search}`, + headers: { ...route.request().headers(), host: domain }, + }); + await route.fulfill({ response }); + }); + + try { + await page.goto(`http://${domain}/login`); + await expect(page.getByRole('button', { name: /sign in|log in|login/i })).toBeVisible(); + await expect(page.getByRole('textbox', { name: /email/i })).toBeVisible(); + await expect(page.getByLabel(/password/i).first()).toBeVisible(); + await expect(page.locator('form').first()).toMatchAriaSnapshot(` + - text: Email + - textbox "Email" + - text: Password + - textbox "Password" + - button "Sign In" + `); + } finally { + await context.close(); + } + }); + + test('resolves the connecting peer as the client address through a proxy host', async ({ + page, + }) => { + let direct: { ip: string; source: string }; + let throughHop: { ip: string; source: string }; + + await test.step('Read the address the app reports for a direct request', async () => { + const response = await page.request.get('/api/v1/system/my-ip'); + expect(response.status()).toBe(200); + direct = await response.json(); + expect(direct.ip).toMatch(/^[0-9a-f.:]+$/i); + }); + + await test.step('Read the address reported for a request through the hop', async () => { + const response = await page.request.get(`${origin}/api/v1/system/my-ip`, { + headers: { Host: domain, Authorization: `Bearer ${token}` }, + }); + expect(response.status()).toBe(200); + throughHop = await response.json(); + expect(throughHop.ip).toMatch(/^[0-9a-f.:]+$/i); + expect(throughHop.source).toBe('forwarded'); + }); + + await test.step('The address through the hop is the connecting peer', async () => { + expect(throughHop.ip).not.toBe(''); + expect(throughHop.ip).toBe(direct.ip); + }); + }); + + test('ignores forwarding headers supplied by the client through a proxy host', async ({ + page, + }) => { + const baseline = await ( + await page.request.get(`${origin}/api/v1/system/my-ip`, { + headers: { Host: domain, Authorization: `Bearer ${token}` }, + }) + ).json(); + + // The E2E compose file lists loopback and the private ranges in + // CHARON_TRUSTED_PROXIES (needed by the auth-rate-limit spec). A peer the + // operator configured as trusted keeps the configured forwarded-header + // handling by design, so a supplied X-Forwarded-For is not asserted here; + // the default (no trusted proxies) path is covered by the Go unit tests. + // The proxy always overwrites X-Real-IP with the connecting peer, which + // holds under any configuration. + await test.step('A supplied real-IP value does not change the resolved address', async () => { + const response = await page.request.get(`${origin}/api/v1/system/my-ip`, { + headers: { + Host: domain, + Authorization: `Bearer ${token}`, + 'X-Real-IP': SUPPLIED_FORWARDED_ADDRESS, + }, + }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.ip).toBe(baseline.ip); + expect(JSON.stringify(body)).not.toContain(SUPPLIED_FORWARDED_ADDRESS); + }); + + await test.step('Supplied hop headers are not reflected and do not change the address', async () => { + const response = await page.request.get(`${origin}/api/v1/system/my-ip`, { + headers: { + Host: domain, + Authorization: `Bearer ${token}`, + [HOP_SECRET_HEADER]: SUPPLIED_HOP_SECRET, + [HOP_CLIENT_HEADER]: SUPPLIED_HOP_CLIENT_ADDRESS, + }, + }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.ip).toBe(baseline.ip); + const reflected = JSON.stringify(body) + JSON.stringify(response.headers()); + expect(reflected).not.toContain(SUPPLIED_HOP_SECRET); + expect(reflected).not.toContain(SUPPLIED_HOP_CLIENT_ADDRESS); + }); + + await test.step('Supplied hop headers sent straight to the app are ignored', async () => { + const response = await page.request.get('/api/v1/system/my-ip', { + headers: { + [HOP_SECRET_HEADER]: SUPPLIED_HOP_SECRET, + [HOP_CLIENT_HEADER]: SUPPLIED_HOP_CLIENT_ADDRESS, + }, + }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.ip).not.toBe(SUPPLIED_HOP_CLIENT_ADDRESS); + expect(JSON.stringify(body)).not.toContain(SUPPLIED_HOP_SECRET); + }); + }); + + test('keeps the per-process hop value out of responses and logs', async ({ page, request }) => { + const container = resolveContainer(); + + // The value is read from the proxy's admin API (loopback). If that read + // ever stops working this assertion fails rather than silently weakening + // the log checks below. + const live = await readLiveHopValue(request); + expect(live, 'live hop value should be readable from the proxy configuration').toMatch( + /^[0-9a-f]{32,}$/i + ); + + const responseBodies: string[] = []; + const collect = async (r: import('@playwright/test').APIResponse): Promise => { + responseBodies.push(await r.text(), JSON.stringify(r.headers())); + }; + + await test.step('Exercise the app through the hop and directly', async () => { + const through = { Host: domain, Authorization: `Bearer ${token}` }; + for (const path of [ + '/api/v1/system/my-ip', + '/api/v1/health', + '/api/v1/proxy-hosts', + '/api/v1/security/status', + '/login', + ]) { + await collect( + await page.request.get(`${origin}${path}`, { + headers: through, + failOnStatusCode: false, + }) + ); + } + // Requests carrying supplied hop headers also leave log lines behind. + await collect( + await page.request.get(`${origin}/api/v1/system/my-ip`, { + headers: { ...through, [HOP_SECRET_HEADER]: SUPPLIED_HOP_SECRET }, + failOnStatusCode: false, + }) + ); + for (const path of ['/api/v1/system/my-ip', '/api/v1/proxy-hosts', '/api/v1/security/status']) { + await collect(await page.request.get(path, { failOnStatusCode: false })); + } + }); + + await test.step('The value is absent from every collected API response', async () => { + for (const body of responseBodies) { + expect(body.includes(live)).toBe(false); + } + }); + + await test.step('The value is absent from the proxy access log', async () => { + const accessLog = containerFile(container, '/var/log/caddy/access.log'); + // The log must actually contain the requests made above, otherwise an + // empty file would pass trivially. + expect(accessLog).toContain(domain); + expect(accessLog.includes(live)).toBe(false); + }); + + await test.step('The value is absent from the container and app logs', async () => { + const containerLog = dockerLogs(container); + expect(containerLog.length).toBeGreaterThan(0); + expect(containerLog.includes(live)).toBe(false); + + const appLog = containerFile(container, '/app/data/logs/charon.log'); + expect(appLog.length).toBeGreaterThan(0); + expect(appLog.includes(live)).toBe(false); + }); + }); +}); + +test.describe('Emergency server request handling @security', () => { + test('uses the connecting address and requires credentials regardless of forwarding headers', async ({ + request, + }) => { + const container = resolveContainer(); + + await test.step('Health answers without credentials when forwarding headers are supplied', async () => { + const response = await request.get(`${EMERGENCY_SERVER.baseURL}/health`, { + headers: { + 'X-Forwarded-For': SUPPLIED_FORWARDED_ADDRESS, + [HOP_SECRET_HEADER]: SUPPLIED_HOP_SECRET, + [HOP_CLIENT_HEADER]: SUPPLIED_HOP_CLIENT_ADDRESS, + }, + }); + expect(response.status()).toBe(200); + expect((await response.json()).server).toBe('emergency'); + }); + + await test.step('Protected routes still require credentials', async () => { + const response = await request.post(`${EMERGENCY_SERVER.baseURL}/emergency/security-reset`, { + headers: { + 'X-Forwarded-For': SUPPLIED_FORWARDED_ADDRESS, + [HOP_SECRET_HEADER]: SUPPLIED_HOP_SECRET, + [HOP_CLIENT_HEADER]: SUPPLIED_HOP_CLIENT_ADDRESS, + }, + failOnStatusCode: false, + }); + expect(response.status()).toBe(401); + }); + + await test.step('The request log records the connecting address only', async () => { + const appLog = containerFile(container, '/app/data/logs/charon.log'); + const emergencyLines = appLog.split('\n').filter((l) => l.includes('Emergency server request')); + expect(emergencyLines.length).toBeGreaterThan(0); + for (const line of emergencyLines) { + expect(line).not.toContain(SUPPLIED_FORWARDED_ADDRESS); + expect(line).not.toContain(SUPPLIED_HOP_CLIENT_ADDRESS); + } + }); + }); +}); From dbc0fc1e62a0cfb02924f7bfe5503fd75ffe22c1 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 06:44:09 +0000 Subject: [PATCH 11/57] test: add coverage for authentication edge cases --- frontend/src/pages/__tests__/Login.test.tsx | 11 + tests/core/account-credentials.spec.ts | 402 ++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 tests/core/account-credentials.spec.ts diff --git a/frontend/src/pages/__tests__/Login.test.tsx b/frontend/src/pages/__tests__/Login.test.tsx index d528278a3..536d7041c 100644 --- a/frontend/src/pages/__tests__/Login.test.tsx +++ b/frontend/src/pages/__tests__/Login.test.tsx @@ -63,6 +63,17 @@ describe('', () => { expect(toastSpy).toHaveBeenCalledWith('Bad creds') }) + it('shows the generic message returned for a failed sign-in', async () => { + vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false }) + vi.spyOn(client, 'post').mockRejectedValueOnce({ response: { data: { error: 'invalid credentials' } } }) + const toastSpy = vi.spyOn(toast, 'error') + renderWithProviders() + fireEvent.change(screen.getByPlaceholderText(/admin@example.com/i), { target: { value: 'a@b.com' } }) + fireEvent.change(screen.getByPlaceholderText(/••••••••/i), { target: { value: 'pw' } }) + fireEvent.click(screen.getByRole('button', { name: /Sign In/i })) + await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('invalid credentials')) + }) + it('uses returned token when cookie is unavailable', async () => { vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false }) const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { token: 'bearer-token' } }) diff --git a/tests/core/account-credentials.spec.ts b/tests/core/account-credentials.spec.ts new file mode 100644 index 000000000..3581d9ae2 --- /dev/null +++ b/tests/core/account-credentials.spec.ts @@ -0,0 +1,402 @@ +/** + * Account Credentials E2E Tests + * + * Covers sign-in outcomes and password-change behavior: + * - Every failed sign-in returns the same response and shows the same message + * on the login page + * - An account locks after repeated wrong passwords, and a correct password + * does not change the outcome while it is locked + * - Changing your own password through PUT /api/v1/users/:id needs the current + * password and ends existing sessions + * - POST /api/v1/auth/change-password keeps the caller signed in and ends + * other sessions + * - An administrator can set another user's password without a current one + * + * Every test creates its own dedicated users (removed by the testData fixture) + * and presents a unique forwarded client address, so the shared admin account + * and other specs' sign-in budgets are never touched. + * + * @see backend/internal/services/auth_service.go + * @see backend/internal/api/handlers/user_handler.go + */ + +import { randomInt, randomUUID } from 'crypto'; +import { + request as playwrightRequest, + type APIRequestContext, + type APIResponse, +} from '@playwright/test'; +import { test, expect, TEST_PASSWORD } from '../fixtures/auth-fixtures'; +import { STORAGE_STATE } from '../constants'; +import type { TestDataManager } from '../utils/TestDataManager'; +import { sendLoginHonoringThrottle } from '../utils/login-throttle'; + +const LOGIN_PATH = '/api/v1/auth/login'; +const GENERIC_SIGN_IN_ERROR = 'invalid credentials'; +const FAILED_ATTEMPTS_BEFORE_LOCK = 5; +const WRONG_PASSWORD = 'WrongPassword123!'; +const NEW_PASSWORD = 'BrandNewPass456!'; + +/** A random address in 198.18.0.0/15 (benchmarking range) so each test gets its own bucket. */ +function isolatedClientIp(): string { + return `198.${randomInt(18, 20)}.${randomInt(0, 256)}.${randomInt(1, 255)}`; +} + +/** A request context with no session that presents the given forwarded client address. */ +async function anonymousContext(baseURL: string, clientIp: string): Promise { + return playwrightRequest.newContext({ + baseURL, + storageState: { cookies: [], origins: [] }, + extraHTTPHeaders: { Accept: 'application/json', 'X-Forwarded-For': clientIp }, + }); +} + +/** A request context carrying the shared admin session, used only to manage dedicated test users. */ +async function adminContext(baseURL: string, clientIp: string): Promise { + return playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + extraHTTPHeaders: { Accept: 'application/json', 'X-Forwarded-For': clientIp }, + }); +} + +function signIn(ctx: APIRequestContext, email: string, password: string): Promise { + return sendLoginHonoringThrottle(() => ctx.post(LOGIN_PATH, { data: { email, password } })); +} + +/** Sign in and return a context that holds the resulting session cookie. */ +async function signedInContext(baseURL: string, clientIp: string, email: string, password: string) { + const ctx = await anonymousContext(baseURL, clientIp); + const response = await signIn(ctx, email, password); + expect(response.status(), await response.text()).toBe(200); + return ctx; +} + +/** Create a dedicated regular user through the shared fixture manager. */ +function createDedicatedUser(testData: TestDataManager, label: string) { + const unique = randomUUID().slice(0, 8); + return testData.createUser({ + name: `Credentials ${label} ${unique}`, + email: `cred-${label}-${unique}@test.local`, + password: TEST_PASSWORD, + role: 'user', + }); +} + +/** Lock an account by sending wrong passwords until the threshold is reached. */ +async function lockAccount(ctx: APIRequestContext, email: string): Promise { + for (let attempt = 0; attempt < FAILED_ATTEMPTS_BEFORE_LOCK; attempt += 1) { + const response = await signIn(ctx, email, WRONG_PASSWORD); + expect(response.status()).toBe(401); + } +} + +test.describe('Account credentials', () => { + test.describe('Sign-in responses (API)', () => { + test('every failed sign-in returns the same response', async ({ + baseURL, + testData, + }) => { + const clientIp = isolatedClientIp(); + const anonymous = await anonymousContext(baseURL!, clientIp); + const admin = await adminContext(baseURL!, clientIp); + try { + const wrongPasswordUser = await createDedicatedUser(testData, 'wrong'); + const disabledUser = await createDedicatedUser(testData, 'disabled'); + const lockedUser = await createDedicatedUser(testData, 'locked'); + + await test.step('Disable one account and lock another', async () => { + const disable = await admin.put(`/api/v1/users/${disabledUser.id}`, { data: { enabled: false } }); + expect(disable.status(), await disable.text()).toBe(200); + await lockAccount(anonymous, lockedUser.email); + }); + + const outcomes = await test.step('Collect the response for each kind of failed sign-in', async () => { + const attempts = { + unknown: { email: `nobody-${randomUUID()}@test.local`, password: TEST_PASSWORD }, + wrongPassword: { email: wrongPasswordUser.email, password: WRONG_PASSWORD }, + disabled: { email: disabledUser.email, password: TEST_PASSWORD }, + locked: { email: lockedUser.email, password: TEST_PASSWORD }, + }; + const results: Record = {}; + for (const [kind, credentials] of Object.entries(attempts)) { + const response = await signIn(anonymous, credentials.email, credentials.password); + results[kind] = { status: response.status(), body: await response.json() }; + } + return results; + }); + + await test.step('Every failure is the same 401 with the same body', () => { + for (const [kind, outcome] of Object.entries(outcomes)) { + expect(outcome.status, kind).toBe(401); + expect(outcome.body, kind).toEqual({ error: GENERIC_SIGN_IN_ERROR }); + } + }); + } finally { + await Promise.all([anonymous.dispose(), admin.dispose()]); + } + }); + }); + + test.describe('Account lock (API)', () => { + test('locks after repeated wrong passwords and a correct password still fails the same way', async ({ + baseURL, + testData, + }) => { + const clientIp = isolatedClientIp(); + const anonymous = await anonymousContext(baseURL!, clientIp); + try { + const user = await createDedicatedUser(testData, 'lock'); + + await test.step('Fewer wrong passwords than the threshold leave the account usable', async () => { + for (let attempt = 0; attempt < FAILED_ATTEMPTS_BEFORE_LOCK - 1; attempt += 1) { + const response = await signIn(anonymous, user.email, WRONG_PASSWORD); + expect(response.status()).toBe(401); + } + const response = await signIn(anonymous, user.email, TEST_PASSWORD); + expect(response.status()).toBe(200); + }); + + await test.step('A successful sign-in restarts the failure count', async () => { + for (let attempt = 0; attempt < FAILED_ATTEMPTS_BEFORE_LOCK - 1; attempt += 1) { + const response = await signIn(anonymous, user.email, WRONG_PASSWORD); + expect(response.status()).toBe(401); + } + const response = await signIn(anonymous, user.email, TEST_PASSWORD); + expect(response.status()).toBe(200); + }); + + await test.step('Reaching the threshold locks the account', async () => { + await lockAccount(anonymous, user.email); + }); + + await test.step('A correct password is rejected identically while locked', async () => { + const response = await signIn(anonymous, user.email, TEST_PASSWORD); + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ error: GENERIC_SIGN_IN_ERROR }); + }); + } finally { + await anonymous.dispose(); + } + }); + + test('an administrator password reset clears the lock', async ({ baseURL, testData }) => { + const clientIp = isolatedClientIp(); + const anonymous = await anonymousContext(baseURL!, clientIp); + const admin = await adminContext(baseURL!, clientIp); + try { + const user = await createDedicatedUser(testData, 'reset-lock'); + + await test.step('Lock the account', async () => { + await lockAccount(anonymous, user.email); + expect((await signIn(anonymous, user.email, TEST_PASSWORD)).status()).toBe(401); + }); + + await test.step('The administrator sets a new password', async () => { + const response = await admin.put(`/api/v1/users/${user.id}`, { data: { password: NEW_PASSWORD } }); + expect(response.status(), await response.text()).toBe(200); + }); + + await test.step('The user can sign in with the new password', async () => { + expect((await signIn(anonymous, user.email, NEW_PASSWORD)).status()).toBe(200); + }); + } finally { + await Promise.all([anonymous.dispose(), admin.dispose()]); + } + }); + }); + + test.describe('Changing your own password (API)', () => { + test('requires the current password', async ({ baseURL, testData }) => { + const clientIp = isolatedClientIp(); + const user = await createDedicatedUser(testData, 'self-change'); + const session = await signedInContext(baseURL!, clientIp, user.email, TEST_PASSWORD); + const anonymous = await anonymousContext(baseURL!, clientIp); + try { + await test.step('A request without the current password is rejected as incomplete', async () => { + const response = await session.put(`/api/v1/users/${user.id}`, { data: { password: NEW_PASSWORD } }); + expect(response.status()).toBe(400); + }); + + await test.step('A request with the wrong current password is rejected as unauthorized', async () => { + const response = await session.put(`/api/v1/users/${user.id}`, { + data: { password: NEW_PASSWORD, current_password: WRONG_PASSWORD }, + }); + expect(response.status()).toBe(401); + }); + + await test.step('The original password still works and the session is still valid', async () => { + expect((await session.get('/api/v1/auth/me')).status()).toBe(200); + expect((await signIn(anonymous, user.email, TEST_PASSWORD)).status()).toBe(200); + expect((await signIn(anonymous, user.email, NEW_PASSWORD)).status()).toBe(401); + }); + } finally { + await Promise.all([session.dispose(), anonymous.dispose()]); + } + }); + + test('with the current password it succeeds and ends existing sessions', async ({ baseURL, testData }) => { + const clientIp = isolatedClientIp(); + const user = await createDedicatedUser(testData, 'self-change-ok'); + const session = await signedInContext(baseURL!, clientIp, user.email, TEST_PASSWORD); + const otherSession = await signedInContext(baseURL!, clientIp, user.email, TEST_PASSWORD); + const anonymous = await anonymousContext(baseURL!, clientIp); + try { + await test.step('Change the password with the current one', async () => { + const response = await session.put(`/api/v1/users/${user.id}`, { + data: { password: NEW_PASSWORD, current_password: TEST_PASSWORD }, + }); + expect(response.status(), await response.text()).toBe(200); + }); + + await test.step('Existing sessions are ended', async () => { + expect((await session.get('/api/v1/auth/me')).status()).toBe(401); + expect((await otherSession.get('/api/v1/auth/me')).status()).toBe(401); + }); + + await test.step('Only the new password signs in', async () => { + expect((await signIn(anonymous, user.email, TEST_PASSWORD)).status()).toBe(401); + expect((await signIn(anonymous, user.email, NEW_PASSWORD)).status()).toBe(200); + }); + } finally { + await Promise.all([session.dispose(), otherSession.dispose(), anonymous.dispose()]); + } + }); + + test('change-password keeps the caller signed in and ends other sessions', async ({ baseURL, testData }) => { + const clientIp = isolatedClientIp(); + const user = await createDedicatedUser(testData, 'change-endpoint'); + const caller = await signedInContext(baseURL!, clientIp, user.email, TEST_PASSWORD); + const otherSession = await signedInContext(baseURL!, clientIp, user.email, TEST_PASSWORD); + const anonymous = await anonymousContext(baseURL!, clientIp); + try { + await test.step('Change the password from the caller session', async () => { + const response = await caller.post('/api/v1/auth/change-password', { + data: { old_password: TEST_PASSWORD, new_password: NEW_PASSWORD }, + }); + expect(response.status(), await response.text()).toBe(200); + }); + + await test.step('The caller remains signed in', async () => { + expect((await caller.get('/api/v1/auth/me')).status()).toBe(200); + }); + + await test.step('The other session is ended', async () => { + expect((await otherSession.get('/api/v1/auth/me')).status()).toBe(401); + }); + + await test.step('Only the new password signs in', async () => { + expect((await signIn(anonymous, user.email, TEST_PASSWORD)).status()).toBe(401); + expect((await signIn(anonymous, user.email, NEW_PASSWORD)).status()).toBe(200); + }); + } finally { + await Promise.all([caller.dispose(), otherSession.dispose(), anonymous.dispose()]); + } + }); + }); + + test.describe('Administrator password reset (API)', () => { + test("setting another user's password needs no current password", async ({ baseURL, testData }) => { + const clientIp = isolatedClientIp(); + const admin = await adminContext(baseURL!, clientIp); + const anonymous = await anonymousContext(baseURL!, clientIp); + try { + const user = await createDedicatedUser(testData, 'admin-reset'); + const userSession = await signedInContext(baseURL!, clientIp, user.email, TEST_PASSWORD); + + await test.step('The administrator sets a new password without a current one', async () => { + const response = await admin.put(`/api/v1/users/${user.id}`, { data: { password: NEW_PASSWORD } }); + expect(response.status(), await response.text()).toBe(200); + }); + + await test.step("The user's earlier session ends and only the new password works", async () => { + expect((await userSession.get('/api/v1/auth/me')).status()).toBe(401); + expect((await signIn(anonymous, user.email, TEST_PASSWORD)).status()).toBe(401); + expect((await signIn(anonymous, user.email, NEW_PASSWORD)).status()).toBe(200); + }); + + await userSession.dispose(); + } finally { + await Promise.all([admin.dispose(), anonymous.dispose()]); + } + }); + }); + + test.describe('Login page', () => { + test.use({ + storageState: { cookies: [], origins: [] }, + extraHTTPHeaders: { 'X-Forwarded-For': isolatedClientIp() }, + }); + + /** Submit the login form and return the visible error alert's text. */ + async function submitAndReadError( + page: import('@playwright/test').Page, + email: string, + password: string + ): Promise { + await page.goto('/login'); + await page.getByRole('textbox', { name: /email/i }).fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: /sign in/i }).click(); + + const alert = page.getByTestId('toast-error'); + await expect(alert).toBeVisible(); + await expect(alert).toHaveRole('alert'); + await expect(page).toHaveURL(/login/); + return (await alert.innerText()).trim(); + } + + test('shows the same message for every failed sign-in', async ({ + page, + baseURL, + testData, + }) => { + const clientIp = isolatedClientIp(); + const anonymous = await anonymousContext(baseURL!, clientIp); + const admin = await adminContext(baseURL!, clientIp); + try { + const wrongPasswordUser = await createDedicatedUser(testData, 'ui-wrong'); + const disabledUser = await createDedicatedUser(testData, 'ui-disabled'); + const lockedUser = await createDedicatedUser(testData, 'ui-locked'); + + await test.step('Disable one account and lock another', async () => { + const disable = await admin.put(`/api/v1/users/${disabledUser.id}`, { data: { enabled: false } }); + expect(disable.status(), await disable.text()).toBe(200); + await lockAccount(anonymous, lockedUser.email); + }); + + const messages: Record = {}; + + await test.step('Wrong password', async () => { + messages.wrongPassword = await submitAndReadError(page, wrongPasswordUser.email, WRONG_PASSWORD); + }); + await test.step('Unknown account', async () => { + messages.unknown = await submitAndReadError(page, `nobody-${randomUUID()}@test.local`, TEST_PASSWORD); + }); + await test.step('Disabled account', async () => { + messages.disabled = await submitAndReadError(page, disabledUser.email, TEST_PASSWORD); + }); + await test.step('Locked account with the correct password', async () => { + messages.locked = await submitAndReadError(page, lockedUser.email, TEST_PASSWORD); + }); + + await test.step('All four messages are identical', () => { + expect(messages.wrongPassword.toLowerCase()).toContain(GENERIC_SIGN_IN_ERROR); + expect(messages.unknown).toBe(messages.wrongPassword); + expect(messages.disabled).toBe(messages.wrongPassword); + expect(messages.locked).toBe(messages.wrongPassword); + }); + + await test.step('The error alert exposes the expected structure', async () => { + await expect(page.getByTestId('toast-error')).toMatchAriaSnapshot(` + - alert: + - text: ✗invalid credentials + - button "Close": × + `); + }); + } finally { + await Promise.all([anonymous.dispose(), admin.dispose()]); + } + }); + }); +}); From e2e806afb5149ac447fc19696d76f5c02bae4c67 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 10:33:21 +0000 Subject: [PATCH 12/57] test: update emergency reset rate-limit spec --- tests/security-enforcement/emergency-reset.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/security-enforcement/emergency-reset.spec.ts b/tests/security-enforcement/emergency-reset.spec.ts index 95b053b8a..2e0d3aa19 100644 --- a/tests/security-enforcement/emergency-reset.spec.ts +++ b/tests/security-enforcement/emergency-reset.spec.ts @@ -229,7 +229,7 @@ test.describe('Emergency Access & Rate Limiting', () => { let rateLimitHit = false; while (attempts < maxAttempts) { - const response = await request.get('/api/v1/auth/verify', { + const response = await request.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}`, }, From 79a1e0afaf29c202aa073587b2c0462d678a94cf Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 12:16:09 +0000 Subject: [PATCH 13/57] chore: widen codeql suppression line range --- .github/codeql/codeql-suppressions.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/codeql/codeql-suppressions.yml b/.github/codeql/codeql-suppressions.yml index d33ec4c7a..dcd31d22b 100644 --- a/.github/codeql/codeql-suppressions.yml +++ b/.github/codeql/codeql-suppressions.yml @@ -26,7 +26,9 @@ suppressions: - rule_id: go/cookie-secure-not-set path: backend/internal/api/handlers/auth_handler.go - line: 187 + line_range: + start: 187 + end: 205 reason: > Secure is false only when isLocalRequest(c) AND scheme != "https" (loopback/RFC1918/IPv6-ULA/Tailscale-CGNAT origin over plain HTTP) — @@ -43,6 +45,10 @@ suppressions: the documented fallback for that case. See docs/issues/codeql-cookie-suppression-not-honored.md for the full investigation history. + Extended 2026-10-03: the request-origin/auth handler changes in the + security hardening PRs shift the SetCookie sink between lines 187 and + ~202 depending on merge state, so the entry is widened to a + line_range; this is the only SetCookie call in the file. added: "2026-08-04" review_by: "2026-11-04" From 39ae2cf0da56d6d1b4de794873f8ec6d01715796 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 12:16:31 +0000 Subject: [PATCH 14/57] chore: widen codeql suppression line range --- .github/codeql/codeql-suppressions.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/codeql/codeql-suppressions.yml b/.github/codeql/codeql-suppressions.yml index d33ec4c7a..dcd31d22b 100644 --- a/.github/codeql/codeql-suppressions.yml +++ b/.github/codeql/codeql-suppressions.yml @@ -26,7 +26,9 @@ suppressions: - rule_id: go/cookie-secure-not-set path: backend/internal/api/handlers/auth_handler.go - line: 187 + line_range: + start: 187 + end: 205 reason: > Secure is false only when isLocalRequest(c) AND scheme != "https" (loopback/RFC1918/IPv6-ULA/Tailscale-CGNAT origin over plain HTTP) — @@ -43,6 +45,10 @@ suppressions: the documented fallback for that case. See docs/issues/codeql-cookie-suppression-not-honored.md for the full investigation history. + Extended 2026-10-03: the request-origin/auth handler changes in the + security hardening PRs shift the SetCookie sink between lines 187 and + ~202 depending on merge state, so the entry is widened to a + line_range; this is the only SetCookie call in the file. added: "2026-08-04" review_by: "2026-11-04" From 09dccc6f12a0b2309b07d6e7a5d66affabf85414 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 12:16:39 +0000 Subject: [PATCH 15/57] chore: widen codeql suppression line range --- .github/codeql/codeql-suppressions.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/codeql/codeql-suppressions.yml b/.github/codeql/codeql-suppressions.yml index d33ec4c7a..dcd31d22b 100644 --- a/.github/codeql/codeql-suppressions.yml +++ b/.github/codeql/codeql-suppressions.yml @@ -26,7 +26,9 @@ suppressions: - rule_id: go/cookie-secure-not-set path: backend/internal/api/handlers/auth_handler.go - line: 187 + line_range: + start: 187 + end: 205 reason: > Secure is false only when isLocalRequest(c) AND scheme != "https" (loopback/RFC1918/IPv6-ULA/Tailscale-CGNAT origin over plain HTTP) — @@ -43,6 +45,10 @@ suppressions: the documented fallback for that case. See docs/issues/codeql-cookie-suppression-not-honored.md for the full investigation history. + Extended 2026-10-03: the request-origin/auth handler changes in the + security hardening PRs shift the SetCookie sink between lines 187 and + ~202 depending on merge state, so the entry is widened to a + line_range; this is the only SetCookie call in the file. added: "2026-08-04" review_by: "2026-11-04" From 0888108fa873f6cd2c10d8e2cf7bdb4673650045 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 12:42:37 +0000 Subject: [PATCH 16/57] chore: fix test lint warnings --- backend/internal/api/handlers/account_credentials_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/handlers/account_credentials_test.go b/backend/internal/api/handlers/account_credentials_test.go index 709f430fc..5a3a652e5 100644 --- a/backend/internal/api/handlers/account_credentials_test.go +++ b/backend/internal/api/handlers/account_credentials_test.go @@ -67,7 +67,7 @@ func (e *credentialsEnv) do(t *testing.T, method, path, token string, body any) return w } -func (e *credentialsEnv) user(t *testing.T, email string, admin bool) (*models.User, string) { +func (e *credentialsEnv) user(t *testing.T, email string, admin bool) (u *models.User, token string) { t.Helper() u, err := e.auth.Register(email, "password123", "User") require.NoError(t, err) @@ -76,7 +76,7 @@ func (e *credentialsEnv) user(t *testing.T, email string, admin bool) (*models.U } else { require.NoError(t, e.db.Model(u).Update("role", models.RoleUser).Error) } - token, err := e.auth.Login(email, "password123") + token, err = e.auth.Login(email, "password123") require.NoError(t, err) return u, token } From 2b9c7aa18e43919288ef41f07a7bd836aab85538 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 12:43:33 +0000 Subject: [PATCH 17/57] chore: fix test lint warnings --- backend/internal/caddy/self_upstream_test.go | 2 +- backend/internal/security/selfhop/selfhop_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/caddy/self_upstream_test.go b/backend/internal/caddy/self_upstream_test.go index 370fd53d1..9e866ec10 100644 --- a/backend/internal/caddy/self_upstream_test.go +++ b/backend/internal/caddy/self_upstream_test.go @@ -284,7 +284,7 @@ func TestManager_ApplyConfig_SelfHopWiringAndSnapshotRedaction(t *testing.T) { continue } snapshots++ - body, readErr := os.ReadFile(filepath.Join(dir, e.Name())) + body, readErr := os.ReadFile(filepath.Clean(filepath.Join(dir, e.Name()))) require.NoError(t, readErr) assert.NotContains(t, string(body), secret.Reveal()) assert.Contains(t, string(body), snapshotSecretPlaceholder) diff --git a/backend/internal/security/selfhop/selfhop_test.go b/backend/internal/security/selfhop/selfhop_test.go index 548e4ec1c..0b038575b 100644 --- a/backend/internal/security/selfhop/selfhop_test.go +++ b/backend/internal/security/selfhop/selfhop_test.go @@ -53,10 +53,10 @@ func TestSecret_FormattingIsRedacted(t *testing.T) { outputs := []string{ s.String(), s.GoString(), - fmt.Sprintf("%v", s), + fmt.Sprintf("%v", s), //nolint:gocritic // exercising the fmt path is the point of this test fmt.Sprintf("%+v", s), fmt.Sprintf("%#v", s), - fmt.Sprintf("%s", s), //nolint:gosimple // exercising the verb + fmt.Sprintf("%s", s), //nolint:gocritic // exercising the fmt path is the point of this test fmt.Sprintf("%v", holder{S: s}), fmt.Sprintf("%+v", holder{S: s}), } From 52d63f0378f10a3ad65bfbb7f4104593c2e1657e Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 18:08:05 +0000 Subject: [PATCH 18/57] chore: add Aikido API key env to Claude settings --- .claude/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index d808f4670..07013c085 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,5 +23,6 @@ "semgrep@claude-plugins-official": true, "pr-review-toolkit@claude-plugins-official": true, "aikido@claude-plugins-official": true - } + }, + "env": { "AIKIDO_API_KEY": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhaWtpZG8uZGV2IiwiYXVkIjoiaWRlLmFpa2lkbyIsImlhdCI6MTc5MTA1MDg0MSwibmJmIjoxNzkxMDUwODMxLCJleHAiOjI1Nzk5NjkyNDEsImlzX2lkZV90b2tlbiI6dHJ1ZSwidXNlcl9pZCI6MjkzNjIxLCJ0b2tlbl9pZCI6NTAxMzUsInJlZ2lvbiI6ImV1In0.bJmRJ1UeI480Q6GEyWrZMWn4wrFGKXJyJl0rFPHWQs0" } } From a8e7d5e8605afb086fbf080855d0fbba496b824d Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 18:11:59 +0000 Subject: [PATCH 19/57] chore: harden workflow triggers and checkout credentials --- .github/workflows/dry-run-history-rewrite.yml | 11 ++--------- .github/workflows/history-rewrite-tests.yml | 11 ++--------- .github/workflows/security-pr.yml | 1 + 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/.github/workflows/dry-run-history-rewrite.yml b/.github/workflows/dry-run-history-rewrite.yml index 7df67ec71..68c8f80f8 100644 --- a/.github/workflows/dry-run-history-rewrite.yml +++ b/.github/workflows/dry-run-history-rewrite.yml @@ -1,15 +1,12 @@ name: History Rewrite Dry-Run on: - workflow_run: - workflows: ["Docker Build, Publish & Test"] - types: [completed] schedule: - cron: '0 2 * * *' # daily at 02:00 UTC workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true permissions: @@ -19,16 +16,12 @@ jobs: preview-history: name: Dry-run preview for history rewrite runs-on: ubuntu-latest - if: >- - ${{ github.event_name != 'workflow_run' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository) }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false - name: Debug git info run: | diff --git a/.github/workflows/history-rewrite-tests.yml b/.github/workflows/history-rewrite-tests.yml index 5079cd29f..1db87aaa1 100644 --- a/.github/workflows/history-rewrite-tests.yml +++ b/.github/workflows/history-rewrite-tests.yml @@ -1,9 +1,6 @@ name: History Rewrite Tests on: - workflow_run: - workflows: ["Docker Build, Publish & Test"] - types: [completed] pull_request: paths: - 'scripts/history-rewrite/**' @@ -16,7 +13,7 @@ on: workflow_dispatch: {} concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true permissions: @@ -25,16 +22,12 @@ permissions: jobs: test: runs-on: ubuntu-latest - if: >- - ${{ github.event_name != 'workflow_run' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository) }} steps: - name: Checkout with full history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false - name: Install dependencies run: | diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index 3e0fdfa09..8814dec80 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -66,6 +66,7 @@ jobs: uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 with: ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false - name: Extract PR number from workflow_run id: pr-info From e69da964adfa419b8e93dd3e085a58f048288d1a Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 18:43:54 +0000 Subject: [PATCH 20/57] deps: pin dnsimple-go to v8.3.1 in Caddy build --- .github/renovate.json | 13 +++++++++++++ Dockerfile | 7 +++++++ scripts/toolchain-key.sh | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/renovate.json b/.github/renovate.json index f2ee1fdf3..67a0c1737 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -857,6 +857,19 @@ "depNameTemplate": "github.com/libdns/vercel", "datasourceTemplate": "go", "versioningTemplate": "semver" + }, + { + "customType": "regex", + "description": "Track dnsimple-go/v8 forced transitive version ARG in Dockerfile", + "managerFilePatterns": [ + "/^Dockerfile$/" + ], + "matchStrings": [ + "ARG CADDY_DNS_DNSIMPLE_GO_VERSION=(?[^\\s]+)" + ], + "depNameTemplate": "github.com/dnsimple/dnsimple-go/v8", + "datasourceTemplate": "go", + "versioningTemplate": "semver" } ], "github-actions": { diff --git a/Dockerfile b/Dockerfile index 4a3020926..9185c35f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -185,6 +185,11 @@ ARG CADDY_DNS_VERCEL_VERSION=0.0.2 ARG LIBDNS_NAMEDOTCOM_VERSION=0.9.0 # renovate: datasource=go depName=github.com/libdns/vercel ARG LIBDNS_VERCEL_VERSION=0.1.0 +# Forced transitive pin: caddy-dns/dnsimple -> libdns/dnsimple -> dnsimple-go/v8 +# resolves to v8.0.0 by default (flagged by supply-chain scanners). Force the +# latest release via an extra --with entry, same mechanism as the libdns ARGs above. +# renovate: datasource=go depName=github.com/dnsimple/dnsimple-go/v8 +ARG CADDY_DNS_DNSIMPLE_GO_VERSION=8.3.1 ## When an official caddy image tag isn't available on the host, use a ## plain Alpine base image and overwrite its caddy binary with our ## xcaddy-built binary in the later COPY step. This avoids relying on @@ -474,6 +479,7 @@ ARG CADDY_DNS_BUNNY_VERSION ARG CADDY_DNS_VERCEL_VERSION ARG LIBDNS_NAMEDOTCOM_VERSION ARG LIBDNS_VERCEL_VERSION +ARG CADDY_DNS_DNSIMPLE_GO_VERSION # renovate: datasource=go depName=github.com/caddyserver/xcaddy ARG XCADDY_VERSION=0.4.7 ARG EXPR_LANG_VERSION @@ -560,6 +566,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --with github.com/caddy-dns/hetzner/v2@v${CADDY_DNS_HETZNER_VERSION} \ --with github.com/caddy-dns/vultr@v${CADDY_DNS_VULTR_VERSION} \ --with github.com/caddy-dns/dnsimple@v${CADDY_DNS_DNSIMPLE_VERSION} \ + --with github.com/dnsimple/dnsimple-go/v8@v${CADDY_DNS_DNSIMPLE_GO_VERSION} \ --with github.com/caddy-dns/ovh@v${CADDY_DNS_OVH_VERSION} \ --with github.com/caddy-dns/gandi@v${CADDY_DNS_GANDI_VERSION} \ --with github.com/caddy-dns/linode@v${CADDY_DNS_LINODE_VERSION} \ diff --git a/scripts/toolchain-key.sh b/scripts/toolchain-key.sh index e15416435..832785e9f 100755 --- a/scripts/toolchain-key.sh +++ b/scripts/toolchain-key.sh @@ -70,7 +70,7 @@ for pair in "caddy-inline:$caddy_stage" "crowdsec-inline:$crowdsec_stage"; do done # ARG names whose default values feed the key. Keep in sync with spec §2.2 / §3.4.2. -arg_re='^ARG (GO_VERSION|ALPINE_IMAGE|CROWDSEC_VERSION|EXPR_LANG_VERSION|XNET_VERSION|XCRYPTO_VERSION|KLAUSPOST_COMPRESS_VERSION|GRPC_VERSION|CADDY_VERSION|CADDY_CANDIDATE_VERSION|CADDY_USE_CANDIDATE|CADDY_PATCH_SCENARIO|CADDY_SECURITY_VERSION|CORAZA_CADDY_VERSION|CADDY_GEOIP2_VERSION|CADDY_RATELIMIT_VERSION|CADDY_DNS_CLOUDFLARE_VERSION|CADDY_DNS_ROUTE53_VERSION|CADDY_DNS_DIGITALOCEAN_VERSION|CADDY_DNS_GOOGLECLOUDDNS_VERSION|CADDY_DNS_AZURE_VERSION|CADDY_DNS_NAMECHEAP_VERSION|CADDY_DNS_GODADDY_VERSION|CADDY_DNS_HETZNER_VERSION|CADDY_DNS_VULTR_VERSION|CADDY_DNS_DNSIMPLE_VERSION|CADDY_DNS_OVH_VERSION|CADDY_DNS_GANDI_VERSION|CADDY_DNS_LINODE_VERSION|CADDY_DNS_PORKBUN_VERSION|CADDY_DNS_NETLIFY_VERSION|CADDY_DNS_DESEC_VERSION|CADDY_DNS_SCALEWAY_VERSION|CADDY_DNS_DUCKDNS_VERSION|CADDY_DNS_DNSMADEEASY_VERSION|CADDY_DNS_NAMEDOTCOM_VERSION|CADDY_DNS_NAMESILO_VERSION|CADDY_DNS_RFC2136_VERSION|CADDY_DNS_POWERDNS_VERSION|CADDY_DNS_INWX_VERSION|CADDY_DNS_LOOPIA_VERSION|CADDY_DNS_BUNNY_VERSION|CADDY_DNS_VERCEL_VERSION|LIBDNS_NAMEDOTCOM_VERSION|LIBDNS_VERCEL_VERSION)=' +arg_re='^ARG (GO_VERSION|ALPINE_IMAGE|CROWDSEC_VERSION|EXPR_LANG_VERSION|XNET_VERSION|XCRYPTO_VERSION|KLAUSPOST_COMPRESS_VERSION|GRPC_VERSION|CADDY_VERSION|CADDY_CANDIDATE_VERSION|CADDY_USE_CANDIDATE|CADDY_PATCH_SCENARIO|CADDY_SECURITY_VERSION|CORAZA_CADDY_VERSION|CADDY_GEOIP2_VERSION|CADDY_RATELIMIT_VERSION|CADDY_DNS_CLOUDFLARE_VERSION|CADDY_DNS_ROUTE53_VERSION|CADDY_DNS_DIGITALOCEAN_VERSION|CADDY_DNS_GOOGLECLOUDDNS_VERSION|CADDY_DNS_AZURE_VERSION|CADDY_DNS_NAMECHEAP_VERSION|CADDY_DNS_GODADDY_VERSION|CADDY_DNS_HETZNER_VERSION|CADDY_DNS_VULTR_VERSION|CADDY_DNS_DNSIMPLE_VERSION|CADDY_DNS_OVH_VERSION|CADDY_DNS_GANDI_VERSION|CADDY_DNS_LINODE_VERSION|CADDY_DNS_PORKBUN_VERSION|CADDY_DNS_NETLIFY_VERSION|CADDY_DNS_DESEC_VERSION|CADDY_DNS_SCALEWAY_VERSION|CADDY_DNS_DUCKDNS_VERSION|CADDY_DNS_DNSMADEEASY_VERSION|CADDY_DNS_NAMEDOTCOM_VERSION|CADDY_DNS_NAMESILO_VERSION|CADDY_DNS_RFC2136_VERSION|CADDY_DNS_POWERDNS_VERSION|CADDY_DNS_INWX_VERSION|CADDY_DNS_LOOPIA_VERSION|CADDY_DNS_BUNNY_VERSION|CADDY_DNS_VERCEL_VERSION|LIBDNS_NAMEDOTCOM_VERSION|LIBDNS_VERCEL_VERSION|CADDY_DNS_DNSIMPLE_GO_VERSION)=' key="$( { From 98a872e4ca8bea863adbe853e588b473875b1077 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Sat, 3 Oct 2026 15:00:51 -0400 Subject: [PATCH 21/57] fix: apply dnsimple-go pin via go get instead of xcaddy --with --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9185c35f7..39ac038db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -186,9 +186,9 @@ ARG LIBDNS_NAMEDOTCOM_VERSION=0.9.0 # renovate: datasource=go depName=github.com/libdns/vercel ARG LIBDNS_VERCEL_VERSION=0.1.0 # Forced transitive pin: caddy-dns/dnsimple -> libdns/dnsimple -> dnsimple-go/v8 -# resolves to v8.0.0 by default (flagged by supply-chain scanners). Force the -# latest release via an extra --with entry, same mechanism as the libdns ARGs above. -# renovate: datasource=go depName=github.com/dnsimple/dnsimple-go/v8 +# resolves to v8.0.0 by default (flagged by supply-chain scanners). Applied via +# `go get` in the Stage 2 patch block — NOT an xcaddy `--with`, since xcaddy adds a +# blank import of the module root and dnsimple-go/v8 has no root package. ARG CADDY_DNS_DNSIMPLE_GO_VERSION=8.3.1 ## When an official caddy image tag isn't available on the host, use a ## plain Alpine base image and overwrite its caddy binary with our @@ -566,7 +566,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --with github.com/caddy-dns/hetzner/v2@v${CADDY_DNS_HETZNER_VERSION} \ --with github.com/caddy-dns/vultr@v${CADDY_DNS_VULTR_VERSION} \ --with github.com/caddy-dns/dnsimple@v${CADDY_DNS_DNSIMPLE_VERSION} \ - --with github.com/dnsimple/dnsimple-go/v8@v${CADDY_DNS_DNSIMPLE_GO_VERSION} \ --with github.com/caddy-dns/ovh@v${CADDY_DNS_OVH_VERSION} \ --with github.com/caddy-dns/gandi@v${CADDY_DNS_GANDI_VERSION} \ --with github.com/caddy-dns/linode@v${CADDY_DNS_LINODE_VERSION} \ @@ -674,6 +673,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ # crowdsec-builder pin below. # renovate: datasource=go depName=golang.org/x/mod _retry go get golang.org/x/mod@v0.40.0; \ + _retry go get github.com/dnsimple/dnsimple-go/v8@v${CADDY_DNS_DNSIMPLE_GO_VERSION}; \ if [ "${CADDY_PATCH_SCENARIO}" = "A" ]; then \ # Rollback scenario: keep explicit nebula pin if upstream compatibility regresses. # NOTE: smallstep/certificates (pulled by caddy-security stack) currently From dba3150dfa037125ac0dce6668e973928cebe18e Mon Sep 17 00:00:00 2001 From: Wikid82 <176516789+Wikid82@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:17:26 +0000 Subject: [PATCH 22/57] chore(docker): refresh bundled proxy toolchain image Rebuilds the prebuilt Caddy/CrowdSec toolchain image so the shipped binaries pick up upstream fixes, and bumps the digest pin in the Dockerfile. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 39ac038db..4cec9cb03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,8 @@ ARG CHARON_TOOLCHAIN_IMAGE=ghcr.io/wikid82/charon-toolchain # NOT Renovate-tracked (a content-hash tag has no series to follow, N7) — the # toolchain-image.yml bot owns these two lines. DIGEST is the arch-independent # manifest-list (OCI index) digest, so one pin covers linux/amd64 + linux/arm64. -ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-bc8619e4e914410f -ARG CHARON_TOOLCHAIN_DIGEST=sha256:1e3c18331c2b65eac0ba827d4a7cf362a7ca274616d783f9ab34c85bc9f888a5 +ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-afa4dcd430cdd6fd +ARG CHARON_TOOLCHAIN_DIGEST=sha256:789e1264534fd18d94b961963778a62747825fab521fe7f94a7137b138d7f320 # Stage selector — default consumes the prebuilt toolchain image (no compile). # Fork PRs / bootstrap / offline builds pass From 328b6f4633525ad8709f635e82ee6ecca93aa708 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 20:08:35 +0000 Subject: [PATCH 23/57] chore: build and test the powerdns plugin in the workspace Give the bundled PowerDNS plugin its own module entry in the repository workspace so it is compiled and tested alongside the backend with identical dependency versions. This adds a make target and CI step that build the plugin (CGO enabled, same toolchain and flags as the host binary: no trimpath, race or cover) and a plugin.Open smoke test that always rebuilds the plugin into a temporary directory. The new manifest lives in the existing plugin directory and only imports the public backend packages; no backend code is copied or moved. The Renovate config is updated accordingly. No behaviour change. --- .github/renovate.json | 13 +++ .github/workflows/quality-checks.yml | 9 ++ Makefile | 24 ++++- .../services/plugin_loader_smoke_test.go | 55 +++++++++++ go.work | 1 + go.work.sum | 1 + plugins/powerdns/README.md | 8 +- plugins/powerdns/go.mod | 7 ++ plugins/powerdns/main_test.go | 91 +++++++++++++++++++ 9 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 backend/internal/services/plugin_loader_smoke_test.go create mode 100644 plugins/powerdns/go.mod create mode 100644 plugins/powerdns/main_test.go diff --git a/.github/renovate.json b/.github/renovate.json index 67a0c1737..e918abc33 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -905,6 +905,19 @@ ], "enabled": false }, + { + "description": "The bundled powerdns plugin consumes the in-repo backend module through a local replace directive; never let Renovate resolve or bump it.", + "matchManagers": [ + "gomod" + ], + "matchFileNames": [ + "plugins/powerdns/go.mod" + ], + "matchPackageNames": [ + "github.com/Wikid82/charon/backend" + ], + "enabled": false + }, { "description": "Group GitHub Actions non-major updates into one PR", "matchManagers": [ diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index b52a0f747..16aaa0dcf 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -273,6 +273,15 @@ jobs: run: | bash "scripts/go-test-coverage.sh" 2>&1 | tee backend/test-output.txt; exit "${PIPESTATUS[0]}" + # Compile gate + tests for the bundled PowerDNS plugin, plus a plugin.Open + # smoke test against a freshly built .so (same toolchain/flags as the host). + - name: Build and test PowerDNS plugin + env: + CGO_ENABLED: 1 + run: | + make plugin-powerdns + make plugin-powerdns-smoke + - name: Upload test output artifact if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/Makefile b/Makefile index 5d0a70f2c..ceb1ab7bf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install test build run clean docker-build docker-run build-offline release go-check gopls-logs lint-backend lint-agent lint-fast lint-staticcheck-only security-local +.PHONY: help plugin-powerdns plugin-powerdns-smoke install test build run clean docker-build docker-run build-offline release go-check gopls-logs lint-backend lint-agent lint-fast lint-staticcheck-only security-local # Default target help: @@ -19,6 +19,8 @@ help: @echo " go-check - Verify backend build readiness (runs scripts/check_go_build.sh)" @echo " gopls-logs - Collect gopls diagnostics (runs scripts/gopls_collect.sh)" @echo " local-patch-report - Generate local patch coverage report" + @echo " plugin-powerdns - Build and test the PowerDNS plugin" + @echo " plugin-powerdns-smoke - plugin.Open smoke test against a fresh PowerDNS plugin build" @echo "" @echo "Security targets:" @echo " security-scan - Quick security scan (govulncheck on Go deps)" @@ -230,3 +232,23 @@ benchmark: integration-test: @echo "Running integration tests..." @./scripts/integration-test.sh + +# Build the bundled PowerDNS plugin and run its tests. +# The plugin MUST be built with the same toolchain, flags and dependency +# versions as the host binary (no -trimpath, -race or -cover), otherwise +# plugin.Open rejects it. The workspace (go.work) pins the shared dependencies. +plugin-powerdns: + @echo "Building and testing the PowerDNS plugin..." + @tmp=$$(mktemp -d) && trap 'rm -rf "$$tmp"' EXIT && \ + cd plugins/powerdns && \ + CGO_ENABLED=1 go build -buildmode=plugin -o "$$tmp/powerdns.so" . && \ + CGO_ENABLED=1 go test -count=1 ./... + +# Prove a freshly rebuilt powerdns.so loads into the host (plugin.Open). +# Always rebuilds into a temp dir; never reuses plugins/powerdns/powerdns.so. +plugin-powerdns-smoke: + @echo "Smoke-testing plugin.Open against a freshly built PowerDNS plugin..." + @tmp=$$(mktemp -d) && trap 'rm -rf "$$tmp"' EXIT && \ + (cd plugins/powerdns && CGO_ENABLED=1 go build -buildmode=plugin -o "$$tmp/powerdns.so" .) && \ + cd backend && CGO_ENABLED=1 CHARON_SMOKE_PLUGIN_SO="$$tmp/powerdns.so" \ + go test -tags plugin_smoke -run TestPluginSmoke ./internal/services -count=1 diff --git a/backend/internal/services/plugin_loader_smoke_test.go b/backend/internal/services/plugin_loader_smoke_test.go new file mode 100644 index 000000000..90b86de8b --- /dev/null +++ b/backend/internal/services/plugin_loader_smoke_test.go @@ -0,0 +1,55 @@ +//go:build plugin_smoke + +package services + +import ( + "os" + "path/filepath" + "plugin" + "strings" + "testing" + + "github.com/Wikid82/charon/backend/pkg/dnsprovider" +) + +// TestPluginSmoke verifies that a freshly built powerdns.so can be opened by a +// host process built with the same toolchain, flags and dependency versions. +// It is driven by `make plugin-powerdns-smoke`, which always rebuilds the +// plugin into a temporary directory and passes it in CHARON_SMOKE_PLUGIN_SO. +func TestPluginSmoke(t *testing.T) { + soPath := os.Getenv("CHARON_SMOKE_PLUGIN_SO") + if soPath == "" { + t.Fatal("CHARON_SMOKE_PLUGIN_SO must point at a freshly built plugin (use make plugin-powerdns-smoke)") + } + + abs, err := filepath.Abs(filepath.Clean(soPath)) + if err != nil { + t.Fatalf("resolve plugin path: %v", err) + } + if strings.Contains(filepath.ToSlash(abs), "/plugins/powerdns/") { + t.Fatalf("refusing to load a plugin from the source directory (stale build risk): %s", abs) + } + + p, err := plugin.Open(abs) + if err != nil { + t.Fatalf("plugin.Open: %v", err) + } + sym, err := p.Lookup("Plugin") + if err != nil { + t.Fatalf("lookup Plugin symbol: %v", err) + } + + var provider dnsprovider.ProviderPlugin + switch v := sym.(type) { + case dnsprovider.ProviderPlugin: + provider = v + case *dnsprovider.ProviderPlugin: + provider = *v + default: + t.Fatalf("Plugin symbol has unexpected type %T", sym) + } + + if got := provider.Type(); got != "powerdns" { + t.Fatalf("Type() = %q, want powerdns", got) + } +} diff --git a/go.work b/go.work index 3029d413f..d37d0ba88 100644 --- a/go.work +++ b/go.work @@ -3,4 +3,5 @@ go 1.27.1 use ( ./agent ./backend + ./plugins/powerdns ) diff --git a/go.work.sum b/go.work.sum index f2fc4d850..56cf92763 100644 --- a/go.work.sum +++ b/go.work.sum @@ -22,6 +22,7 @@ dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/nistec v0.0.4 h1:F14ZHT5htWlMnQVPndX9ro9arf56cBhQxq4LnDI491s= filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI= github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= diff --git a/plugins/powerdns/README.md b/plugins/powerdns/README.md index 3357fe0a9..bd888d863 100644 --- a/plugins/powerdns/README.md +++ b/plugins/powerdns/README.md @@ -4,11 +4,15 @@ This is an example DNS provider plugin for Charon that adds support for PowerDNS ## Building -To build this plugin, you **must** use `CGO_ENABLED=1` and the same Go version as the Charon binary: +The plugin is a module in the repository workspace (`go.work`), so it is built against the exact dependency versions of the Charon backend. It **must** be built with `CGO_ENABLED=1`, the same Go toolchain, and the same build flags as the Charon binary: no `-trimpath`, no `-race`, no `-cover`. A plugin built differently is rejected when Charon loads it. ```bash +make plugin-powerdns # build (into a temp dir) and run the plugin tests +make plugin-powerdns-smoke # rebuild and verify the plugin loads into the host + +# or manually cd plugins/powerdns -CGO_ENABLED=1 go build -buildmode=plugin -o ../powerdns.so main.go +CGO_ENABLED=1 go build -buildmode=plugin -o powerdns.so . ``` ## Installation diff --git a/plugins/powerdns/go.mod b/plugins/powerdns/go.mod new file mode 100644 index 000000000..fb824bd27 --- /dev/null +++ b/plugins/powerdns/go.mod @@ -0,0 +1,7 @@ +module github.com/Wikid82/charon/plugins/powerdns + +go 1.27.1 + +require github.com/Wikid82/charon/backend v0.0.0 + +replace github.com/Wikid82/charon/backend => ../../backend diff --git a/plugins/powerdns/main_test.go b/plugins/powerdns/main_test.go new file mode 100644 index 000000000..43d6b772b --- /dev/null +++ b/plugins/powerdns/main_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "testing" + "time" + + "github.com/Wikid82/charon/backend/pkg/dnsprovider" +) + +func TestProviderIdentity(t *testing.T) { + p := &PowerDNSProvider{} + + if got := p.Type(); got != "powerdns" { + t.Fatalf("Type() = %q, want powerdns", got) + } + + md := p.Metadata() + if md.Type != "powerdns" || md.Name != "PowerDNS" || md.IsBuiltIn { + t.Fatalf("unexpected metadata: %+v", md) + } + if md.InterfaceVersion != dnsprovider.InterfaceVersion { + t.Fatalf("InterfaceVersion = %q, want %q", md.InterfaceVersion, dnsprovider.InterfaceVersion) + } + + if Plugin == nil || Plugin.Type() != "powerdns" { + t.Fatalf("exported Plugin symbol is not the PowerDNS provider") + } +} + +func TestCredentialFieldSpecs(t *testing.T) { + p := &PowerDNSProvider{} + + required := map[string]bool{} + for _, f := range p.RequiredCredentialFields() { + required[f.Name] = true + } + if !required["api_url"] || !required["api_key"] || len(required) != 2 { + t.Fatalf("unexpected required fields: %v", required) + } + + optional := p.OptionalCredentialFields() + if len(optional) != 1 || optional[0].Name != "server_id" { + t.Fatalf("unexpected optional fields: %+v", optional) + } +} + +func TestValidateCredentialsRequiredFields(t *testing.T) { + p := &PowerDNSProvider{} + + tests := []struct { + name string + creds map[string]string + wantErr bool + }{ + {"missing api_url", map[string]string{"api_key": "k"}, true}, + {"missing api_key", map[string]string{"api_url": "https://pdns.example.com:8081"}, true}, + {"both present", map[string]string{"api_url": "https://pdns.example.com:8081", "api_key": "k"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := p.ValidateCredentials(tt.creds); (err != nil) != tt.wantErr { + t.Fatalf("ValidateCredentials() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBuildCaddyConfigDefaultsServerID(t *testing.T) { + p := &PowerDNSProvider{} + creds := map[string]string{"api_url": "https://pdns.example.com:8081", "api_key": "k"} + + cfg := p.BuildCaddyConfig(creds) + if cfg["name"] != "powerdns" || cfg["server_id"] != "localhost" || cfg["api_url"] != creds["api_url"] { + t.Fatalf("unexpected config: %v", cfg) + } + + creds["server_id"] = "pdns1" + if got := p.BuildCaddyConfigForZone("example.com", creds)["server_id"]; got != "pdns1" { + t.Fatalf("server_id = %v, want pdns1", got) + } +} + +func TestPropagationSettings(t *testing.T) { + p := &PowerDNSProvider{} + if p.PropagationTimeout() != 60*time.Second || p.PollingInterval() != 2*time.Second { + t.Fatalf("unexpected propagation settings") + } + if p.SupportsMultiCredential() { + t.Fatalf("SupportsMultiCredential() = true, want false") + } +} From f15fd842d2259079a702ec5a78dead535a868a3d Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 20:16:02 +0000 Subject: [PATCH 24/57] refactor: add shared helper for outbound provider requests Introduce a small public helper package for plugins and in-tree providers that need to make outbound HTTP requests to administrator-configured endpoints, and consolidate the address-policy checks in the internal network package behind a single predicate. Policy rejections now carry a stable sentinel error that omits resolved addresses, and optional range-blocking client options are added (off by default). Foundation for the following commits, which add the first callers; no behaviour change. --- backend/internal/network/safeclient.go | 219 ++++++--- .../network/safeclient_policy_test.go | 330 +++++++++++++ backend/internal/security/url_validator.go | 23 +- .../security/url_validator_sentinel_test.go | 60 +++ backend/pkg/safehttp/safehttp.go | 284 +++++++++++ backend/pkg/safehttp/safehttp_test.go | 446 ++++++++++++++++++ 6 files changed, 1300 insertions(+), 62 deletions(-) create mode 100644 backend/internal/network/safeclient_policy_test.go create mode 100644 backend/internal/security/url_validator_sentinel_test.go create mode 100644 backend/pkg/safehttp/safehttp.go create mode 100644 backend/pkg/safehttp/safehttp_test.go diff --git a/backend/internal/network/safeclient.go b/backend/internal/network/safeclient.go index bd780f192..c38ff5ade 100644 --- a/backend/internal/network/safeclient.go +++ b/backend/internal/network/safeclient.go @@ -5,6 +5,7 @@ package network import ( "context" + "errors" "fmt" "net" "net/http" @@ -12,6 +13,16 @@ import ( "time" ) +// ErrBlockedAddress is wrapped (with %w) by every error that reports a +// destination rejected by the address policy. Its message never contains a +// resolved IP address, so it is safe to surface to administrators. Callers +// should test for it with errors.Is rather than matching message text. +var ErrBlockedAddress = errors.New("destination address is not allowed") + +// lookupIPAddr resolves a host to IP addresses. It is a package-level seam so +// tests can supply a controlled resolver; production always uses the default. +var lookupIPAddr = net.DefaultResolver.LookupIPAddr + // privateBlocks holds pre-parsed CIDR blocks for private/reserved IP ranges. // These are parsed once at package initialization for performance. var ( @@ -69,33 +80,59 @@ var privateCIDRs = []string{ "fe80::/10", } +// cgnatCIDRs is the shared-address space used by carrier-grade NAT and many +// overlay networks. It is NOT part of IsPrivateIP; callers opt in to blocking +// it with WithBlockCGNAT. +var cgnatCIDRs = []string{ + "100.64.0.0/10", +} + +// transitionCIDRs lists IPv4/IPv6 transition, translation and special-purpose +// ranges that have no legitimate use as an outbound HTTP destination. They are +// NOT part of IsPrivateIP; callers opt in to blocking them with +// WithBlockTransitionRanges. +var transitionCIDRs = []string{ + "192.0.0.0/24", // IETF protocol assignments + "198.18.0.0/15", // Benchmarking + "64:ff9b::/96", // NAT64 (RFC 6052) + "64:ff9b:1::/48", // NAT64 local-use (RFC 8215) + "2002::/16", // 6to4 + "::/96", // IPv4-compatible IPv6 (deprecated) + "2001::/32", // Teredo +} + +var ( + cgnatBlocks []*net.IPNet + cgnatOnce sync.Once + transitionBlocks []*net.IPNet + transitionOnce sync.Once +) + +// parseBlocks parses CIDR strings into networks. Invalid entries are skipped; +// they cannot occur with the compile-time constant lists in this file. +func parseBlocks(cidrs []string) []*net.IPNet { + blocks := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, block, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + blocks = append(blocks, block) + } + return blocks +} + // initPrivateBlocks parses all CIDR blocks once at startup. func initPrivateBlocks() { initOnce.Do(func() { - privateBlocks = make([]*net.IPNet, 0, len(privateCIDRs)) - for _, cidr := range privateCIDRs { - _, block, err := net.ParseCIDR(cidr) - if err != nil { - // This should never happen with valid CIDR strings - continue - } - privateBlocks = append(privateBlocks, block) - } + privateBlocks = parseBlocks(privateCIDRs) }) } // initRFC1918Blocks parses the three RFC 1918 CIDR blocks once at startup. func initRFC1918Blocks() { rfc1918Once.Do(func() { - rfc1918Blocks = make([]*net.IPNet, 0, len(rfc1918CIDRs)) - for _, cidr := range rfc1918CIDRs { - _, block, err := net.ParseCIDR(cidr) - if err != nil { - // This should never happen with valid CIDR strings - continue - } - rfc1918Blocks = append(rfc1918Blocks, block) - } + rfc1918Blocks = parseBlocks(rfc1918CIDRs) }) } @@ -170,6 +207,43 @@ func IsRFC1918(ip net.IP) bool { return false } +// IsCGNAT reports whether ip is in the carrier-grade NAT shared address space +// (100.64.0.0/10). IPv4-mapped IPv6 addresses are normalised first. +func IsCGNAT(ip net.IP) bool { + if ip == nil { + return false + } + cgnatOnce.Do(func() { cgnatBlocks = parseBlocks(cgnatCIDRs) }) + return containsAny(cgnatBlocks, ip) +} + +// IsTransitionRange reports whether ip is in an IPv4/IPv6 transition, +// translation or special-purpose range (192.0.0.0/24, 198.18.0.0/15, +// 64:ff9b::/96, 64:ff9b:1::/48, 2002::/16, ::/96, 2001::/32). +// +// IPv6-only forms are matched on the raw 16-byte address; IPv4-mapped IPv6 +// addresses are matched through their IPv4 form. The unspecified and loopback +// addresses are excluded here because IsPrivateIP already covers them. +func IsTransitionRange(ip net.IP) bool { + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() { + return false + } + transitionOnce.Do(func() { transitionBlocks = parseBlocks(transitionCIDRs) }) + return containsAny(transitionBlocks, ip) +} + +// containsAny reports whether ip is inside any of the blocks. net.IPNet.Contains +// already compares IPv4-mapped addresses through their 4-byte form and never +// matches an IPv4 address against an IPv6 block (or vice versa). +func containsAny(blocks []*net.IPNet, ip net.IP) bool { + for _, block := range blocks { + if block.Contains(ip) { + return true + } + } + return false +} + // ClientOptions configures the behavior of the safe HTTP client. type ClientOptions struct { // Timeout is the total request timeout (default: 10s) @@ -198,6 +272,14 @@ type ClientOptions struct { // cloud metadata (169.254.x.x), and reserved — remain blocked regardless. AllowRFC1918 bool + // BlockCGNAT rejects the carrier-grade NAT range (100.64.0.0/10). Off by + // default so existing callers are unchanged. + BlockCGNAT bool + + // BlockTransitionRanges rejects IPv4/IPv6 transition and special-purpose + // ranges (see IsTransitionRange). Off by default. + BlockTransitionRanges bool + // keepAlive, when true, enables HTTP connection pooling on the SSRF-safe // client. When false (the default) the client keeps its historical // behaviour byte-for-byte: DisableKeepAlives=true, MaxIdleConns=1, @@ -272,6 +354,51 @@ func WithAllowRFC1918() Option { } } +// WithBlockCGNAT rejects destinations in the carrier-grade NAT range +// (100.64.0.0/10) in addition to the ranges blocked by IsPrivateIP. +func WithBlockCGNAT() Option { + return func(opts *ClientOptions) { + opts.BlockCGNAT = true + } +} + +// WithBlockTransitionRanges rejects destinations in IPv4/IPv6 transition and +// special-purpose ranges (see IsTransitionRange). +func WithBlockTransitionRanges() Option { + return func(opts *ClientOptions) { + opts.BlockTransitionRanges = true + } +} + +// blockedByPolicy is the single address-policy predicate shared by the dialer +// (both its validation and selection passes) and the redirect check. It reports +// whether ip must NOT be connected to under opts. +// +// The always-on rules (IsPrivateIP, which includes loopback and link-local) can +// only be relaxed by the explicit AllowLocalhost / AllowRFC1918 branches, and +// those branches only match loopback and RFC 1918 addresses, so no other +// restricted address is reachable through them. +func blockedByPolicy(ip net.IP, opts *ClientOptions) bool { + if ip == nil { + return true + } + // Transition ranges are matched on the raw address first: IPv6-only forms + // do not survive the To4 normalisation below. + if opts.BlockTransitionRanges && IsTransitionRange(ip) { + return true + } + if opts.AllowLocalhost && ip.IsLoopback() { + return false + } + if opts.AllowRFC1918 && IsRFC1918(ip) { + return false + } + if opts.BlockCGNAT && IsCGNAT(ip) { + return true + } + return IsPrivateIP(ip) +} + // WithKeepAlive enables connection pooling (HTTP keep-alives) on the SSRF-safe // client. Without this option the client's transport is byte-for-byte identical // to today: keep-alives disabled, a single idle connection, IdleConnTimeout @@ -324,7 +451,7 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str } // Resolve DNS with context timeout - ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + ips, err := lookupIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("DNS resolution failed for %s: %w", host, err) } @@ -333,46 +460,27 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str return nil, fmt.Errorf("no IP addresses found for host: %s", host) } - // Validate ALL resolved IPs - if ANY are private, reject the entire request - // This prevents attackers from using DNS load balancing to mix private/public IPs + // Validate ALL resolved IPs - if ANY are blocked, reject the entire request. + // This prevents attackers from using DNS load balancing to mix private/public IPs. + // The error deliberately omits the resolved address. for _, ip := range ips { - // Allow localhost IPs if AllowLocalhost is set - if opts.AllowLocalhost && ip.IP.IsLoopback() { - continue - } - - // Allow RFC 1918 addresses only when explicitly permitted (e.g., admin-configured - // uptime monitors targeting internal hosts). Link-local (169.254.x.x), loopback, - // cloud metadata, and all other restricted ranges remain blocked. - if opts.AllowRFC1918 && IsRFC1918(ip.IP) { - continue - } - - if IsPrivateIP(ip.IP) { - return nil, fmt.Errorf("connection to private IP blocked: %s resolved to %s", host, ip.IP) + if blockedByPolicy(ip.IP, opts) { + return nil, fmt.Errorf("connection to private IP blocked for host %s: %w", host, ErrBlockedAddress) } } - // Find first valid IP to connect to + // Select the first IP that passes the same policy predicate, so a + // blocked address can never be chosen even if the loops drift apart. var selectedIP net.IP for _, ip := range ips { - if opts.AllowLocalhost && ip.IP.IsLoopback() { - selectedIP = ip.IP - break - } - // Select RFC 1918 IPs when the caller has opted in. - if opts.AllowRFC1918 && IsRFC1918(ip.IP) { - selectedIP = ip.IP - break - } - if !IsPrivateIP(ip.IP) { + if !blockedByPolicy(ip.IP, opts) { selectedIP = ip.IP break } } if selectedIP == nil { - return nil, fmt.Errorf("no valid IP addresses found for host: %s", host) + return nil, fmt.Errorf("connection to private IP blocked for host %s: no usable address: %w", host, ErrBlockedAddress) } // Connect to the validated IP (prevents DNS rebinding TOCTOU attacks) @@ -382,10 +490,8 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str } // validateRedirectTarget checks if a redirect URL is safe to follow. -// Returns an error if the redirect target resolves to private IPs. -// -// TODO: If MaxRedirects is ever re-enabled for uptime monitors, thread AllowRFC1918 -// through this function to permit RFC 1918 redirect targets. +// It applies the same address policy as the dialer (blockedByPolicy), so the +// AllowLocalhost, AllowRFC1918 and range-blocking options behave identically. func validateRedirectTarget(req *http.Request, opts *ClientOptions) error { host := req.URL.Hostname() if host == "" { @@ -397,24 +503,21 @@ func validateRedirectTarget(req *http.Request, opts *ClientOptions) error { if opts.AllowLocalhost { return nil } - return fmt.Errorf("redirect to localhost blocked") + return fmt.Errorf("redirect to localhost blocked: %w", ErrBlockedAddress) } // Resolve and validate IPs ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout) defer cancel() - ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + ips, err := lookupIPAddr(ctx, host) if err != nil { return fmt.Errorf("DNS resolution failed for redirect target %s: %w", host, err) } for _, ip := range ips { - if opts.AllowLocalhost && ip.IP.IsLoopback() { - continue - } - if IsPrivateIP(ip.IP) { - return fmt.Errorf("redirect to private IP blocked: %s resolved to %s", host, ip.IP) + if blockedByPolicy(ip.IP, opts) { + return fmt.Errorf("redirect to private IP blocked for host %s: %w", host, ErrBlockedAddress) } } diff --git a/backend/internal/network/safeclient_policy_test.go b/backend/internal/network/safeclient_policy_test.go new file mode 100644 index 000000000..9e699668b --- /dev/null +++ b/backend/internal/network/safeclient_policy_test.go @@ -0,0 +1,330 @@ +package network + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// withResolver replaces the package resolver seam for the duration of a test. +// Tests using it must not call t.Parallel (the seam is a package variable). +func withResolver(t *testing.T, answers map[string][]string) { + t.Helper() + prev := lookupIPAddr + lookupIPAddr = func(_ context.Context, host string) ([]net.IPAddr, error) { + raw, ok := answers[host] + if !ok { + return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} + } + out := make([]net.IPAddr, 0, len(raw)) + for _, s := range raw { + out = append(out, net.IPAddr{IP: net.ParseIP(s)}) + } + return out, nil + } + t.Cleanup(func() { lookupIPAddr = prev }) +} + +func TestIsCGNAT(t *testing.T) { + t.Parallel() + tests := []struct { + ip string + want bool + }{ + {"100.64.0.1", true}, + {"100.127.255.254", true}, + {"100.100.100.200", true}, + {"::ffff:100.64.0.1", true}, + {"100.63.255.255", false}, + {"100.128.0.0", false}, + {"8.8.8.8", false}, + {"2001:4860:4860::8888", false}, + } + for _, tt := range tests { + if got := IsCGNAT(net.ParseIP(tt.ip)); got != tt.want { + t.Errorf("IsCGNAT(%s) = %v, want %v", tt.ip, got, tt.want) + } + } + if IsCGNAT(nil) { + t.Error("IsCGNAT(nil) must be false") + } +} + +func TestIsTransitionRange(t *testing.T) { + t.Parallel() + tests := []struct { + ip string + want bool + }{ + {"192.0.0.1", true}, + {"198.18.0.1", true}, + {"198.19.255.255", true}, + {"64:ff9b::1", true}, + {"64:ff9b::808:808", true}, + {"64:ff9b:1::1", true}, + {"2002::1", true}, + {"2002:c000:204::1", true}, + {"::1.2.3.4", true}, + {"2001:0:4136:e378:8000:63bf:3fff:fdd2", true}, + // Excluded: handled by IsPrivateIP, or ordinary addresses. + {"::", false}, + {"::1", false}, + {"::ffff:1.2.3.4", false}, + {"192.0.1.1", false}, + {"198.20.0.1", false}, + {"8.8.8.8", false}, + {"2001:db8::1", false}, + {"2606:4700:4700::1111", false}, + } + for _, tt := range tests { + if got := IsTransitionRange(net.ParseIP(tt.ip)); got != tt.want { + t.Errorf("IsTransitionRange(%s) = %v, want %v", tt.ip, got, tt.want) + } + } + if IsTransitionRange(nil) { + t.Error("IsTransitionRange(nil) must be false") + } +} + +func TestBlockedByPolicy(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ip string + opts ClientOptions + want bool + }{ + {"public default", "8.8.8.8", ClientOptions{}, false}, + {"nil ip", "", ClientOptions{}, true}, + {"loopback default", "127.0.0.1", ClientOptions{}, true}, + {"loopback allowed", "127.0.0.1", ClientOptions{AllowLocalhost: true}, false}, + {"mapped loopback allowed", "::ffff:127.0.0.1", ClientOptions{AllowLocalhost: true}, false}, + {"rfc1918 default", "10.0.0.5", ClientOptions{}, true}, + {"rfc1918 allowed", "10.0.0.5", ClientOptions{AllowRFC1918: true}, false}, + {"link-local never allowed by rfc1918", "169.254.169.254", ClientOptions{AllowRFC1918: true, AllowLocalhost: true}, true}, + {"cgnat default allowed through", "100.64.0.1", ClientOptions{}, false}, + {"cgnat blocked by option", "100.64.0.1", ClientOptions{BlockCGNAT: true}, true}, + {"cgnat blocked even with rfc1918 allowed", "100.64.0.1", ClientOptions{BlockCGNAT: true, AllowRFC1918: true}, true}, + {"transition default allowed through", "2002::1", ClientOptions{}, false}, + {"transition blocked by option", "2002::1", ClientOptions{BlockTransitionRanges: true}, true}, + {"transition blocked with every allowance", "64:ff9b::1", ClientOptions{BlockTransitionRanges: true, AllowLocalhost: true, AllowRFC1918: true}, true}, + {"teredo blocked by option", "2001:0:4136:e378:8000:63bf:3fff:fdd2", ClientOptions{BlockTransitionRanges: true}, true}, + {"loopback v6 with transition option and allowance", "::1", ClientOptions{BlockTransitionRanges: true, AllowLocalhost: true}, false}, + {"unspecified blocked", "0.0.0.0", ClientOptions{AllowLocalhost: true, AllowRFC1918: true}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ip net.IP + if tt.ip != "" { + ip = net.ParseIP(tt.ip) + } + opts := tt.opts + if got := blockedByPolicy(ip, &opts); got != tt.want { + t.Errorf("blockedByPolicy(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} + +func TestSafeDialer_BlockedAddressSentinelAndNoResolvedIP(t *testing.T) { + withResolver(t, map[string][]string{ + "internal.example": {"10.20.30.40"}, + "meta.example": {"169.254.169.254"}, + }) + opts := defaultOptions() + dial := safeDialer(&opts) + + for _, host := range []string{"internal.example", "meta.example"} { + _, err := dial(context.Background(), "tcp", net.JoinHostPort(host, "80")) + if err == nil { + t.Fatalf("%s: expected error", host) + } + if !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: error does not wrap ErrBlockedAddress: %v", host, err) + } + if !strings.Contains(err.Error(), "connection to private IP blocked") { + t.Errorf("%s: unexpected message: %v", host, err) + } + for _, leaked := range []string{"10.20.30.40", "169.254.169.254", "resolved to"} { + if strings.Contains(err.Error(), leaked) { + t.Errorf("%s: message leaks %q: %v", host, leaked, err) + } + } + } +} + +func TestSafeDialer_NonPolicyErrorsDoNotWrapSentinel(t *testing.T) { + withResolver(t, map[string][]string{"empty.example": {}}) + opts := defaultOptions() + dial := safeDialer(&opts) + + for _, addr := range []string{"nxdomain.example:80", "empty.example:80", "no-port"} { + _, err := dial(context.Background(), "tcp", addr) + if err == nil { + t.Fatalf("%s: expected error", addr) + } + if errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: must not wrap ErrBlockedAddress: %v", addr, err) + } + } +} + +func TestSafeDialer_RejectsBlockedAnswerInEitherPosition(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + _, port, _ := net.SplitHostPort(srv.Listener.Addr().String()) + + withResolver(t, map[string][]string{ + "blocked-first.example": {"10.0.0.5", "127.0.0.1"}, + "blocked-last.example": {"127.0.0.1", "10.0.0.5"}, + "cgnat-mixed.example": {"127.0.0.1", "100.64.0.1"}, + "trans-mixed.example": {"2002::1", "127.0.0.1"}, + "meta-alias.example": {"::ffff:169.254.169.254", "127.0.0.1"}, + "clean.example": {"127.0.0.1"}, + }) + + tests := []struct { + name string + host string + opts ClientOptions + ok bool + }{ + {"rfc1918 blocked first", "blocked-first.example", ClientOptions{AllowLocalhost: true}, false}, + {"rfc1918 blocked last", "blocked-last.example", ClientOptions{AllowLocalhost: true}, false}, + {"rfc1918 allowed last", "blocked-last.example", ClientOptions{AllowLocalhost: true, AllowRFC1918: true}, true}, + {"cgnat blocked by option", "cgnat-mixed.example", ClientOptions{AllowLocalhost: true, BlockCGNAT: true}, false}, + {"cgnat not blocked without option", "cgnat-mixed.example", ClientOptions{AllowLocalhost: true}, true}, + {"transition blocked by option", "trans-mixed.example", ClientOptions{AllowLocalhost: true, BlockTransitionRanges: true}, false}, + {"mapped link-local blocked despite allowances", "meta-alias.example", ClientOptions{AllowLocalhost: true, AllowRFC1918: true}, false}, + {"clean answer", "clean.example", ClientOptions{AllowLocalhost: true}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := tt.opts + opts.DialTimeout = 2 * time.Second + conn, err := safeDialer(&opts)(context.Background(), "tcp", net.JoinHostPort(tt.host, port)) + if tt.ok { + if err != nil { + t.Fatalf("expected dial to succeed: %v", err) + } + _ = conn.Close() + return + } + if conn != nil { + _ = conn.Close() + } + if !errors.Is(err, ErrBlockedAddress) { + t.Fatalf("expected ErrBlockedAddress, got %v", err) + } + }) + } + if hits.Load() != 0 { + t.Errorf("server received %d requests; no HTTP request should have been sent", hits.Load()) + } +} + +func TestValidateRedirectTarget_SharedPolicy(t *testing.T) { + withResolver(t, map[string][]string{ + "lan.example": {"192.168.1.10"}, + "overlay.example": {"100.64.0.9"}, + "nat64.example": {"64:ff9b::1"}, + "public.example": {"8.8.8.8"}, + "meta.example": {"169.254.169.254"}, + }) + + tests := []struct { + name string + host string + opts ClientOptions + want bool // true = blocked + }{ + {"rfc1918 default", "lan.example", ClientOptions{}, true}, + {"rfc1918 allowed", "lan.example", ClientOptions{AllowRFC1918: true}, false}, + {"cgnat blocked", "overlay.example", ClientOptions{AllowRFC1918: true, BlockCGNAT: true}, true}, + {"cgnat default", "overlay.example", ClientOptions{}, false}, + {"transition blocked", "nat64.example", ClientOptions{BlockTransitionRanges: true}, true}, + {"public", "public.example", ClientOptions{}, false}, + {"link-local with allowances", "meta.example", ClientOptions{AllowRFC1918: true, AllowLocalhost: true}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := tt.opts + opts.DialTimeout = time.Second + req, _ := http.NewRequest(http.MethodGet, "https://"+tt.host+"/", http.NoBody) + err := validateRedirectTarget(req, &opts) + if tt.want { + if !errors.Is(err, ErrBlockedAddress) { + t.Fatalf("expected ErrBlockedAddress, got %v", err) + } + if strings.Contains(err.Error(), "192.168") || strings.Contains(err.Error(), "169.254") { + t.Errorf("message leaks resolved address: %v", err) + } + return + } + if err != nil { + t.Fatalf("expected redirect allowed, got %v", err) + } + }) + } +} + +func TestValidateRedirectTarget_LocalhostWrapsSentinel(t *testing.T) { + t.Parallel() + opts := &ClientOptions{DialTimeout: time.Second} + req, _ := http.NewRequest(http.MethodGet, "http://localhost/", http.NoBody) + if err := validateRedirectTarget(req, opts); !errors.Is(err, ErrBlockedAddress) { + t.Fatalf("expected ErrBlockedAddress, got %v", err) + } +} + +func TestValidateRedirectTarget_ResolutionFailure(t *testing.T) { + withResolver(t, map[string][]string{}) + opts := &ClientOptions{DialTimeout: time.Second} + req, _ := http.NewRequest(http.MethodGet, "https://gone.example/", http.NoBody) + err := validateRedirectTarget(req, opts) + if err == nil || errors.Is(err, ErrBlockedAddress) { + t.Fatalf("expected non-policy resolution error, got %v", err) + } +} + +func TestNewSafeHTTPClient_BlockOptionsWired(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + })) + defer srv.Close() + _, port, _ := net.SplitHostPort(srv.Listener.Addr().String()) + + withResolver(t, map[string][]string{ + "overlay.example": {"100.64.0.9"}, + "nat64.example": {"64:ff9b::1"}, + }) + + for _, host := range []string{"overlay.example", "nat64.example"} { + client := NewSafeHTTPClient( + WithAllowRFC1918(), WithBlockCGNAT(), WithBlockTransitionRanges(), + WithTimeout(2*time.Second), + ) + resp, err := client.Get("http://" + net.JoinHostPort(host, port) + "/") + if err == nil { + resp.Body.Close() + t.Fatalf("%s: expected request to be blocked", host) + } + if !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: sentinel must survive *url.Error wrapping: %v", host, err) + } + } + if hits.Load() != 0 { + t.Errorf("server hit %d times, want 0", hits.Load()) + } +} diff --git a/backend/internal/security/url_validator.go b/backend/internal/security/url_validator.go index f29e5f4f7..4e740b94f 100644 --- a/backend/internal/security/url_validator.go +++ b/backend/internal/security/url_validator.go @@ -297,9 +297,9 @@ func ValidateExternalURL(rawURL string, options ...ValidationOption) (string, er // Cloud metadata endpoint must produce the specific error even // when the address arrives as an IPv4-mapped IPv6 value. if ipv4.String() == "169.254.169.254" { - return "", fmt.Errorf("access to cloud metadata endpoints is blocked for security (detected: %s)", sanitizeIPForError(ipv4.String())) + return "", blockedAddressError("access to cloud metadata endpoints is blocked for security (detected: %s)", sanitizeIPForError(ipv4.String())) } - return "", fmt.Errorf("connection to private ip addresses is blocked for security (detected: %s)", sanitizeIPForError(ipv4.String())) + return "", blockedAddressError("connection to private ip addresses is blocked for security (detected: %s)", sanitizeIPForError(ipv4.String())) } } @@ -323,9 +323,9 @@ func ValidateExternalURL(rawURL string, options ...ValidationOption) (string, er // Don't leak internal IPs in error messages to external users sanitizedIP := sanitizeIPForError(ip.String()) if ip.String() == "169.254.169.254" { - return "", fmt.Errorf("access to cloud metadata endpoints is blocked for security (detected: %s)", sanitizedIP) + return "", blockedAddressError("access to cloud metadata endpoints is blocked for security (detected: %s)", sanitizedIP) } - return "", fmt.Errorf("connection to private ip addresses is blocked for security (detected: %s)", sanitizedIP) + return "", blockedAddressError("connection to private ip addresses is blocked for security (detected: %s)", sanitizedIP) } } } @@ -366,6 +366,21 @@ func parsePort(port string) (int, error) { return portNum, nil } +// blockedAddressErr carries a policy-rejection message verbatim while letting +// callers detect it with errors.Is(err, network.ErrBlockedAddress). +type blockedAddressErr struct{ msg string } + +func (e *blockedAddressErr) Error() string { return e.msg } + +func (e *blockedAddressErr) Unwrap() error { return network.ErrBlockedAddress } + +// blockedAddressError formats a message for a destination rejected by the +// address policy. Only address rejections use it; scheme, DNS and port +// failures do not wrap the sentinel. +func blockedAddressError(format string, args ...any) error { + return &blockedAddressErr{msg: fmt.Sprintf(format, args...)} +} + // sanitizeIPForError removes sensitive details from IP addresses in error messages. // This prevents leaking internal network topology to external users. func sanitizeIPForError(ip string) string { diff --git a/backend/internal/security/url_validator_sentinel_test.go b/backend/internal/security/url_validator_sentinel_test.go new file mode 100644 index 000000000..9852e7835 --- /dev/null +++ b/backend/internal/security/url_validator_sentinel_test.go @@ -0,0 +1,60 @@ +package security + +import ( + "errors" + "testing" + + "github.com/Wikid82/charon/backend/internal/network" +) + +func TestValidateExternalURL_WrapsBlockedAddressSentinel(t *testing.T) { + t.Parallel() + tests := []struct { + name string + url string + }{ + {"loopback literal", "http://127.0.0.1"}, + {"rfc1918 literal", "http://10.0.0.5"}, + {"metadata literal", "http://169.254.169.254"}, + {"ipv6 loopback", "http://[::1]"}, + {"mapped metadata", "http://[::ffff:169.254.169.254]"}, + {"mapped rfc1918", "http://[::ffff:192.168.1.1]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := ValidateExternalURL(tt.url, WithAllowHTTP()) + if err == nil { + t.Fatalf("expected %s to be rejected", tt.url) + } + if !errors.Is(err, network.ErrBlockedAddress) { + t.Errorf("error does not wrap ErrBlockedAddress: %v", err) + } + }) + } +} + +func TestValidateExternalURL_NonAddressFailuresDoNotWrapSentinel(t *testing.T) { + t.Parallel() + tests := []struct { + name string + url string + }{ + {"bad scheme", "ftp://example.com"}, + {"userinfo", "https://user:pw@example.com"}, + {"bad port", "https://example.com:70000"}, + {"http without opt-in", "http://example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := ValidateExternalURL(tt.url) + if err == nil { + t.Fatalf("expected %s to be rejected", tt.url) + } + if errors.Is(err, network.ErrBlockedAddress) { + t.Errorf("non-address failure must not wrap the sentinel: %v", err) + } + }) + } +} diff --git a/backend/pkg/safehttp/safehttp.go b/backend/pkg/safehttp/safehttp.go new file mode 100644 index 000000000..e0cddb88d --- /dev/null +++ b/backend/pkg/safehttp/safehttp.go @@ -0,0 +1,284 @@ +// Package safehttp provides validated outbound HTTP for in-tree and +// community DNS provider plugins that cannot import Charon's internal +// packages. It is a deliberately minimal facade over the internal network +// helpers. +package safehttp + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wikid82/charon/backend/internal/network" + "github.com/Wikid82/charon/backend/internal/security" +) + +// ErrBlockedAddress is wrapped by every error reporting a destination that the +// address policy rejects. Test for it with errors.Is; the message never +// contains a resolved IP address. +var ErrBlockedAddress = network.ErrBlockedAddress + +const maxHostnameLength = 253 + +// Policy selects which address classes an integration may reach. The zero +// value is the strictest policy (https only, public addresses only). Fields are +// unexported: policies can only be built through the constructors below. +type Policy struct { + allowHTTP bool + allowRFC1918 bool + allowCGNAT bool +} + +// PublicHTTPSOnly returns the strictest policy: https only, and RFC 1918, +// carrier-grade NAT and transition ranges are blocked. +func PublicHTTPSOnly() Policy { + return Policy{} +} + +// PrivateNetworkOK returns a policy that permits http and https and allows +// RFC 1918 and carrier-grade NAT addresses. Loopback, link-local, unspecified, +// reserved and transition ranges stay blocked. +func PrivateNetworkOK() Policy { + return Policy{allowHTTP: true, allowRFC1918: true, allowCGNAT: true} +} + +// clientOptions maps the policy onto the internal client options. +func (p Policy) clientOptions(timeout time.Duration) []network.Option { + opts := []network.Option{network.WithTimeout(timeout), network.WithBlockTransitionRanges()} + if p.allowRFC1918 { + opts = append(opts, network.WithAllowRFC1918()) + } + if !p.allowCGNAT { + opts = append(opts, network.WithBlockCGNAT()) + } + return opts +} + +// ValidateURLSyntax validates raw without any DNS lookups: the scheme allowlist +// for the policy, a present hostname, no userinfo, no query, no fragment, and +// literal-IP hosts checked against the address policy. +func ValidateURLSyntax(raw string, p Policy) (*url.URL, error) { + if strings.TrimSpace(raw) == "" { + return nil, errors.New("url is required") + } + if strings.ContainsAny(raw, "#") { + return nil, errors.New("url must not contain a fragment") + } + + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid url format: %w", err) + } + + switch u.Scheme { + case "https": + case "http": + if !p.allowHTTP { + return nil, errors.New("http scheme not allowed (use https)") + } + default: + return nil, fmt.Errorf("unsupported scheme %q (only http and https are allowed)", u.Scheme) + } + + if u.Opaque != "" { + return nil, errors.New("url must be of the form scheme://host[:port][/path]") + } + if u.User != nil { + return nil, errors.New("urls with embedded credentials are not allowed") + } + if u.RawQuery != "" || u.ForceQuery { + return nil, errors.New("url must not contain a query string") + } + host := u.Hostname() + if host == "" { + return nil, errors.New("missing hostname in url") + } + if len(host) > maxHostnameLength { + return nil, fmt.Errorf("hostname exceeds maximum length of %d characters", maxHostnameLength) + } + if strings.Contains(host, "..") { + return nil, errors.New("hostname contains suspicious pattern (..)") + } + + if port := u.Port(); port != "" { + n, convErr := strconv.Atoi(port) + if convErr != nil || n < 1 || n > 65535 { + return nil, fmt.Errorf("invalid port: %s", port) + } + } + + if err := checkLiteralHost(host, p); err != nil { + return nil, err + } + return u, nil +} + +// checkLiteralHost applies the address policy to hosts that are literal IPs or +// well-known loopback names. Other hostnames cannot be judged without DNS; the +// dialer in NewClient is authoritative for those. +func checkLiteralHost(host string, p Policy) error { + name := strings.TrimSuffix(host, ".") + lower := strings.ToLower(name) + if lower == "localhost" || strings.HasSuffix(lower, ".localhost") { + return fmt.Errorf("url host is not allowed: %w", ErrBlockedAddress) + } + + // Drop an IPv6 zone identifier before parsing; zoned addresses are + // link-local and are rejected by the range checks below. + if i := strings.IndexByte(name, '%'); i >= 0 { + name = name[:i] + } + ip := net.ParseIP(name) + if ip == nil { + if isNonCanonicalNumericHost(name) { + return fmt.Errorf("url host is not allowed: %w", ErrBlockedAddress) + } + return nil + } + + blocked := network.IsTransitionRange(ip) || + (network.IsCGNAT(ip) && !p.allowCGNAT) || + (network.IsPrivateIP(ip) && (!p.allowRFC1918 || !network.IsRFC1918(ip))) + if blocked { + return fmt.Errorf("url host is not allowed: %w", ErrBlockedAddress) + } + return nil +} + +// isNonCanonicalNumericHost reports whether host is made only of numeric +// segments (decimal digits or 0x-prefixed hex) separated by dots but is not a +// canonical dotted-quad address, such as "127.1", "2130706433", "0x7f000001" or +// "0177.0.0.1". Some resolvers and proxies read these as IPv4 addresses, so +// they are refused rather than guessed at. Names that merely contain digits +// (dns1.example.com, 10-0-0-1.example.com) are not affected. +func isNonCanonicalNumericHost(host string) bool { + if host == "" { + return false + } + for _, seg := range strings.Split(host, ".") { + if !isNumericSegment(seg) { + return false + } + } + return true +} + +func isNumericSegment(seg string) bool { + if seg == "" { + return false + } + digits := seg + isDigit := func(r rune) bool { return r >= '0' && r <= '9' } + if len(seg) >= 2 && seg[0] == '0' && (seg[1] == 'x' || seg[1] == 'X') { + digits = seg[2:] + isDigit = func(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') + } + } + for _, r := range digits { + if !isDigit(r) { + return false + } + } + return true +} + +// ValidateURL runs ValidateURLSyntax and then a DNS-based address check. +// +// It is an early-error convenience only: the dialer inside NewClient re-checks +// every address at connect time and is the authoritative control. In particular +// the early check does not apply the carrier-grade NAT or transition-range +// rules to resolved hostnames. +func ValidateURL(raw string, p Policy) (*url.URL, error) { + u, err := ValidateURLSyntax(raw, p) + if err != nil { + return nil, err + } + + opts := []security.ValidationOption{} + if p.allowHTTP { + opts = append(opts, security.WithAllowHTTP()) + } + if p.allowRFC1918 { + opts = append(opts, security.WithAllowRFC1918()) + } + // The returned normalised string is discarded on purpose: requests are built + // from the *url.URL parsed above, never from a re-parsed string. + if _, err := security.ValidateExternalURL(raw, opts...); err != nil { + if errors.Is(err, network.ErrBlockedAddress) { + return nil, fmt.Errorf("url validation failed: %w", ErrBlockedAddress) + } + return nil, fmt.Errorf("url validation failed: %w", err) + } + return u, nil +} + +// NewClient returns an HTTP client that ignores proxy environment variables, +// does not follow redirects, and re-validates the destination address on every +// connection. There is deliberately no way to allow loopback through this API. +func NewClient(p Policy, timeout time.Duration) *http.Client { + return newClient(p, timeout) +} + +// newClient is NewClient plus extra internal options; it exists so this +// package's own tests can reach httptest servers. +func newClient(p Policy, timeout time.Duration, extra ...network.Option) *http.Client { + return network.NewSafeHTTPClient(append(p.clientOptions(timeout), extra...)...) +} + +// JoinPath appends escaped path segments to the path of a validated base URL +// and returns the resulting absolute URL string. Empty segments, dot segments +// (including encoded forms), control characters and separators inside a segment +// are rejected, so each segment always stays exactly one path element. +func JoinPath(base *url.URL, segments ...string) (string, error) { + if base == nil || base.Host == "" { + return "", errors.New("base url is required") + } + + var b strings.Builder + b.WriteString(base.Scheme) + b.WriteString("://") + b.WriteString(base.Host) + b.WriteString(strings.TrimRight(base.EscapedPath(), "/")) + + for _, seg := range segments { + if err := checkSegment(seg); err != nil { + return "", err + } + b.WriteByte('/') + b.WriteString(url.PathEscape(seg)) + } + return b.String(), nil +} + +func checkSegment(seg string) error { + if seg == "" { + return errors.New("path segment must not be empty") + } + for _, candidate := range []string{seg, unescapeOrSelf(seg)} { + if candidate == "." || candidate == ".." { + return errors.New("path segment must not be a dot segment") + } + if strings.ContainsAny(candidate, `/\`) { + return errors.New("path segment must not contain a path separator") + } + for _, r := range candidate { + if r < 0x20 || r == 0x7f { + return errors.New("path segment must not contain control characters") + } + } + } + return nil +} + +func unescapeOrSelf(s string) string { + if dec, err := url.PathUnescape(s); err == nil { + return dec + } + return s +} diff --git a/backend/pkg/safehttp/safehttp_test.go b/backend/pkg/safehttp/safehttp_test.go new file mode 100644 index 000000000..3c80aa2fe --- /dev/null +++ b/backend/pkg/safehttp/safehttp_test.go @@ -0,0 +1,446 @@ +package safehttp + +import ( + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Wikid82/charon/backend/internal/network" +) + +func policies() map[string]Policy { + return map[string]Policy{ + "zero": {}, + "public": PublicHTTPSOnly(), + "privateOK": PrivateNetworkOK(), + } +} + +func TestPolicyConstructors(t *testing.T) { + t.Parallel() + if (Policy{}) != PublicHTTPSOnly() { + t.Error("zero Policy must equal PublicHTTPSOnly (strictest)") + } + if p := PrivateNetworkOK(); !p.allowHTTP || !p.allowRFC1918 || !p.allowCGNAT { + t.Errorf("PrivateNetworkOK = %+v", p) + } + if p := PublicHTTPSOnly(); p.allowHTTP || p.allowRFC1918 || p.allowCGNAT { + t.Errorf("PublicHTTPSOnly = %+v", p) + } +} + +func TestValidateURLSyntax_AlwaysBlockedAddresses(t *testing.T) { + t.Parallel() + hosts := []string{ + "169.254.169.254", "[::ffff:169.254.169.254]", "169.254.169.254.", "[fd00:ec2::254]", + "127.0.0.1", "127.0.0.2", "[::1]", "[::ffff:127.0.0.1]", "localhost", "localhost.", "LOCALHOST", "foo.localhost", + "[fd00::1]", "[fe80::1]", "[fe80::1%25eth0]", + "0.0.0.0", "240.0.0.1", "255.255.255.255", + "192.0.0.1", "198.18.0.1", "[64:ff9b::1]", "[64:ff9b:1::1]", "[2002::1]", "[::1.2.3.4]", + "[2001:0:4136:e378:8000:63bf:3fff:fdd2]", + } + for name, p := range policies() { + for _, h := range hosts { + for _, scheme := range []string{"https", "http"} { + raw := scheme + "://" + h + "/" + _, err := ValidateURLSyntax(raw, p) + if err == nil { + t.Errorf("%s: %s accepted", name, raw) + continue + } + // http under the strict policy fails on scheme first; every https form + // (and every http form under a policy that allows it) must carry the sentinel. + if (scheme == "https" || p.allowHTTP) && !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: %s: expected ErrBlockedAddress, got %v", name, raw, err) + } + } + } + } +} + +func TestValidateURLSyntax_PolicyDependentAddresses(t *testing.T) { + t.Parallel() + tests := []struct { + host string + publicOK bool + privateOK bool + }{ + {"10.0.0.1", false, true}, + {"172.16.0.1", false, true}, + {"192.168.1.1", false, true}, + {"[::ffff:192.168.1.1]", false, true}, + {"100.64.0.1", false, true}, + {"100.127.255.254", false, true}, + {"8.8.8.8", true, true}, + {"[2606:4700:4700::1111]", true, true}, + {"example.com", true, true}, + } + for _, tt := range tests { + raw := "https://" + tt.host + ":8081" + _, errPub := ValidateURLSyntax(raw, PublicHTTPSOnly()) + if (errPub == nil) != tt.publicOK { + t.Errorf("PublicHTTPSOnly %s: err=%v, wantOK=%v", raw, errPub, tt.publicOK) + } + if errPub != nil && !errors.Is(errPub, ErrBlockedAddress) { + t.Errorf("PublicHTTPSOnly %s: missing sentinel: %v", raw, errPub) + } + _, errPriv := ValidateURLSyntax(raw, PrivateNetworkOK()) + if (errPriv == nil) != tt.privateOK { + t.Errorf("PrivateNetworkOK %s: err=%v, wantOK=%v", raw, errPriv, tt.privateOK) + } + } +} + +func TestValidateURLSyntax_MalformedAndTricks(t *testing.T) { + t.Parallel() + reject := []string{ + "", " ", + "https://user:pw@example.com/", "https://good.com@169.254.169.254/", "https://user@example.com/", + "https://example.com/#@good.com", "https://example.com/#frag", + `https://good.com\@10.0.0.1/`, + "https://example.com/?x=1", "https://example.com/?", + "file:///etc/passwd", "gopher://example.com", "ftp://example.com", "javascript:alert(1)", + "//example.com", "example.com", "mailto:a@b.c", + "https:///path", "https://", + "https://exa..mple.com", + "https://" + strings.Repeat("a", 254) + ".com", + "https://example.com:0", "https://example.com:70000", "https://example.com:abc", + "https://exa mple.com", + "https:example.com", + } + for _, raw := range reject { + if _, err := ValidateURLSyntax(raw, PrivateNetworkOK()); err == nil { + t.Errorf("%q accepted", raw) + } + } + + if _, err := ValidateURLSyntax("http://example.com", PublicHTTPSOnly()); err == nil { + t.Error("http accepted under PublicHTTPSOnly") + } + if _, err := ValidateURLSyntax("http://example.com:8081", PrivateNetworkOK()); err != nil { + t.Errorf("http rejected under PrivateNetworkOK: %v", err) + } + u, err := ValidateURLSyntax("HTTPS://Example.com:8443/prefix/", PublicHTTPSOnly()) + if err != nil { + t.Fatalf("mixed-case scheme rejected: %v", err) + } + if u.Scheme != "https" || u.Hostname() != "Example.com" || u.Port() != "8443" || u.Path != "/prefix/" { + t.Errorf("unexpected parse: %+v", u) + } +} + +func TestValidateURL(t *testing.T) { + t.Parallel() + + for _, raw := range []string{ + "https://127.0.0.1/", "https://localhost/", "https://169.254.169.254/", + "https://[::1]/", "http://127.0.0.1:8081/", + } { + _, err := ValidateURL(raw, PrivateNetworkOK()) + if !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: expected ErrBlockedAddress, got %v", raw, err) + } + if err != nil && strings.Contains(err.Error(), "127.0.0.1") && !strings.Contains(raw, "127.0.0.1") { + t.Errorf("%s: message leaks resolved address: %v", raw, err) + } + } + + // A literal RFC 1918 address passes under the private policy (no DNS needed). + u, err := ValidateURL("http://10.0.0.5:8081/pdns", PrivateNetworkOK()) + if err != nil { + t.Fatalf("private literal rejected: %v", err) + } + if u.Host != "10.0.0.5:8081" || u.Path != "/pdns" { + t.Errorf("unexpected url %+v", u) + } + if _, err := ValidateURL("https://10.0.0.5/", PublicHTTPSOnly()); !errors.Is(err, ErrBlockedAddress) { + t.Errorf("private literal under strict policy: %v", err) + } + + // Privileged non-standard ports are rejected by the shared validator. + if _, err := ValidateURL("https://8.8.8.8:22/", PublicHTTPSOnly()); err == nil { + t.Error("privileged port accepted") + } else if errors.Is(err, ErrBlockedAddress) { + t.Errorf("port failure must not carry the address sentinel: %v", err) + } + + // Syntax failures surface before any lookup. + if _, err := ValidateURL("https://user:pw@8.8.8.8/", PublicHTTPSOnly()); err == nil { + t.Error("userinfo accepted") + } +} + +func TestNewClient_HardenedTransportForEveryPolicy(t *testing.T) { + t.Parallel() + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) })) + defer srv.Close() + + for name, p := range policies() { + client := NewClient(p, 2*time.Second) + tr, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("%s: unexpected transport %T", name, client.Transport) + } + if tr.Proxy != nil { + t.Errorf("%s: Proxy must be nil", name) + } + if client.Timeout != 2*time.Second { + t.Errorf("%s: timeout = %v", name, client.Timeout) + } + if err := client.CheckRedirect(&http.Request{}, nil); !errors.Is(err, http.ErrUseLastResponse) { + t.Errorf("%s: CheckRedirect = %v, want ErrUseLastResponse", name, err) + } + + resp, err := client.Get(srv.URL) + if err == nil { + resp.Body.Close() + t.Errorf("%s: loopback request succeeded", name) + continue + } + if !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: sentinel lost through *url.Error: %v", name, err) + } + var ue *url.Error + if !errors.As(err, &ue) { + t.Errorf("%s: expected *url.Error, got %T", name, err) + } + } + if hits.Load() != 0 { + t.Errorf("loopback server hit %d times, want 0", hits.Load()) + } +} + +func TestNewClient_NamedLoopbackBlockedAtDial(t *testing.T) { + t.Parallel() + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) })) + defer srv.Close() + _, port, _ := net.SplitHostPort(srv.Listener.Addr().String()) + + // The pre-check is bypassed on purpose: the dialer alone must stop a name + // that resolves to loopback. + for _, host := range []string{"localhost", "localhost."} { + resp, err := NewClient(PrivateNetworkOK(), 2*time.Second).Get("http://" + host + ":" + port + "/") + if err == nil { + resp.Body.Close() + t.Fatalf("%s: request succeeded", host) + } + if hits.Load() != 0 { + t.Fatalf("%s: server was reached", host) + } + } +} + +func TestClient_RedirectsAreNotFollowed(t *testing.T) { + t.Parallel() + for _, code := range []int{http.StatusFound, http.StatusMovedPermanently, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + var secondHits atomic.Int32 + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + secondHits.Add(1) + _, _ = w.Write([]byte("second")) + })) + + targets := []string{second.URL, "http://169.254.169.254/latest/meta-data/", "https://example.com/"} + for _, target := range targets { + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target, code) + })) + resp, err := newClient(PrivateNetworkOK(), 2*time.Second, network.WithAllowLocalhost()).Get(first.URL) + if err != nil { + t.Fatalf("code %d target %s: %v", code, target, err) + } + if resp.StatusCode != code { + t.Errorf("code %d target %s: got status %d, want the 3xx surfaced", code, target, resp.StatusCode) + } + resp.Body.Close() + first.Close() + } + if secondHits.Load() != 0 { + t.Errorf("code %d: redirect target hit %d times, want 0", code, secondHits.Load()) + } + second.Close() + } +} + +func TestClient_IgnoresProxyEnvironment(t *testing.T) { + var proxyConns atomic.Int32 + proxy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = proxy.Close() }() + go func() { + for { + c, acceptErr := proxy.Accept() + if acceptErr != nil { + return + } + proxyConns.Add(1) + _ = c.Close() + } + }() + + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) })) + defer srv.Close() + + proxyURL := "http://" + proxy.Addr().String() + for _, env := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { + t.Setenv(env, proxyURL) + } + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + resp, err := newClient(PrivateNetworkOK(), 2*time.Second, network.WithAllowLocalhost()).Get(srv.URL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if hits.Load() != 1 { + t.Errorf("server hits = %d, want 1 (direct connection)", hits.Load()) + } + if proxyConns.Load() != 0 { + t.Errorf("proxy saw %d connections, want 0", proxyConns.Load()) + } +} + +func TestClient_TimeoutAndBodyNotRequired(t *testing.T) { + t.Parallel() + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(500 * time.Millisecond) + })) + defer slow.Close() + + start := time.Now() + resp, err := newClient(PrivateNetworkOK(), 100*time.Millisecond, network.WithAllowLocalhost()).Get(slow.URL) + if err == nil { + resp.Body.Close() + t.Fatal("expected timeout") + } + if elapsed := time.Since(start); elapsed > 400*time.Millisecond { + t.Errorf("timeout took %v", elapsed) + } +} + +func TestJoinPath(t *testing.T) { + t.Parallel() + mustParse := func(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u + } + + ok := []struct { + name string + base string + segs []string + want string + }{ + {"plain", "https://pdns.example.com:8081", []string{"api", "v1", "servers", "localhost"}, "https://pdns.example.com:8081/api/v1/servers/localhost"}, + {"trailing slash", "https://pdns.example.com/", []string{"api"}, "https://pdns.example.com/api"}, + {"path prefix", "https://example.com/pdns/", []string{"api", "v1", "servers", "ns1.example_com-2"}, "https://example.com/pdns/api/v1/servers/ns1.example_com-2"}, + {"ipv6 host", "http://[fd00::1]:8081", []string{"api"}, "http://[fd00::1]:8081/api"}, + {"space is escaped", "https://example.com", []string{"a b"}, "https://example.com/a%20b"}, + {"percent is escaped", "https://example.com", []string{"a%b"}, "https://example.com/a%25b"}, + {"query chars escaped", "https://example.com", []string{"x?a=b", "y#f"}, "https://example.com/x%3Fa=b/y%23f"}, + {"no segments", "https://example.com/base", nil, "https://example.com/base"}, + } + for _, tt := range ok { + got, err := JoinPath(mustParse(tt.base), tt.segs...) + if err != nil { + t.Errorf("%s: %v", tt.name, err) + continue + } + if got != tt.want { + t.Errorf("%s: got %q want %q", tt.name, got, tt.want) + } + // Each segment must survive as exactly one path element. + if len(tt.segs) > 0 { + count := func(p string) int { + p = strings.Trim(p, "/") + if p == "" { + return 0 + } + return len(strings.Split(p, "/")) + } + if n := count(mustParse(got).EscapedPath()); n != count(mustParse(tt.base).EscapedPath())+len(tt.segs) { + t.Errorf("%s: %q has %d path elements", tt.name, got, n) + } + } + } + + base := mustParse("https://example.com") + for _, seg := range []string{"", ".", "..", "%2e", "%2E", "%2e%2e", "%2E%2e", "a/b", `a\b`, "a%2Fb", "a%5Cb", "x\r\ny", "x\ny", "a\x00b"} { + if got, err := JoinPath(base, "api", seg); err == nil { + t.Errorf("segment %q accepted: %s", seg, got) + } + } + + if _, err := JoinPath(nil, "a"); err == nil { + t.Error("nil base accepted") + } + if _, err := JoinPath(&url.URL{}, "a"); err == nil { + t.Error("hostless base accepted") + } +} + +func TestErrBlockedAddressIdentity(t *testing.T) { + t.Parallel() + if !errors.Is(ErrBlockedAddress, network.ErrBlockedAddress) { + t.Error("safehttp.ErrBlockedAddress must be the network sentinel") + } +} + +func TestValidateURLSyntax_RejectsNonCanonicalNumericHosts(t *testing.T) { + t.Parallel() + hosts := []string{ + "127.1", "2130706433", "0x7f000001", "0177.0.0.1", "0x7f.0.0.1", "0x7f.1", + "017700000001", "169.254.43518", "10.1", "1.2.3.4.5", "0X7F000001", "127.1.", "0x", + } + for name, p := range policies() { + for _, h := range hosts { + raw := "https://" + h + ":8081/" + _, err := ValidateURLSyntax(raw, p) + if err == nil { + t.Errorf("%s: %s accepted", name, raw) + continue + } + if !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: %s error %v does not wrap ErrBlockedAddress", name, raw, err) + } + } + } +} + +func TestValidateURLSyntax_AllowsDigitContainingNames(t *testing.T) { + t.Parallel() + hosts := []string{ + "dns1.example.com", "10-0-0-1.example.com", "123.example.com", "1.2.3.4.example.com", + "0x7f.example.com", "a1", "pdns-2", "8.8.8.8", "203.0.113.7", + } + for _, h := range hosts { + if _, err := ValidateURLSyntax("https://"+h+"/", PublicHTTPSOnly()); err != nil { + t.Errorf("%s rejected: %v", h, err) + } + } +} + +func TestIsNonCanonicalNumericHost_EdgeCases(t *testing.T) { + t.Parallel() + for host, want := range map[string]bool{ + "": false, ".": false, ".1": false, "1.": false, "1.2.3.4": true, "0x": true, "0xg": false, "example": false, + } { + if got := isNonCanonicalNumericHost(host); got != want { + t.Errorf("isNonCanonicalNumericHost(%q) = %v, want %v", host, got, want) + } + } +} From febd3cd4d3b3fda96d3ac43a7eb2a7ef48056a6b Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 20:39:56 +0000 Subject: [PATCH 25/57] fix(security): harden URL handling in provider integrations Route the tunnel provider integrations and the security notification sender through the shared request helper so that administrator-configured endpoints are checked against the private-network policy on every connection, and request paths for provider calls are built defensively. Duplicate per-package address tables are removed in favour of the shared one, and the global blocked-address list gains one entry. Endpoints in shared-address (overlay) ranges are no longer accepted for the tunnel integrations; malformed network identifiers now return a 400. --- .../internal/api/handlers/hecate_handler.go | 13 +- .../api/handlers/hecate_handler_test.go | 39 ++++ .../hecate/providers/netbird/api_client.go | 96 ++++------ .../netbird/api_client_policy_test.go | 138 ++++++++++++++ .../hecate/providers/netbird/provider_test.go | 73 +------- .../hecate/providers/zerotier/api_client.go | 115 +++++------- .../zerotier/api_client_policy_test.go | 169 ++++++++++++++++++ .../providers/zerotier/provider_test.go | 84 ++------- backend/internal/network/safeclient.go | 6 + .../network/safeclient_policy_test.go | 64 +++++++ .../security/url_validator_sentinel_test.go | 13 ++ .../enhanced_security_notification_service.go | 7 +- ...nced_security_notification_service_test.go | 44 +++++ backend/pkg/safehttp/safehttp_test.go | 1 + 14 files changed, 584 insertions(+), 278 deletions(-) create mode 100644 backend/internal/hecate/providers/netbird/api_client_policy_test.go create mode 100644 backend/internal/hecate/providers/zerotier/api_client_policy_test.go diff --git a/backend/internal/api/handlers/hecate_handler.go b/backend/internal/api/handlers/hecate_handler.go index 7886feddd..6c7f82d1d 100644 --- a/backend/internal/api/handlers/hecate_handler.go +++ b/backend/internal/api/handlers/hecate_handler.go @@ -355,12 +355,23 @@ func (h *HecateHandler) ListZeroTierMembers(c *gin.Context) { } members, err := client.ListMembers(c.Request.Context(), networkID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + status, body := zeroTierMembersErrorResponse(err) + c.JSON(status, body) return } c.JSON(http.StatusOK, members) } +// zeroTierMembersErrorResponse maps a ListMembers failure to an HTTP response: +// a malformed network ID is a client error; anything else keeps the existing +// upstream-failure behaviour. +func zeroTierMembersErrorResponse(err error) (int, gin.H) { + if errors.Is(err, ztprovider.ErrInvalidNetworkID) { + return http.StatusBadRequest, gin.H{"error": "invalid network id"} + } + return http.StatusInternalServerError, gin.H{"error": err.Error()} +} + // ListNetBirdPeers proxies a ListPeers call to the active NetBird provider. func (h *HecateHandler) ListNetBirdPeers(c *gin.Context) { p, ok := h.svc.GetManager().GetProviderByType(models.ProviderNetBird) diff --git a/backend/internal/api/handlers/hecate_handler_test.go b/backend/internal/api/handlers/hecate_handler_test.go index 1ee99da9d..f7e802c15 100644 --- a/backend/internal/api/handlers/hecate_handler_test.go +++ b/backend/internal/api/handlers/hecate_handler_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -1290,3 +1291,41 @@ func TestHecateHandler_Update_ServiceError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) } + +// TestZeroTierMembersErrorResponse verifies that a malformed network ID maps to +// 400 while other upstream failures keep the existing 500 behaviour. +func TestZeroTierMembersErrorResponse(t *testing.T) { + status, body := zeroTierMembersErrorResponse(fmt.Errorf("wrapped: %w", ztprovider.ErrInvalidNetworkID)) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, gin.H{"error": "invalid network id"}, body) + + status, body = zeroTierMembersErrorResponse(errors.New("zerotier: unexpected status 502")) + assert.Equal(t, http.StatusInternalServerError, status) + assert.Equal(t, gin.H{"error": "zerotier: unexpected status 502"}, body) +} + +// TestHecateHandler_ListZeroTierMembers_InvalidNetworkID verifies a malformed +// network ID is reported as a client error. +func TestHecateHandler_ListZeroTierMembers_InvalidNetworkID(t *testing.T) { + h, svc := newHecateTestSetup(t) + + cfg := &models.TunnelConfig{UUID: "zt-badid-uuid", Provider: models.ProviderZeroTier} + ztProv, err := ztprovider.NewZeroTierProvider(cfg, `{"api_token":"test","controller_url":"https://8.8.8.8"}`) + require.NoError(t, err) + // Start with an already-cancelled context: the client is created and kept, + // and the token check fails without opening a connection. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.Error(t, ztProv.Start(ctx)) + require.NotNil(t, ztProv.GetClient()) + svc.GetManager().RegisterProvider("zt-badid-uuid", ztProv) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/management/hecate/zerotier/networks/bad/members", http.NoBody) + c.Params = gin.Params{{Key: "network_id", Value: "not-a-network-id"}} + h.ListZeroTierMembers(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid network id") +} diff --git a/backend/internal/hecate/providers/netbird/api_client.go b/backend/internal/hecate/providers/netbird/api_client.go index 75e7b3879..23b101037 100644 --- a/backend/internal/hecate/providers/netbird/api_client.go +++ b/backend/internal/hecate/providers/netbird/api_client.go @@ -4,38 +4,16 @@ import ( "context" "encoding/json" "fmt" - "net" "net/http" "net/url" "sync" "time" + + "github.com/Wikid82/charon/backend/pkg/safehttp" ) const defaultManagementURL = "https://api.netbird.io" -// privateRanges defines IP ranges that must not be contacted to prevent SSRF. -var privateRanges []*net.IPNet - -func init() { - cidrs := []string{ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "169.254.0.0/16", - "::1/128", - "fe80::/10", - "fc00::/7", - } - for _, cidr := range cidrs { - _, network, err := net.ParseCIDR(cidr) - if err != nil { - panic(fmt.Sprintf("netbird: invalid private CIDR %q: %v", cidr, err)) - } - privateRanges = append(privateRanges, network) - } -} - // NetBirdPeer represents a peer registered in a NetBird network. type NetBirdPeer struct { ID string `json:"id"` @@ -48,11 +26,14 @@ type NetBirdPeer struct { GroupsCount int `json:"groups_count,omitempty"` } -const cacheTTL = 60 * time.Second +const ( + cacheTTL = 60 * time.Second + requestTimeout = 15 * time.Second +) // NetBirdClient is an authenticated HTTP client for the NetBird Management API. type NetBirdClient struct { - baseURL string + baseURL *url.URL httpClient *http.Client accessToken string @@ -62,61 +43,42 @@ type NetBirdClient struct { cacheTTL time.Duration } -// NewNetBirdClient creates a NetBirdClient with SSRF validation on the management URL. -// Returns an error if managementURL is not a valid, reachable HTTPS address or if it -// resolves to a loopback, link-local, or RFC-1918 address. +// NewNetBirdClient creates a NetBirdClient for the given management URL. +// The URL must be https and must not point at a loopback, link-local, private, +// carrier-grade NAT or otherwise restricted address. The URL is checked up front +// for an early, readable error; the HTTP client then re-validates the destination +// on every connection and never follows redirects. func NewNetBirdClient(ctx context.Context, accessToken, managementURL string) (*NetBirdClient, error) { - return newNetBirdClientWithURL(ctx, accessToken, managementURL, false) + if managementURL == "" { + managementURL = defaultManagementURL + } + if _, err := safehttp.ValidateURL(managementURL, safehttp.PublicHTTPSOnly()); err != nil { + return nil, fmt.Errorf("netbird: invalid management_url: %w", err) + } + return newNetBirdClientWithHTTP(ctx, accessToken, managementURL, safehttp.NewClient(safehttp.PublicHTTPSOnly(), requestTimeout)) } -// newNetBirdClientWithURL is the internal constructor. When skipSSRF is true the -// DNS resolution check is skipped; use this only in tests that use httptest.Server. -func newNetBirdClientWithURL(ctx context.Context, accessToken, managementURL string, skipSSRF bool) (*NetBirdClient, error) { +// newNetBirdClientWithHTTP builds a client around an already-constructed +// http.Client without validating the URL against the address policy. Production +// code reaches it only through NewNetBirdClient; tests use it with an httptest +// server's client. +func newNetBirdClientWithHTTP(_ context.Context, accessToken, managementURL string, hc *http.Client) (*NetBirdClient, error) { if managementURL == "" { managementURL = defaultManagementURL } - parsed, err := url.Parse(managementURL) if err != nil { return nil, fmt.Errorf("netbird: invalid management_url: %w", err) } - if !skipSSRF { - if parsed.Scheme != "https" { - return nil, fmt.Errorf("netbird: management_url must use https scheme, got %q", parsed.Scheme) - } - host := parsed.Hostname() - addrs, resolveErr := net.DefaultResolver.LookupIPAddr(ctx, host) - if resolveErr != nil { - return nil, fmt.Errorf("netbird: resolve management host %q: %w", host, resolveErr) - } - for _, addr := range addrs { - if isPrivateIP(addr.IP) { - return nil, fmt.Errorf("netbird: management_url resolves to a private/loopback address — SSRF protection") - } - } - } - return &NetBirdClient{ - baseURL: managementURL, + baseURL: parsed, accessToken: accessToken, cacheTTL: cacheTTL, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + httpClient: hc, }, nil } -// isPrivateIP returns true if ip falls within any of the restricted private ranges. -func isPrivateIP(ip net.IP) bool { - for _, network := range privateRanges { - if network.Contains(ip) { - return true - } - } - return false -} - // ListPeers returns all peers visible to the configured access token. // Results are cached for 60 seconds; subsequent calls within that window return // the cached result without making an HTTP request. @@ -138,7 +100,11 @@ func (c *NetBirdClient) ForceRefresh(ctx context.Context) ([]NetBirdPeer, error) } func (c *NetBirdClient) fetchAndCache(ctx context.Context) ([]NetBirdPeer, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/peers", http.NoBody) + target, err := safehttp.JoinPath(c.baseURL, "api", "peers") + if err != nil { + return nil, fmt.Errorf("netbird: build request url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody) if err != nil { return nil, fmt.Errorf("netbird: build request: %w", err) } diff --git a/backend/internal/hecate/providers/netbird/api_client_policy_test.go b/backend/internal/hecate/providers/netbird/api_client_policy_test.go new file mode 100644 index 000000000..095a12c47 --- /dev/null +++ b/backend/internal/hecate/providers/netbird/api_client_policy_test.go @@ -0,0 +1,138 @@ +package netbird + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/Wikid82/charon/backend/pkg/safehttp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewNetBirdClient_DefaultURL(t *testing.T) { + c, err := newNetBirdClientWithHTTP(context.Background(), "tok", "", http.DefaultClient) + require.NoError(t, err) + assert.Equal(t, defaultManagementURL, c.baseURL.String()) +} + +func TestNewNetBirdClient_RejectsDisallowedURLs(t *testing.T) { + blocked := []string{ + "https://127.0.0.1", "https://127.0.0.2:8443", "https://[::1]", "https://[::ffff:127.0.0.1]", + "https://10.0.0.5", "https://192.168.1.1", "https://172.16.0.1", + "https://169.254.169.254", "https://[fd00::1]", "https://[fe80::1]", + "https://100.64.0.1", "https://100.127.255.254", + "https://[64:ff9b::1]", "https://[2002::1]", "https://192.0.0.1", "https://198.18.0.1", + "https://0.0.0.0", "https://localhost", "https://localhost.", + } + for _, raw := range blocked { + _, err := NewNetBirdClient(context.Background(), "tok", raw) + require.Error(t, err, raw) + assert.True(t, errors.Is(err, safehttp.ErrBlockedAddress), "%s: expected blocked-address error, got %v", raw, err) + assert.True(t, strings.HasPrefix(err.Error(), "netbird: "), "%s: missing prefix: %v", raw, err) + } + + other := []string{ + "http://api.netbird.io", "https://user:pw@api.netbird.io", "https://good.com@169.254.169.254", + "ftp://api.netbird.io", "https://api.netbird.io/?x=1", "https://api.netbird.io/#frag", "https://[::1", + } + for _, raw := range other { + _, err := NewNetBirdClient(context.Background(), "tok", raw) + require.Error(t, err, raw) + assert.True(t, strings.HasPrefix(err.Error(), "netbird: "), "%s: missing prefix: %v", raw, err) + } +} + +func TestNewNetBirdClient_ProductionClientIsHardened(t *testing.T) { + c, err := NewNetBirdClient(context.Background(), "tok", "https://8.8.8.8/mgmt/") + require.NoError(t, err) + + tr, ok := c.httpClient.Transport.(*http.Transport) + require.True(t, ok) + assert.Nil(t, tr.Proxy, "proxy environment must be ignored") + assert.ErrorIs(t, c.httpClient.CheckRedirect(&http.Request{}, nil), http.ErrUseLastResponse) + assert.Equal(t, "8.8.8.8", c.baseURL.Hostname()) +} + +func TestNetBirdClient_ProductionClientBlocksLoopbackAtDial(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("[]")) + })) + defer srv.Close() + + c, err := NewNetBirdClient(context.Background(), "tok", "https://8.8.8.8") + require.NoError(t, err) + // Re-point the validated client at a loopback server: only the dialer stands in the way. + c.baseURL = mustParseURL(t, srv.URL) + + _, err = c.ListPeers(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, safehttp.ErrBlockedAddress) + assert.Zero(t, hits.Load(), "no connection may reach the loopback server") +} + +// Uses a test-supplied CheckRedirect, so it does not prove production wiring; +// TestNewNetBirdClient_ProductionClientIsHardened covers the constructor. +func TestNetBirdClient_StatusErrorOnRedirectResponse(t *testing.T) { + var secondHits atomic.Int32 + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + secondHits.Add(1) + _, _ = w.Write([]byte("[]")) + })) + defer second.Close() + + for _, code := range []int{http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, second.URL, code) + })) + hc := first.Client() + hc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + c, err := newNetBirdClientWithHTTP(context.Background(), "tok", first.URL, hc) + require.NoError(t, err) + + _, err = c.ListPeers(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status") + first.Close() + } + assert.Zero(t, secondHits.Load()) +} + +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + require.NoError(t, err) + return u +} + +func TestNetBirdClient_WithHTTPRejectsUnparsableURL(t *testing.T) { + _, err := newNetBirdClientWithHTTP(context.Background(), "tok", "https://[::1", http.DefaultClient) + require.Error(t, err) + assert.True(t, strings.HasPrefix(err.Error(), "netbird: "), "unexpected error: %v", err) +} + +// The empty-URL default is applied before validation. Whether the default host +// then resolves depends on the machine, so only the "not rejected as empty" +// property is asserted. +func TestNewNetBirdClient_EmptyURLUsesDefault(t *testing.T) { + c, err := NewNetBirdClient(context.Background(), "tok", "") + if err != nil { + assert.NotContains(t, err.Error(), "url is required") + return + } + assert.Equal(t, defaultManagementURL, c.baseURL.String()) +} + +func TestNetBirdClient_ForceRefresh_RejectsUnusableBaseURL(t *testing.T) { + c := &NetBirdClient{baseURL: &url.URL{}, httpClient: http.DefaultClient} + _, err := c.ForceRefresh(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "build request url") +} diff --git a/backend/internal/hecate/providers/netbird/provider_test.go b/backend/internal/hecate/providers/netbird/provider_test.go index 22bc39756..3d670a491 100644 --- a/backend/internal/hecate/providers/netbird/provider_test.go +++ b/backend/internal/hecate/providers/netbird/provider_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net" "net/http" "net/http/httptest" "sync" @@ -25,7 +24,7 @@ func newTestNBClient(t *testing.T, handler http.HandlerFunc) (*NetBirdClient, *h t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - c, err := newNetBirdClientWithURL(context.Background(), "nb-test-token", srv.URL, true) + c, err := newNetBirdClientWithHTTP(context.Background(), "nb-test-token", srv.URL, srv.Client()) require.NoError(t, err) return c, srv } @@ -156,68 +155,6 @@ func TestForceRefresh_BypassesCache(t *testing.T) { assert.Equal(t, "refreshed", r[0].ID) } -// --- SSRF Validation Tests --- - -func TestNewNetBirdClient_DefaultURL(t *testing.T) { - // Empty management URL defaults to api.netbird.io — SSRF runs but it's a real host. - // We skip SSRF to avoid DNS calls in CI; just verify the base URL is set correctly. - c, err := newNetBirdClientWithURL(context.Background(), "tok", "", true) - require.NoError(t, err) - assert.Equal(t, defaultManagementURL, c.baseURL) -} - -func TestNewNetBirdClient_InvalidScheme(t *testing.T) { - _, err := NewNetBirdClient(context.Background(), "tok", "http://api.netbird.io") - require.Error(t, err) - assert.Contains(t, err.Error(), "https scheme") -} - -func TestNewNetBirdClient_LoopbackRejected(t *testing.T) { - _, err := newNetBirdClientWithURL(context.Background(), "tok", "https://127.0.0.1", false) - require.Error(t, err) -} - -func TestNewNetBirdClient_PrivateRangeRejected(t *testing.T) { - _, err := newNetBirdClientWithURL(context.Background(), "tok", "https://192.168.1.1", false) - require.Error(t, err) -} - -func TestNewNetBirdClient_LinkLocalRejected(t *testing.T) { - _, err := NewNetBirdClient(context.Background(), "tok", "https://169.254.169.254") - require.Error(t, err) -} - -func TestNewNetBirdClient_SkipSSRFAllowsLoopback(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "[]") //nolint:errcheck - })) - defer srv.Close() - - c, err := newNetBirdClientWithURL(context.Background(), "tok", srv.URL, true) - require.NoError(t, err) - assert.NotNil(t, c) -} - -func TestIsPrivateIP_Ranges(t *testing.T) { - tests := []struct { - addr string - expected bool - }{ - {"127.0.0.1", true}, - {"10.0.0.1", true}, - {"172.16.0.1", true}, - {"192.168.1.1", true}, - {"169.254.1.1", true}, - {"8.8.8.8", false}, - {"1.1.1.1", false}, - } - for _, tt := range tests { - ip := net.ParseIP(tt.addr) - require.NotNil(t, ip, "failed to parse IP %s", tt.addr) - assert.Equal(t, tt.expected, isPrivateIP(ip), "IP %s", tt.addr) - } -} - // --- Provider Tests --- func TestFactory_ValidCredentials(t *testing.T) { @@ -253,7 +190,7 @@ func TestNetBirdProvider_Start_Success(t *testing.T) { require.NoError(t, err) p.newClientFn = func(ctx context.Context, token, url string) (*NetBirdClient, error) { - return newNetBirdClientWithURL(ctx, token, srv.URL, true) + return newNetBirdClientWithHTTP(ctx, token, srv.URL, srv.Client()) } require.NoError(t, p.Start(context.Background())) @@ -272,7 +209,7 @@ func TestNetBirdProvider_Start_InvalidToken(t *testing.T) { require.NoError(t, err) p.newClientFn = func(ctx context.Context, token, url string) (*NetBirdClient, error) { - return newNetBirdClientWithURL(ctx, "bad-token", srv.URL, true) + return newNetBirdClientWithHTTP(ctx, "bad-token", srv.URL, srv.Client()) } err = p.Start(context.Background()) @@ -291,7 +228,7 @@ func TestNetBirdProvider_Start_EmptyPeerList(t *testing.T) { require.NoError(t, err) p.newClientFn = func(ctx context.Context, token, url string) (*NetBirdClient, error) { - return newNetBirdClientWithURL(ctx, token, srv.URL, true) + return newNetBirdClientWithHTTP(ctx, token, srv.URL, srv.Client()) } require.NoError(t, p.Start(context.Background())) @@ -353,7 +290,7 @@ func TestNetBirdProvider_GetClient(t *testing.T) { assert.Nil(t, p.GetClient()) p.newClientFn = func(ctx context.Context, token, url string) (*NetBirdClient, error) { - return newNetBirdClientWithURL(ctx, token, srv.URL, true) + return newNetBirdClientWithHTTP(ctx, token, srv.URL, srv.Client()) } require.NoError(t, p.Start(context.Background())) diff --git a/backend/internal/hecate/providers/zerotier/api_client.go b/backend/internal/hecate/providers/zerotier/api_client.go index 5bb85b6d6..8052aa09f 100644 --- a/backend/internal/hecate/providers/zerotier/api_client.go +++ b/backend/internal/hecate/providers/zerotier/api_client.go @@ -3,37 +3,27 @@ package zerotier import ( "context" "encoding/json" + "errors" "fmt" - "net" "net/http" "net/url" + "regexp" "time" + + "github.com/Wikid82/charon/backend/pkg/safehttp" ) -const defaultControllerURL = "https://api.zerotier.com" - -// privateRanges defines IP ranges that must not be contacted to prevent SSRF. -var privateRanges []*net.IPNet - -func init() { - cidrs := []string{ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "169.254.0.0/16", - "::1/128", - "fe80::/10", - "fc00::/7", - } - for _, cidr := range cidrs { - _, network, err := net.ParseCIDR(cidr) - if err != nil { - panic(fmt.Sprintf("zerotier: invalid private CIDR %q: %v", cidr, err)) - } - privateRanges = append(privateRanges, network) - } -} +const ( + defaultControllerURL = "https://api.zerotier.com" + requestTimeout = 15 * time.Second +) + +// ErrInvalidNetworkID is returned when a ZeroTier network ID is not exactly 16 +// lowercase hexadecimal characters. +var ErrInvalidNetworkID = errors.New("zerotier: invalid network id") + +// networkIDPattern matches a ZeroTier network ID. +var networkIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`) // ZeroTierNetwork represents a ZeroTier network entry. type ZeroTierNetwork struct { @@ -54,68 +44,49 @@ type ZeroTierMember struct { // ZeroTierClient is an authenticated HTTP client for the ZeroTier Central API. type ZeroTierClient struct { apiToken string - controllerURL string + controllerURL *url.URL httpClient *http.Client } -// NewZeroTierClient creates a ZeroTierClient with SSRF validation on the controller URL. -// Returns an error if controllerURL is not a valid, reachable HTTPS address or if it -// resolves to a loopback, link-local, or RFC-1918 address. +// NewZeroTierClient creates a ZeroTierClient for the given controller URL. +// The URL must be https and must not point at a loopback, link-local, private, +// carrier-grade NAT or otherwise restricted address. The URL is checked up front +// for an early, readable error; the HTTP client then re-validates the destination +// on every connection and never follows redirects. func NewZeroTierClient(ctx context.Context, apiToken, controllerURL string) (*ZeroTierClient, error) { - return newZeroTierClientWithURL(ctx, apiToken, controllerURL, false) + if controllerURL == "" { + controllerURL = defaultControllerURL + } + if _, err := safehttp.ValidateURL(controllerURL, safehttp.PublicHTTPSOnly()); err != nil { + return nil, fmt.Errorf("zerotier: invalid controller_url: %w", err) + } + return newZeroTierClientWithHTTP(ctx, apiToken, controllerURL, safehttp.NewClient(safehttp.PublicHTTPSOnly(), requestTimeout)) } -// newZeroTierClientWithURL is the internal constructor. When skipSSRF is true the -// DNS resolution check is skipped; use this only in tests that use httptest.Server. -func newZeroTierClientWithURL(ctx context.Context, apiToken, controllerURL string, skipSSRF bool) (*ZeroTierClient, error) { +// newZeroTierClientWithHTTP builds a client around an already-constructed +// http.Client without validating the URL against the address policy. Production +// code reaches it only through NewZeroTierClient; tests use it with an httptest +// server's client. +func newZeroTierClientWithHTTP(_ context.Context, apiToken, controllerURL string, hc *http.Client) (*ZeroTierClient, error) { if controllerURL == "" { controllerURL = defaultControllerURL } - parsed, err := url.Parse(controllerURL) if err != nil { return nil, fmt.Errorf("zerotier: invalid controller_url: %w", err) } - if !skipSSRF { - if parsed.Scheme != "https" { - return nil, fmt.Errorf("zerotier: controller_url must use https scheme, got %q", parsed.Scheme) - } - host := parsed.Hostname() - addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return nil, fmt.Errorf("zerotier: resolve controller host %q: %w", host, err) - } - for _, addr := range addrs { - if isPrivateIP(addr.IP) { - return nil, fmt.Errorf("zerotier: controller_url resolves to a private/loopback address — SSRF protection") - } - } - } - return &ZeroTierClient{ apiToken: apiToken, - controllerURL: controllerURL, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + controllerURL: parsed, + httpClient: hc, }, nil } -// isPrivateIP returns true if ip falls within any of the restricted private ranges. -func isPrivateIP(ip net.IP) bool { - for _, network := range privateRanges { - if network.Contains(ip) { - return true - } - } - return false -} - // ListNetworks returns all ZeroTier networks accessible via the configured API token. func (c *ZeroTierClient) ListNetworks(ctx context.Context) ([]ZeroTierNetwork, error) { var networks []ZeroTierNetwork - if err := c.get(ctx, "/api/v1/network", &networks); err != nil { + if err := c.get(ctx, &networks, "api", "v1", "network"); err != nil { return nil, err } return networks, nil @@ -123,16 +94,22 @@ func (c *ZeroTierClient) ListNetworks(ctx context.Context) ([]ZeroTierNetwork, e // ListMembers returns all members of the given ZeroTier network. func (c *ZeroTierClient) ListMembers(ctx context.Context, networkID string) ([]ZeroTierMember, error) { + if !networkIDPattern.MatchString(networkID) { + return nil, ErrInvalidNetworkID + } var members []ZeroTierMember - path := fmt.Sprintf("/api/v1/network/%s/member", networkID) - if err := c.get(ctx, path, &members); err != nil { + if err := c.get(ctx, &members, "api", "v1", "network", networkID, "member"); err != nil { return nil, err } return members, nil } -func (c *ZeroTierClient) get(ctx context.Context, path string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.controllerURL+path, http.NoBody) +func (c *ZeroTierClient) get(ctx context.Context, out any, segments ...string) error { + target, err := safehttp.JoinPath(c.controllerURL, segments...) + if err != nil { + return fmt.Errorf("zerotier: build request url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody) if err != nil { return fmt.Errorf("zerotier: build request: %w", err) } diff --git a/backend/internal/hecate/providers/zerotier/api_client_policy_test.go b/backend/internal/hecate/providers/zerotier/api_client_policy_test.go new file mode 100644 index 000000000..e3d923927 --- /dev/null +++ b/backend/internal/hecate/providers/zerotier/api_client_policy_test.go @@ -0,0 +1,169 @@ +package zerotier + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/Wikid82/charon/backend/pkg/safehttp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + require.NoError(t, err) + return u +} + +func TestNewZeroTierClient_DefaultURL(t *testing.T) { + c, err := newZeroTierClientWithHTTP(context.Background(), "tok", "", http.DefaultClient) + require.NoError(t, err) + assert.Equal(t, defaultControllerURL, c.controllerURL.String()) +} + +func TestNewZeroTierClient_RejectsDisallowedURLs(t *testing.T) { + blocked := []string{ + "https://127.0.0.1", "https://127.0.0.2:8443", "https://[::1]", "https://[::ffff:127.0.0.1]", + "https://10.0.0.5", "https://192.168.1.1", "https://172.16.0.1", + "https://169.254.169.254", "https://[fd00::1]", "https://[fe80::1]", + "https://100.64.0.1", "https://100.127.255.254", + "https://[64:ff9b::1]", "https://[2002::1]", "https://192.0.0.1", "https://198.18.0.1", + "https://0.0.0.0", "https://localhost", "https://localhost.", + } + for _, raw := range blocked { + _, err := NewZeroTierClient(context.Background(), "tok", raw) + require.Error(t, err, raw) + assert.True(t, errors.Is(err, safehttp.ErrBlockedAddress), "%s: expected blocked-address error, got %v", raw, err) + assert.True(t, strings.HasPrefix(err.Error(), "zerotier: "), "%s: missing prefix: %v", raw, err) + } + + other := []string{ + "http://api.zerotier.com", "https://user:pw@api.zerotier.com", "https://good.com@169.254.169.254", + "ftp://api.zerotier.com", "https://api.zerotier.com/?x=1", "https://api.zerotier.com/#frag", "https://[::1", + } + for _, raw := range other { + _, err := NewZeroTierClient(context.Background(), "tok", raw) + require.Error(t, err, raw) + assert.True(t, strings.HasPrefix(err.Error(), "zerotier: "), "%s: missing prefix: %v", raw, err) + } +} + +func TestNewZeroTierClient_ProductionClientIsHardened(t *testing.T) { + c, err := NewZeroTierClient(context.Background(), "tok", "https://8.8.8.8/ztc/") + require.NoError(t, err) + + tr, ok := c.httpClient.Transport.(*http.Transport) + require.True(t, ok) + assert.Nil(t, tr.Proxy, "proxy environment must be ignored") + assert.ErrorIs(t, c.httpClient.CheckRedirect(&http.Request{}, nil), http.ErrUseLastResponse) + assert.Equal(t, "8.8.8.8", c.controllerURL.Hostname()) +} + +func TestZeroTierClient_ProductionClientBlocksLoopbackAtDial(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("[]")) + })) + defer srv.Close() + + c, err := NewZeroTierClient(context.Background(), "tok", "https://8.8.8.8") + require.NoError(t, err) + // Re-point the validated client at a loopback server: only the dialer stands in the way. + c.controllerURL = mustParseURL(t, srv.URL) + + _, err = c.ListNetworks(context.Background()) + require.ErrorIs(t, err, safehttp.ErrBlockedAddress) + _, err = c.ListMembers(context.Background(), "a1b2c3d4e5f60718") + require.ErrorIs(t, err, safehttp.ErrBlockedAddress) + assert.Zero(t, hits.Load(), "no connection may reach the loopback server") +} + +// Uses a test-supplied CheckRedirect, so it does not prove production wiring; +// TestNewZeroTierClient_ProductionClientIsHardened covers the constructor. +func TestZeroTierClient_StatusErrorOnRedirectResponse(t *testing.T) { + var secondHits atomic.Int32 + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + secondHits.Add(1) + _, _ = w.Write([]byte("[]")) + })) + defer second.Close() + + for _, code := range []int{http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, second.URL, code) + })) + hc := first.Client() + hc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + c, err := newZeroTierClientWithHTTP(context.Background(), "tok", first.URL, hc) + require.NoError(t, err) + + _, err = c.ListNetworks(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status") + _, err = c.ListMembers(context.Background(), "a1b2c3d4e5f60718") + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status") + first.Close() + } + assert.Zero(t, secondHits.Load()) +} + +func TestListMembers_NetworkIDValidation(t *testing.T) { + var hits atomic.Int32 + var gotPath atomic.Value + c, _ := newTestZTClient(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + gotPath.Store(r.URL.EscapedPath()) + _, _ = w.Write([]byte("[]")) + }) + + invalid := []string{ + "net1", "../x", "a1b2c3d4e5f6071", "a1b2c3d4e5f607189", "A1B2C3D4E5F60718", + "%2e%2e", "a1b2c3d4e5f6071/", "", "a1b2c3d4e5f6071g", "a1b2c3d4e5f60718\n", "a1b2c3d4/../ab", + } + for _, id := range invalid { + _, err := c.ListMembers(context.Background(), id) + require.Error(t, err, "%q", id) + assert.ErrorIs(t, err, ErrInvalidNetworkID, "%q", id) + assert.Zero(t, hits.Load(), "invalid id %q must not produce a request", id) + } + + members, err := c.ListMembers(context.Background(), "a1b2c3d4e5f60718") + require.NoError(t, err) + assert.Empty(t, members) + assert.Equal(t, int32(1), hits.Load()) + assert.Equal(t, "/api/v1/network/a1b2c3d4e5f60718/member", gotPath.Load()) +} + +func TestZeroTierClient_WithHTTPRejectsUnparsableURL(t *testing.T) { + _, err := newZeroTierClientWithHTTP(context.Background(), "tok", "https://[::1", http.DefaultClient) + require.Error(t, err) + assert.True(t, strings.HasPrefix(err.Error(), "zerotier: "), "unexpected error: %v", err) +} + +// The empty-URL default is applied before validation. Whether the default host +// then resolves depends on the machine, so only the "not rejected as empty" +// property is asserted. +func TestNewZeroTierClient_EmptyURLUsesDefault(t *testing.T) { + c, err := NewZeroTierClient(context.Background(), "tok", "") + if err != nil { + assert.NotContains(t, err.Error(), "url is required") + return + } + assert.Equal(t, defaultControllerURL, c.controllerURL.String()) +} + +func TestZeroTierClient_ListNetworks_RejectsUnusableBaseURL(t *testing.T) { + c := &ZeroTierClient{controllerURL: &url.URL{}, httpClient: http.DefaultClient} + _, err := c.ListNetworks(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "build request url") +} diff --git a/backend/internal/hecate/providers/zerotier/provider_test.go b/backend/internal/hecate/providers/zerotier/provider_test.go index 99b9c7b52..1962ddd37 100644 --- a/backend/internal/hecate/providers/zerotier/provider_test.go +++ b/backend/internal/hecate/providers/zerotier/provider_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net" "net/http" "net/http/httptest" "testing" @@ -18,15 +17,15 @@ import ( // --- Helpers --- // newTestZTClient creates a ZeroTierClient pointing to a test HTTP server, -// bypassing SSRF validation (the server uses a loopback address). +// bypassing address validation (the server uses a loopback address). // //nolint:unparam // *httptest.Server returned for future test variants func newTestZTClient(t *testing.T, handler http.HandlerFunc) (*ZeroTierClient, *httptest.Server) { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - // Use http:// with skipSSRF=true because httptest uses loopback. - c, err := newZeroTierClientWithURL(context.Background(), "test-token", srv.URL, true) + // httptest listens on loopback, so the production safe client is bypassed. + c, err := newZeroTierClientWithHTTP(context.Background(), "test-token", srv.URL, srv.Client()) require.NoError(t, err) return c, srv } @@ -35,7 +34,7 @@ func newTestZTClient(t *testing.T, handler http.HandlerFunc) (*ZeroTierClient, * func TestListNetworks_Success(t *testing.T) { networks := []ZeroTierNetwork{ - {ID: "net1", Name: "my-network", Private: true}, + {ID: "a1b2c3d4e5f60718", Name: "my-network", Private: true}, } c, _ := newTestZTClient(t, func(w http.ResponseWriter, r *http.Request) { // Verify Authorization header. @@ -47,7 +46,7 @@ func TestListNetworks_Success(t *testing.T) { result, err := c.ListNetworks(context.Background()) require.NoError(t, err) require.Len(t, result, 1) - assert.Equal(t, "net1", result[0].ID) + assert.Equal(t, "a1b2c3d4e5f60718", result[0].ID) assert.True(t, result[0].Private) } @@ -67,11 +66,11 @@ func TestListMembers_Success(t *testing.T) { {ID: "m1", Name: "node-1", IPAssignments: []string{"10.147.17.1"}, Online: true}, } c, _ := newTestZTClient(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/network/net1/member", r.URL.Path) + assert.Equal(t, "/api/v1/network/a1b2c3d4e5f60718/member", r.URL.Path) json.NewEncoder(w).Encode(members) //nolint:errcheck,gosec,gosec }) - result, err := c.ListMembers(context.Background(), "net1") + result, err := c.ListMembers(context.Background(), "a1b2c3d4e5f60718") require.NoError(t, err) require.Len(t, result, 1) assert.Equal(t, "m1", result[0].ID) @@ -83,73 +82,10 @@ func TestListMembers_NetworkNotFound(t *testing.T) { w.WriteHeader(http.StatusNotFound) }) - _, err := c.ListMembers(context.Background(), "missing-net") + _, err := c.ListMembers(context.Background(), "0123456789abcdef") require.Error(t, err) } -// --- SSRF Validation Tests --- - -func TestSSRF_RejectsHTTPScheme(t *testing.T) { - _, err := NewZeroTierClient(context.Background(), "tok", "http://api.zerotier.com") - require.Error(t, err) - assert.Contains(t, err.Error(), "https scheme") -} - -func TestSSRF_RejectsLoopbackViaSkipFalse(t *testing.T) { - // 127.0.0.1 is always loopback; skip SSRF=false will try DNS and then reject. - // We use the internal constructor with skip=false but a literal loopback IP. - _, err := newZeroTierClientWithURL(context.Background(), "tok", "https://127.0.0.1", false) - require.Error(t, err) - // The error is either "SSRF protection" or a DNS resolution error. - assert.NotNil(t, err) -} - -func TestSSRF_RejectsLinkLocal(t *testing.T) { - // 169.254.169.254 is the AWS metadata endpoint — a classic SSRF target. - // We call the public constructor to trigger full validation. - _, err := NewZeroTierClient(context.Background(), "tok", "https://169.254.169.254") - require.Error(t, err) -} - -func TestSSRF_SkipSSRFAllowsLoopback(t *testing.T) { - // Internal constructor with skipSSRF=true should allow loopback (for tests). - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "[]") //nolint:errcheck - })) - defer srv.Close() - - c, err := newZeroTierClientWithURL(context.Background(), "tok", srv.URL, true) - require.NoError(t, err) - assert.NotNil(t, c) -} - -func TestSSRF_RejectsPrivateRange(t *testing.T) { - // Use a private RFC-1918 address directly — isPrivateIP catches it. - // We use skipSSRF=false and a literal private IP so no DNS is needed. - _, err := newZeroTierClientWithURL(context.Background(), "tok", "https://192.168.1.1", false) - require.Error(t, err) -} - -func TestIsPrivateIP_Ranges(t *testing.T) { - tests := []struct { - addr string - expected bool - }{ - {"127.0.0.1", true}, - {"10.0.0.1", true}, - {"172.16.0.1", true}, - {"192.168.1.1", true}, - {"169.254.1.1", true}, - {"8.8.8.8", false}, - {"1.1.1.1", false}, - } - for _, tt := range tests { - ip := net.ParseIP(tt.addr) - require.NotNil(t, ip, "failed to parse IP %s", tt.addr) - assert.Equal(t, tt.expected, isPrivateIP(ip), "IP %s", tt.addr) - } -} - // --- Provider Tests --- //nolint:unparam // controllerURL kept for future test variants @@ -200,7 +136,7 @@ func TestStart_ValidatesToken(t *testing.T) { // Inject factory that produces a test client bypassing SSRF. p.newClientFn = func(ctx context.Context, apiToken, controllerURL string) (*ZeroTierClient, error) { - return newZeroTierClientWithURL(context.Background(), apiToken, srv.URL, true) + return newZeroTierClientWithHTTP(context.Background(), apiToken, srv.URL, srv.Client()) } require.NoError(t, p.Start(context.Background())) @@ -234,7 +170,7 @@ func TestStart_ErrorOnInvalidToken(t *testing.T) { require.NoError(t, err) p.newClientFn = func(ctx context.Context, apiToken, controllerURL string) (*ZeroTierClient, error) { - return newZeroTierClientWithURL(context.Background(), "bad-token", srv.URL, true) + return newZeroTierClientWithHTTP(context.Background(), "bad-token", srv.URL, srv.Client()) } err = p.Start(context.Background()) diff --git a/backend/internal/network/safeclient.go b/backend/internal/network/safeclient.go index c38ff5ade..2b5e2b250 100644 --- a/backend/internal/network/safeclient.go +++ b/backend/internal/network/safeclient.go @@ -21,6 +21,7 @@ var ErrBlockedAddress = errors.New("destination address is not allowed") // lookupIPAddr resolves a host to IP addresses. It is a package-level seam so // tests can supply a controlled resolver; production always uses the default. +// Tests that replace it must not call t.Parallel(): the swap is process-wide. var lookupIPAddr = net.DefaultResolver.LookupIPAddr // privateBlocks holds pre-parsed CIDR blocks for private/reserved IP ranges. @@ -53,6 +54,7 @@ var rfc1918CIDRs = []string{ // - Link-local addresses (169.254.x.x, fe80::) including cloud metadata endpoints // - Reserved ranges (0.x.x.x, 240.x.x.x, 255.255.255.255) // - IPv6 unique local addresses (fc00::) +// - A cloud instance metadata alias inside the CGNAT range (100.100.100.200) var privateCIDRs = []string{ // IPv4 Private Networks (RFC 1918) "10.0.0.0/8", @@ -78,6 +80,10 @@ var privateCIDRs = []string{ // IPv6 Link-Local "fe80::/10", + + // Cloud instance metadata alias, inside the CGNAT range (100.64.0.0/10). + // Blocked globally; the rest of the CGNAT range is only blocked by WithBlockCGNAT. + "100.100.100.200/32", } // cgnatCIDRs is the shared-address space used by carrier-grade NAT and many diff --git a/backend/internal/network/safeclient_policy_test.go b/backend/internal/network/safeclient_policy_test.go index 9e699668b..2273d70c7 100644 --- a/backend/internal/network/safeclient_policy_test.go +++ b/backend/internal/network/safeclient_policy_test.go @@ -328,3 +328,67 @@ func TestNewSafeHTTPClient_BlockOptionsWired(t *testing.T) { t.Errorf("server hit %d times, want 0", hits.Load()) } } + +func TestIsPrivateIP_CGNATMetadataAliasAddress(t *testing.T) { + t.Parallel() + tests := []struct { + ip string + want bool + }{ + {"100.100.100.200", true}, + {"::ffff:100.100.100.200", true}, + {"100.100.100.199", false}, + {"100.100.100.201", false}, + {"100.64.0.1", false}, + {"100.127.255.254", false}, + } + for _, tt := range tests { + if got := IsPrivateIP(net.ParseIP(tt.ip)); got != tt.want { + t.Errorf("IsPrivateIP(%s) = %v, want %v", tt.ip, got, tt.want) + } + } + if IsRFC1918(net.ParseIP("100.100.100.200")) { + t.Error("100.100.100.200 must not be classified as RFC 1918") + } +} + +func TestBlockedByPolicy_CGNATMetadataAliasNeverReachableThroughAllowBranches(t *testing.T) { + t.Parallel() + combos := []ClientOptions{ + {}, + {AllowLocalhost: true}, + {AllowRFC1918: true}, + {AllowLocalhost: true, AllowRFC1918: true}, + // CGNAT-allowed policy: the range is not blocked by option. + {AllowRFC1918: true, BlockTransitionRanges: true}, + {AllowLocalhost: true, AllowRFC1918: true, BlockTransitionRanges: true, BlockCGNAT: true}, + } + for _, ipStr := range []string{"100.100.100.200", "::ffff:100.100.100.200"} { + for i, opts := range combos { + o := opts + if !blockedByPolicy(net.ParseIP(ipStr), &o) { + t.Errorf("combo %d: %s must be blocked", i, ipStr) + } + } + } + // Neighbouring CGNAT addresses stay reachable when CGNAT is not blocked. + o := ClientOptions{AllowRFC1918: true} + if blockedByPolicy(net.ParseIP("100.100.100.199"), &o) { + t.Error("100.100.100.199 must not be blocked when CGNAT blocking is off") + } +} + +func TestSafeDialer_CGNATMetadataAliasBlockedInBothLoops(t *testing.T) { + withResolver(t, map[string][]string{ + "first.example": {"100.100.100.200", "8.8.8.8"}, + "last.example": {"8.8.8.8", "100.100.100.200"}, + "mixed.example": {"::ffff:100.100.100.200"}, + }) + opts := ClientOptions{AllowLocalhost: true, AllowRFC1918: true, DialTimeout: time.Second} + for _, host := range []string{"first.example", "last.example", "mixed.example"} { + _, err := safeDialer(&opts)(context.Background(), "tcp", net.JoinHostPort(host, "80")) + if !errors.Is(err, ErrBlockedAddress) { + t.Errorf("%s: expected ErrBlockedAddress, got %v", host, err) + } + } +} diff --git a/backend/internal/security/url_validator_sentinel_test.go b/backend/internal/security/url_validator_sentinel_test.go index 9852e7835..c39a9fc91 100644 --- a/backend/internal/security/url_validator_sentinel_test.go +++ b/backend/internal/security/url_validator_sentinel_test.go @@ -58,3 +58,16 @@ func TestValidateExternalURL_NonAddressFailuresDoNotWrapSentinel(t *testing.T) { }) } } + +func TestValidateExternalURL_RejectsCGNATMetadataAlias(t *testing.T) { + t.Parallel() + for _, raw := range []string{"http://100.100.100.200", "http://100.100.100.200/latest/meta-data", "http://[::ffff:100.100.100.200]"} { + _, err := ValidateExternalURL(raw, WithAllowHTTP(), WithAllowRFC1918()) + if err == nil { + t.Fatalf("%s accepted", raw) + } + if !errors.Is(err, network.ErrBlockedAddress) { + t.Errorf("%s: error does not wrap ErrBlockedAddress: %v", raw, err) + } + } +} diff --git a/backend/internal/services/enhanced_security_notification_service.go b/backend/internal/services/enhanced_security_notification_service.go index 05b0a360d..9f47014c4 100644 --- a/backend/internal/services/enhanced_security_notification_service.go +++ b/backend/internal/services/enhanced_security_notification_service.go @@ -15,6 +15,7 @@ import ( "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/network" "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/util" "gorm.io/gorm" @@ -595,7 +596,11 @@ func (s *EnhancedSecurityNotificationService) sendWebhook(ctx context.Context, w req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Charon-Cerberus/1.0") - client := &http.Client{Timeout: 10 * time.Second} + // Validated outbound client: dial-time address validation, no redirects, no proxy. + client := network.NewSafeHTTPClient( + network.WithTimeout(10*time.Second), + network.WithAllowLocalhost(), // Allow localhost for testing + ) resp, err := client.Do(req) if err != nil { return fmt.Errorf("execute request: %w", err) diff --git a/backend/internal/services/enhanced_security_notification_service_test.go b/backend/internal/services/enhanced_security_notification_service_test.go index 18f0e975b..1f22a26a4 100644 --- a/backend/internal/services/enhanced_security_notification_service_test.go +++ b/backend/internal/services/enhanced_security_notification_service_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "sync/atomic" "testing" "time" @@ -971,3 +972,46 @@ func TestGetDefaultFeatureFlagValue_TestMode(t *testing.T) { result := service.getDefaultFeatureFlagValue() assert.Equal(t, "true", result, "Test mode should return true") } + +func TestSendWebhook_DoesNotFollowRedirects(t *testing.T) { + db := setupEnhancedServiceDB(t) + service := NewEnhancedSecurityNotificationService(db) + + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetHits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + event := models.SecurityEvent{EventType: "waf_block", Severity: "high", Message: "Test event"} + for _, code := range []int{http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, code) + })) + + err := service.sendWebhook(context.Background(), redirector.URL, event) + redirector.Close() + + require.Error(t, err, "redirect %d must surface as a non-2xx status", code) + assert.Contains(t, err.Error(), "webhook returned status") + } + assert.Zero(t, targetHits.Load(), "redirect target must never be contacted") +} + +func TestSendWebhook_RejectsRestrictedLiteralAddresses(t *testing.T) { + db := setupEnhancedServiceDB(t) + service := NewEnhancedSecurityNotificationService(db) + event := models.SecurityEvent{EventType: "waf_block", Severity: "high", Message: "Test event"} + + for _, raw := range []string{ + "http://169.254.169.254/latest/meta-data", + "http://100.100.100.200/latest/meta-data", + "https://10.0.0.5/hook", + "https://user:pw@example.com/hook", + } { + err := service.sendWebhook(context.Background(), raw, event) + require.Error(t, err, raw) + assert.Contains(t, err.Error(), "ssrf validation failed", raw) + } +} diff --git a/backend/pkg/safehttp/safehttp_test.go b/backend/pkg/safehttp/safehttp_test.go index 3c80aa2fe..e9efa1c8c 100644 --- a/backend/pkg/safehttp/safehttp_test.go +++ b/backend/pkg/safehttp/safehttp_test.go @@ -39,6 +39,7 @@ func TestValidateURLSyntax_AlwaysBlockedAddresses(t *testing.T) { t.Parallel() hosts := []string{ "169.254.169.254", "[::ffff:169.254.169.254]", "169.254.169.254.", "[fd00:ec2::254]", + "100.100.100.200", "[::ffff:100.100.100.200]", "100.100.100.200.", "127.0.0.1", "127.0.0.2", "[::1]", "[::ffff:127.0.0.1]", "localhost", "localhost.", "LOCALHOST", "foo.localhost", "[fd00::1]", "[fe80::1]", "[fe80::1%25eth0]", "0.0.0.0", "240.0.0.1", "255.255.255.255", From 4cfb0820362b867c52bdb6e71ececc8486873ad4 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 20:45:28 +0000 Subject: [PATCH 26/57] fix: validate DNS provider endpoint addresses Check administrator-supplied DNS provider endpoints against the private-network policy when credentials are saved and tested, and send the PowerDNS and connectivity checks through the shared request helper. Server identifiers are restricted to a safe character set, and the webhook provider's save-time validation is tightened and its comments corrected. Endpoints on loopback addresses are no longer accepted for the PowerDNS provider; use a LAN or Docker network address instead. Existing stored credentials are not re-validated, so certificate issuance is unaffected. The bundled PowerDNS plugin must be rebuilt, and installations that pin plugin signatures need to update the allowlist with the new checksum. --- .../api/handlers/dns_provider_handler_test.go | 110 +++++++++ .../services/dns_provider_service_test.go | 149 +++++++++++++ .../dnsprovider/custom/webhook_provider.go | 44 +++- .../custom/webhook_provider_test.go | 86 ++++++++ plugins/powerdns/README.md | 6 + plugins/powerdns/main.go | 96 ++++++-- plugins/powerdns/main_test.go | 208 ++++++++++++++++++ 7 files changed, 675 insertions(+), 24 deletions(-) diff --git a/backend/internal/api/handlers/dns_provider_handler_test.go b/backend/internal/api/handlers/dns_provider_handler_test.go index fc5a1c0e6..e300021d2 100644 --- a/backend/internal/api/handlers/dns_provider_handler_test.go +++ b/backend/internal/api/handlers/dns_provider_handler_test.go @@ -5,20 +5,28 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "sort" + "sync/atomic" "testing" + "time" + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/pkg/dnsprovider" _ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin" // Auto-register DNS providers _ "github.com/Wikid82/charon/backend/pkg/dnsprovider/custom" // Auto-register custom providers (manual) + "github.com/Wikid82/charon/backend/pkg/safehttp" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) // MockDNSProviderService is a mock implementation of DNSProviderService for testing. @@ -1135,3 +1143,105 @@ func TestDNSProviderHandler_CreateGenericError(t *testing.T) { assert.Contains(t, w.Body.String(), "unknown database error") mockService.AssertExpectations(t) } + +// endpointPolicyFakeProvider stands in for a plugin that sends its connectivity +// check through the shared request helper. +type endpointPolicyFakeProvider struct { + dnsprovider.ProviderPlugin +} + +func (endpointPolicyFakeProvider) Type() string { return "endpoint_policy_handler_fake" } + +func (endpointPolicyFakeProvider) Metadata() dnsprovider.ProviderMetadata { + return dnsprovider.ProviderMetadata{Type: "endpoint_policy_handler_fake", Name: "Fake", InterfaceVersion: dnsprovider.InterfaceVersion} +} + +func (endpointPolicyFakeProvider) ValidateCredentials(creds map[string]string) error { + if creds["api_url"] == "" { + return errors.New("api_url is required") + } + return nil +} + +func (endpointPolicyFakeProvider) TestCredentials(creds map[string]string) error { + req, err := http.NewRequest(http.MethodGet, creds["api_url"], http.NoBody) + if err != nil { + return err + } + resp, err := safehttp.NewClient(safehttp.PrivateNetworkOK(), 2*time.Second).Do(req) + if err != nil { + return fmt.Errorf("API connection failed: %w", err) + } + _ = resp.Body.Close() + return nil +} + +func TestDNSProviderHandler_TestCredentials_EndpointPolicyAndRoleGate(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + require.NoError(t, db.AutoMigrate(&models.DNSProvider{}, &models.DNSProviderCredential{}, &models.SecurityAudit{})) + encryptor, err := crypto.NewEncryptionService("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") + require.NoError(t, err) + + base, ok := dnsprovider.Global().Get("cloudflare") + require.True(t, ok) + require.NoError(t, dnsprovider.Global().Register(endpointPolicyFakeProvider{ProviderPlugin: base})) + t.Cleanup(func() { dnsprovider.Global().Unregister("endpoint_policy_handler_fake") }) + + handler := NewDNSProviderHandler(services.NewDNSProviderService(db, encryptor)) + + var role atomic.Value + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(middleware.RoleKey, role.Load()) + c.Next() + }) + router.POST("/dns-providers/test", middleware.RequireRole(models.RoleAdmin), handler.TestCredentials) + + const marker = "RESPONSE-BODY-MARKER" + var hits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(marker)) + })) + defer target.Close() + + body, err := json.Marshal(map[string]any{ + "name": "probe", + "provider_type": "endpoint_policy_handler_fake", + "credentials": map[string]string{"api_url": target.URL, "api_key": "k"}, + }) + require.NoError(t, err) + post := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/dns-providers/test", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + return w + } + + // Non-admin callers are refused before any provider code runs. + role.Store(string(models.RoleUser)) + w := post() + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Zero(t, hits.Load()) + + // Admin: the request succeeds at the HTTP level but the blocked endpoint is + // reported as a failed test, and neither the target nor its body is reached. + role.Store(string(models.RoleAdmin)) + w = post() + require.Equal(t, http.StatusOK, w.Code) + var result services.TestResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.False(t, result.Success) + assert.Equal(t, "CREDENTIALS_TEST_FAILED", result.Code) + assert.NotEmpty(t, result.Error) + assert.NotContains(t, w.Body.String(), marker) + assert.Zero(t, hits.Load(), "the endpoint must not be contacted") +} diff --git a/backend/internal/services/dns_provider_service_test.go b/backend/internal/services/dns_provider_service_test.go index ad50aac1d..08b57693d 100644 --- a/backend/internal/services/dns_provider_service_test.go +++ b/backend/internal/services/dns_provider_service_test.go @@ -5,12 +5,18 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" "os" + "strings" + "sync/atomic" "testing" "time" "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/pkg/dnsprovider" + "github.com/Wikid82/charon/backend/pkg/safehttp" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1869,3 +1875,146 @@ func TestDNSProviderService_ResolveID(t *testing.T) { assert.False(t, errors.Is(err, ErrDNSProviderNotFound)) }) } + +// endpointPolicyProvider is a stand-in for a plugin that composes the shared +// request helper: ValidateCredentials applies the syntax policy and +// TestCredentials sends through safehttp.NewClient. +type endpointPolicyProvider struct { + dnsprovider.ProviderPlugin + typeName string + // strictValidate applies safehttp.ValidateURLSyntax in ValidateCredentials. + strictValidate bool + validateCalls atomic.Int32 +} + +func (p *endpointPolicyProvider) Type() string { return p.typeName } + +func (p *endpointPolicyProvider) Metadata() dnsprovider.ProviderMetadata { + return dnsprovider.ProviderMetadata{Type: p.typeName, Name: "Endpoint policy fake", InterfaceVersion: dnsprovider.InterfaceVersion} +} + +func (p *endpointPolicyProvider) ValidateCredentials(creds map[string]string) error { + p.validateCalls.Add(1) + if creds["api_url"] == "" || creds["api_key"] == "" { + return errors.New("api_url and api_key are required") + } + if p.strictValidate { + if _, err := safehttp.ValidateURLSyntax(creds["api_url"], safehttp.PrivateNetworkOK()); err != nil { + return fmt.Errorf("api_url is invalid: %w", err) + } + } + return nil +} + +func (p *endpointPolicyProvider) TestCredentials(creds map[string]string) error { + req, err := http.NewRequest(http.MethodGet, creds["api_url"], http.NoBody) + if err != nil { + return err + } + resp, err := safehttp.NewClient(safehttp.PrivateNetworkOK(), 2*time.Second).Do(req) + if err != nil { + if errors.Is(err, safehttp.ErrBlockedAddress) { + return errors.New("api_url points to an address that is not allowed") + } + return fmt.Errorf("API connection failed: %w", err) + } + _ = resp.Body.Close() + return nil +} + +func registerEndpointPolicyProvider(t *testing.T, name string, strict bool) *endpointPolicyProvider { + t.Helper() + base, ok := dnsprovider.Global().Get("cloudflare") + require.True(t, ok) + fake := &endpointPolicyProvider{ProviderPlugin: base, typeName: name, strictValidate: strict} + require.NoError(t, dnsprovider.Global().Register(fake)) + t.Cleanup(func() { dnsprovider.Global().Unregister(name) }) + return fake +} + +func TestDNSProviderService_TestCredentials_BlockedEndpointDoesNotConnectOrEcho(t *testing.T) { + db, encryptor := setupDNSProviderTestDB(t) + service := NewDNSProviderService(db, encryptor) + + const marker = "RESPONSE-BODY-MARKER" + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(marker)) + })) + defer srv.Close() + + // Lenient ValidateCredentials: the connection attempt itself must be refused. + registerEndpointPolicyProvider(t, "endpoint_policy_lenient", false) + result, err := service.TestCredentials(context.Background(), CreateDNSProviderRequest{ + Name: "Probe", + ProviderType: "endpoint_policy_lenient", + Credentials: map[string]string{"api_url": srv.URL, "api_key": "k"}, + }) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "CREDENTIALS_TEST_FAILED", result.Code) + assert.Contains(t, result.Error, "not allowed") + assert.NotContains(t, result.Error, marker) + assert.NotContains(t, result.Message, marker) + assert.Zero(t, hits.Load(), "the endpoint must not be contacted") +} + +func TestDNSProviderService_TestCredentials_StrictValidationRejectsBeforeAnyRequest(t *testing.T) { + db, encryptor := setupDNSProviderTestDB(t) + service := NewDNSProviderService(db, encryptor) + + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) })) + defer srv.Close() + + registerEndpointPolicyProvider(t, "endpoint_policy_strict", true) + for _, raw := range []string{srv.URL, "http://169.254.169.254/", "http://100.100.100.200/", "https://user:pw@example.com/"} { + result, err := service.TestCredentials(context.Background(), CreateDNSProviderRequest{ + Name: "Probe", + ProviderType: "endpoint_policy_strict", + Credentials: map[string]string{"api_url": raw, "api_key": "k"}, + }) + require.NoError(t, err) + assert.False(t, result.Success, raw) + assert.Equal(t, "INVALID_CREDENTIALS", result.Code, raw) + } + assert.Zero(t, hits.Load()) +} + +func TestDNSProviderService_StoredLoopbackEndpointIsNotRevalidatedOnRead(t *testing.T) { + db, encryptor := setupDNSProviderTestDB(t) + service := NewDNSProviderService(db, encryptor) + ctx := context.Background() + + fake := registerEndpointPolicyProvider(t, "endpoint_policy_stored", true) + + // A provider saved before the endpoint policy existed: loopback api_url. + credsJSON, err := json.Marshal(map[string]string{"api_url": "http://127.0.0.1:8081", "api_key": "k"}) + require.NoError(t, err) + encrypted, err := encryptor.Encrypt(credsJSON) + require.NoError(t, err) + row := &models.DNSProvider{ + UUID: uuid.New().String(), + Name: "Legacy loopback", + ProviderType: "endpoint_policy_stored", + Enabled: true, + CredentialsEncrypted: encrypted, + KeyVersion: 1, + } + require.NoError(t, db.Create(row).Error) + + list, err := service.List(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + + got, err := service.Get(ctx, row.ID) + require.NoError(t, err) + assert.Equal(t, "Legacy loopback", got.Name) + + creds, err := service.GetDecryptedCredentials(ctx, row.ID) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(creds["api_url"], "http://127.0.0.1")) + + assert.Zero(t, fake.validateCalls.Load(), "reading stored credentials must not run endpoint validation") +} diff --git a/backend/pkg/dnsprovider/custom/webhook_provider.go b/backend/pkg/dnsprovider/custom/webhook_provider.go index 05c614fa1..4a0cb08cf 100644 --- a/backend/pkg/dnsprovider/custom/webhook_provider.go +++ b/backend/pkg/dnsprovider/custom/webhook_provider.go @@ -3,12 +3,14 @@ package custom import ( "fmt" + "net" "net/url" "strconv" "strings" "time" "github.com/Wikid82/charon/backend/pkg/dnsprovider" + "github.com/Wikid82/charon/backend/pkg/safehttp" ) // Webhook provider constants. @@ -195,9 +197,12 @@ func (p *WebhookProvider) ValidateCredentials(creds map[string]string) error { return nil } -// validateWebhookURL validates a webhook URL for format and SSRF protection. -// Note: During validation, we only check format and basic security constraints. -// Full SSRF validation with DNS resolution happens at runtime when the webhook is called. +// validateWebhookURL validates a webhook URL at save time. +// +// Only format and literal-address checks happen here: Charon makes no request +// for webhook credentials, and a hostname cannot be judged without a request +// that may be unreachable from this environment. Any future consumer that sends +// a request to these URLs MUST do so through safehttp.NewClient. func (p *WebhookProvider) validateWebhookURL(rawURL, fieldName string) error { // Parse URL first for basic validation parsed, err := url.Parse(rawURL) @@ -216,22 +221,41 @@ func (p *WebhookProvider) validateWebhookURL(rawURL, fieldName string) error { return fmt.Errorf("%s is missing hostname", fieldName) } - // Check if this is a localhost URL (allowed for development) - isLocalhost := host == "localhost" || host == "127.0.0.1" || host == "::1" + // Loopback is accepted only through this explicit exception (development + // and tests); it is the one place plain http is allowed. + isLoopback := isLoopbackWebhookHost(host) // Require HTTPS for non-localhost URLs - if !isLocalhost && parsed.Scheme != "https" { + if !isLoopback && parsed.Scheme != "https" { return fmt.Errorf("%s must use HTTPS for non-localhost URLs (security requirement)", fieldName) } - // For external URLs (non-localhost), we skip DNS-based SSRF validation during - // credential validation as the target might not be reachable from the validation - // environment. Runtime SSRF protection will be enforced when actually calling the webhook. - // This matches the pattern used by RFC2136Provider which also validates format only. + if isLoopback { + // The shared syntax check rejects loopback outright, so apply only its + // structural rules (no credentials, query or fragment) to the exception. + if parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || strings.Contains(rawURL, "#") { + return fmt.Errorf("%s must not contain credentials, a query string or a fragment", fieldName) + } + return nil + } + if _, err := safehttp.ValidateURLSyntax(rawURL, safehttp.PrivateNetworkOK()); err != nil { + return fmt.Errorf("%s is not allowed: %w", fieldName, err) + } return nil } +// isLoopbackWebhookHost reports whether host is "localhost" (with or without a +// trailing dot) or a loopback IP literal, including IPv4-mapped IPv6 forms. +func isLoopbackWebhookHost(host string) bool { + name := strings.TrimSuffix(host, ".") + if strings.EqualFold(name, "localhost") { + return true + } + ip := net.ParseIP(name) + return ip != nil && ip.IsLoopback() +} + // TestCredentials attempts to verify credentials work. // For webhook, we validate the format but cannot test without making actual HTTP calls. func (p *WebhookProvider) TestCredentials(creds map[string]string) error { diff --git a/backend/pkg/dnsprovider/custom/webhook_provider_test.go b/backend/pkg/dnsprovider/custom/webhook_provider_test.go index 0961418c0..34ec31016 100644 --- a/backend/pkg/dnsprovider/custom/webhook_provider_test.go +++ b/backend/pkg/dnsprovider/custom/webhook_provider_test.go @@ -1,10 +1,13 @@ package custom import ( + "errors" + "strings" "testing" "time" "github.com/Wikid82/charon/backend/pkg/dnsprovider" + "github.com/Wikid82/charon/backend/pkg/safehttp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -475,6 +478,89 @@ func TestWebhookProvider_ValidateWebhookURL(t *testing.T) { } } +func TestWebhookProvider_ValidateWebhookURL_AddressPolicy(t *testing.T) { + provider := NewWebhookProvider() + + accepted := []string{ + "https://api.example.com/webhook", + "https://api.example.com:8443/hooks/dns", + "https://10.0.0.5/webhook", + "https://192.168.1.10:8443/webhook", + "https://100.64.0.7/webhook", + "http://localhost:8080/webhook", + "http://localhost./webhook", + "http://LOCALHOST:8080/webhook", + "http://127.0.0.1:8080/webhook", + "http://127.0.0.2:8080/webhook", + "http://[::1]:8080/webhook", + "http://[::ffff:127.0.0.1]:8080/webhook", + } + for _, raw := range accepted { + if err := provider.validateWebhookURL(raw, "create_url"); err != nil { + t.Errorf("%s rejected: %v", raw, err) + } + } + + rejected := []string{ + // restricted literal addresses (https so only the address policy can reject them) + "https://169.254.169.254/latest/meta-data", + "https://[::ffff:169.254.169.254]/", + "https://100.100.100.200/", + "https://[fd00::1]/webhook", + "https://127.1/webhook", + "https://2130706433/webhook", + "https://0x7f000001/webhook", + "https://0177.0.0.1/webhook", + "https://[fe80::1]/webhook", + "https://0.0.0.0/webhook", + "https://240.0.0.1/webhook", + "https://192.0.0.1/webhook", + "https://[64:ff9b::1]/webhook", + "https://[2002::1]/webhook", + "https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/webhook", + // loopback is only accepted for the explicit localhost exception, and not over https tricks + "https://foo.localhost/webhook", + // non-loopback http + "http://10.0.0.5/webhook", + "http://169.254.169.254/", + "http://100.100.100.200/", + // URL structure + "https://user:pw@api.example.com/webhook", + "https://good.com@169.254.169.254/", + "http://user:pw@localhost:8080/webhook", + "https://api.example.com/webhook#frag", + "https://api.example.com/webhook?token=abc", + "http://localhost:8080/webhook?token=abc", + "http://localhost:8080/webhook#frag", + } + for _, raw := range rejected { + if err := provider.validateWebhookURL(raw, "create_url"); err == nil { + t.Errorf("%s accepted", raw) + } + } + + // Blocked literal addresses carry the shared sentinel and the field name. + err := provider.validateWebhookURL("https://169.254.169.254/", "delete_url") + if err == nil || !errors.Is(err, safehttp.ErrBlockedAddress) || !strings.Contains(err.Error(), "delete_url") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestIsLoopbackWebhookHost(t *testing.T) { + loopback := []string{"localhost", "localhost.", "LocalHost", "127.0.0.1", "127.0.0.2", "127.255.255.254", "::1", "::ffff:127.0.0.1"} + for _, h := range loopback { + if !isLoopbackWebhookHost(h) { + t.Errorf("%q should be loopback", h) + } + } + other := []string{"", "example.com", "127.0.0.1.example.com", "10.0.0.1", "::2", "foo.localhost", "localhost.example.com", "128.0.0.1"} + for _, h := range other { + if isLoopbackWebhookHost(h) { + t.Errorf("%q should not be loopback", h) + } + } +} + func TestWebhookProvider_TestCredentials(t *testing.T) { provider := NewWebhookProvider() diff --git a/plugins/powerdns/README.md b/plugins/powerdns/README.md index bd888d863..b488eb7de 100644 --- a/plugins/powerdns/README.md +++ b/plugins/powerdns/README.md @@ -30,6 +30,12 @@ The PowerDNS plugin requires: - **API Key**: Your PowerDNS API key (X-API-Key header value) - **Server ID** (optional): PowerDNS server ID (default: `localhost`) +The API URL must be `https`, or `http` for a private network address (a private IPv4 LAN address or a Tailscale-style `100.64.x.x` address). Loopback addresses (`localhost`, `127.x.x.x`, `::1`), link-local and other reserved addresses are not accepted: use the host's LAN or Docker network address instead. Credentials are checked when they are saved and when they are tested. The Server ID may contain only letters, digits, `.`, `_` and `-`. + +## Rebuilding + +Rebuilding the plugin produces a different `powerdns.so` checksum. If you pin plugin signatures with `CHARON_PLUGIN_SIGNATURES`, recompute the SHA-256 (`sha256sum powerdns.so`) and update the allowlist. + ## Caddy Requirement This plugin only handles the Charon UI/API integration. To use PowerDNS for DNS challenges, Caddy must be built with the [caddy-dns/powerdns](https://github.com/caddy-dns/powerdns) module. diff --git a/plugins/powerdns/main.go b/plugins/powerdns/main.go index a7ca64c58..c5c4345ab 100644 --- a/plugins/powerdns/main.go +++ b/plugins/powerdns/main.go @@ -1,14 +1,38 @@ package main import ( + "context" + "errors" "fmt" "net/http" + "net/url" + "regexp" "runtime" "time" "github.com/Wikid82/charon/backend/pkg/dnsprovider" + "github.com/Wikid82/charon/backend/pkg/safehttp" ) +const ( + defaultServerID = "localhost" + requestTimeout = 10 * time.Second +) + +// serverIDPattern restricts server_id to a single, safe URL path segment. +var serverIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) + +// endpointBlockedError is the administrator-facing message for an api_url that +// points at an address the policy does not allow. It unwraps to +// safehttp.ErrBlockedAddress. +type endpointBlockedError struct{} + +func (endpointBlockedError) Error() string { + return "api_url points to an address that is not allowed; use a non-loopback address reachable from Charon (for example a private IPv4 LAN address or a 100.64.x.x Tailscale-style address; IPv6 private addresses are not accepted)" +} + +func (endpointBlockedError) Unwrap() error { return safehttp.ErrBlockedAddress } + // Plugin is the exported symbol that Charon looks for. var Plugin dnsprovider.ProviderPlugin = &PowerDNSProvider{} @@ -72,6 +96,27 @@ func (p *PowerDNSProvider) OptionalCredentialFields() []dnsprovider.CredentialFi } } +// serverID returns the configured server_id, defaulting to "localhost". +func serverID(creds map[string]string) string { + if id := creds["server_id"]; id != "" { + return id + } + return defaultServerID +} + +func validServerID(id string) bool { + return id != "." && id != ".." && serverIDPattern.MatchString(id) +} + +// endpointError converts a URL validation failure into an administrator-facing +// error; blocked addresses get the remedy message. +func endpointError(err error) error { + if errors.Is(err, safehttp.ErrBlockedAddress) { + return endpointBlockedError{} + } + return fmt.Errorf("api_url is invalid: %w", err) +} + func (p *PowerDNSProvider) ValidateCredentials(creds map[string]string) error { if creds["api_url"] == "" { return fmt.Errorf("api_url is required") @@ -79,6 +124,14 @@ func (p *PowerDNSProvider) ValidateCredentials(creds map[string]string) error { if creds["api_key"] == "" { return fmt.Errorf("api_key is required") } + if !validServerID(serverID(creds)) { + return fmt.Errorf("server_id must be 1-64 characters: letters, digits, '.', '_' or '-'") + } + // Syntax and literal-address checks only (no DNS), so bad endpoints are also + // rejected when credentials are saved. + if _, err := safehttp.ValidateURLSyntax(creds["api_url"], safehttp.PrivateNetworkOK()); err != nil { + return endpointError(err) + } return nil } @@ -87,23 +140,42 @@ func (p *PowerDNSProvider) TestCredentials(creds map[string]string) error { return err } - // Test API connectivity - serverID := creds["server_id"] - if serverID == "" { - serverID = "localhost" + // Early, readable error; the client's dialer re-validates every connection. + base, err := safehttp.ValidateURL(creds["api_url"], safehttp.PrivateNetworkOK()) + if err != nil { + return endpointError(err) } - url := fmt.Sprintf("%s/api/v1/servers/%s", creds["api_url"], serverID) - req, err := http.NewRequest("GET", url, nil) + client := safehttp.NewClient(safehttp.PrivateNetworkOK(), requestTimeout) + return p.probe(client, base, serverID(creds), creds["api_key"]) +} + +// probe performs the status-only connectivity check. The response body is never +// read or returned. +func (p *PowerDNSProvider) probe(client *http.Client, base *url.URL, id, apiKey string) error { + if !validServerID(id) { + return fmt.Errorf("server_id must be 1-64 characters: letters, digits, '.', '_' or '-'") + } + target, err := safehttp.JoinPath(base, "api", "v1", "servers", id) + if err != nil { + return fmt.Errorf("failed to build request URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody) if err != nil { return fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("X-API-Key", creds["api_key"]) - client := &http.Client{Timeout: 10 * time.Second} + req.Header.Set("X-API-Key", apiKey) + resp, err := client.Do(req) if err != nil { + if errors.Is(err, safehttp.ErrBlockedAddress) { + return endpointBlockedError{} + } return fmt.Errorf("API connection failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("API returned status %d", resp.StatusCode) } @@ -115,15 +187,11 @@ func (p *PowerDNSProvider) SupportsMultiCredential() bool { } func (p *PowerDNSProvider) BuildCaddyConfig(creds map[string]string) map[string]any { - serverID := creds["server_id"] - if serverID == "" { - serverID = "localhost" - } return map[string]any{ "name": "powerdns", "api_url": creds["api_url"], "api_key": creds["api_key"], - "server_id": serverID, + "server_id": serverID(creds), } } diff --git a/plugins/powerdns/main_test.go b/plugins/powerdns/main_test.go index 43d6b772b..c81dc4f76 100644 --- a/plugins/powerdns/main_test.go +++ b/plugins/powerdns/main_test.go @@ -1,10 +1,17 @@ package main import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" "testing" "time" "github.com/Wikid82/charon/backend/pkg/dnsprovider" + "github.com/Wikid82/charon/backend/pkg/safehttp" ) func TestProviderIdentity(t *testing.T) { @@ -89,3 +96,204 @@ func TestPropagationSettings(t *testing.T) { t.Fatalf("SupportsMultiCredential() = true, want false") } } + +func TestValidateCredentials_EndpointAndServerID(t *testing.T) { + p := &PowerDNSProvider{} + base := func(mut func(map[string]string)) map[string]string { + c := map[string]string{"api_url": "https://pdns.example.com:8081", "api_key": "k"} + mut(c) + return c + } + + accepted := []map[string]string{ + base(func(c map[string]string) {}), + base(func(c map[string]string) { c["api_url"] = "http://pdns.example.com:8081" }), + base(func(c map[string]string) { c["api_url"] = "http://10.0.0.5:8081" }), + base(func(c map[string]string) { c["api_url"] = "http://192.168.1.10:8081/pdns/" }), + base(func(c map[string]string) { c["api_url"] = "http://100.64.0.7:8081" }), + base(func(c map[string]string) { c["server_id"] = "ns1.example_com-2" }), + base(func(c map[string]string) { c["server_id"] = strings.Repeat("a", 64) }), + } + for _, creds := range accepted { + if err := p.ValidateCredentials(creds); err != nil { + t.Errorf("%v rejected: %v", creds, err) + } + } + + blockedURLs := []string{ + "http://127.0.0.1:8081", "http://localhost:8081", "http://[::1]:8081", + "http://169.254.169.254", "http://100.100.100.200", "http://[fd00::1]:8081", + "http://0.0.0.0:8081", "http://[64:ff9b::1]", "http://192.0.0.1", + "http://127.1:8081", "http://2130706433:8081", "http://0x7f000001:8081", "http://0177.0.0.1:8081", + } + for _, raw := range blockedURLs { + err := p.ValidateCredentials(base(func(c map[string]string) { c["api_url"] = raw })) + if err == nil { + t.Errorf("%s accepted", raw) + continue + } + if !errors.Is(err, safehttp.ErrBlockedAddress) { + t.Errorf("%s: expected blocked-address error, got %v", raw, err) + } + if !strings.Contains(err.Error(), "use a non-loopback address reachable from Charon") { + t.Errorf("%s: missing remedy text: %v", raw, err) + } + } + + badURLs := []string{ + "ftp://pdns.example.com", "file:///etc/passwd", "https://user:pw@pdns.example.com", + "https://pdns.example.com/?x=1", "https://pdns.example.com/#f", "pdns.example.com", "https://", + } + for _, raw := range badURLs { + err := p.ValidateCredentials(base(func(c map[string]string) { c["api_url"] = raw })) + if err == nil { + t.Errorf("%s accepted", raw) + } + } + + badServerIDs := []string{"x/../y", "x?a=b", "x#f", "%2e%2e", "a b", "x\r\ny", ".", "..", strings.Repeat("a", 65), "a/b", "é"} + for _, id := range badServerIDs { + if err := p.ValidateCredentials(base(func(c map[string]string) { c["server_id"] = id })); err == nil { + t.Errorf("server_id %q accepted", id) + } + } +} + +func TestTestCredentials_BlockedTargetsMakeNoConnection(t *testing.T) { + p := &PowerDNSProvider{} + for _, raw := range []string{"http://127.0.0.1:8081", "https://169.254.169.254", "http://100.100.100.200", "http://localhost:8081"} { + start := time.Now() + err := p.TestCredentials(map[string]string{"api_url": raw, "api_key": "k"}) + if err == nil { + t.Fatalf("%s accepted", raw) + } + if !errors.Is(err, safehttp.ErrBlockedAddress) { + t.Errorf("%s: expected blocked-address error, got %v", raw, err) + } + if time.Since(start) > 3*time.Second { + t.Errorf("%s: rejection should be immediate", raw) + } + } + + if err := p.TestCredentials(map[string]string{"api_url": "https://pdns.example.com"}); err == nil { + t.Error("missing api_key accepted") + } +} + +func probeTarget(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} + +func TestProbe_StatusMappingAndNoBodyEcho(t *testing.T) { + p := &PowerDNSProvider{} + const marker = "SECRET-BODY-MARKER" + + var gotPath, gotKey atomic.Value + status := atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath.Store(r.URL.EscapedPath()) + gotKey.Store(r.Header.Get("X-API-Key")) + w.WriteHeader(int(status.Load())) + _, _ = w.Write([]byte(marker)) + })) + defer srv.Close() + + hc := srv.Client() + hc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + status.Store(http.StatusOK) + if err := p.probe(hc, probeTarget(t, srv.URL+"/pdns/"), "ns1", "my-key"); err != nil { + t.Fatalf("200 should succeed: %v", err) + } + if gotPath.Load() != "/pdns/api/v1/servers/ns1" { + t.Errorf("path = %v", gotPath.Load()) + } + if gotKey.Load() != "my-key" { + t.Errorf("api key header = %v", gotKey.Load()) + } + + for _, code := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusFound, http.StatusInternalServerError} { + status.Store(int32(code)) + err := p.probe(hc, probeTarget(t, srv.URL), "localhost", "k") + if err == nil { + t.Errorf("status %d should fail", code) + continue + } + if !strings.Contains(err.Error(), "API returned status") { + t.Errorf("status %d: unexpected error %v", code, err) + } + if strings.Contains(err.Error(), marker) { + t.Errorf("status %d: response body echoed: %v", code, err) + } + } +} + +func TestProbe_DialTimeBlockMapsToRemedyAndSendsNothing(t *testing.T) { + p := &PowerDNSProvider{} + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) })) + defer srv.Close() + + // The production client has no loopback allowance, so the dialer alone must stop this. + err := p.probe(safehttp.NewClient(safehttp.PrivateNetworkOK(), 2*time.Second), probeTarget(t, srv.URL), "localhost", "k") + if err == nil { + t.Fatal("loopback target accepted") + } + if !errors.Is(err, safehttp.ErrBlockedAddress) { + t.Errorf("expected blocked-address error, got %v", err) + } + if !strings.Contains(err.Error(), "use a non-loopback address reachable from Charon") { + t.Errorf("missing remedy text: %v", err) + } + if hits.Load() != 0 { + t.Errorf("server hit %d times, want 0", hits.Load()) + } +} + +func TestProbe_RedirectNotFollowed(t *testing.T) { + p := &PowerDNSProvider{} + var secondHits atomic.Int32 + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { secondHits.Add(1) })) + defer second.Close() + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, second.URL, http.StatusTemporaryRedirect) + })) + defer first.Close() + + hc := first.Client() + hc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + err := p.probe(hc, probeTarget(t, first.URL), "localhost", "k") + if err == nil || !strings.Contains(err.Error(), "API returned status 307") { + t.Fatalf("expected status 307 error, got %v", err) + } + if secondHits.Load() != 0 { + t.Errorf("redirect target hit %d times", secondHits.Load()) + } +} + +func TestProbe_ConnectionFailureKeepsExistingPrefix(t *testing.T) { + p := &PowerDNSProvider{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + target := probeTarget(t, srv.URL) + srv.Close() + + err := p.probe(&http.Client{Timeout: time.Second}, target, "localhost", "k") + if err == nil || !strings.Contains(err.Error(), "API connection failed") { + t.Fatalf("expected connection failure, got %v", err) + } + if errors.Is(err, safehttp.ErrBlockedAddress) { + t.Error("plain connection failure must not claim a blocked address") + } +} + +func TestProbe_RejectsUnsafeServerID(t *testing.T) { + p := &PowerDNSProvider{} + if err := p.probe(http.DefaultClient, probeTarget(t, "https://pdns.example.com"), "../x", "k"); err == nil { + t.Fatal("dot-dot server id accepted") + } +} From afcc35bf0f339516c4cc10eb2d2abd6250317f1b Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 3 Oct 2026 21:35:53 +0000 Subject: [PATCH 27/57] docs: document stricter address checks for provider integrations Charon now checks the addresses it connects to when you test or save provider integrations, and refuses addresses that point back at the Charon host itself or at cloud-internal ranges. Documents the new behaviour and the matching plugin-author helpers. Release note: - PowerDNS: the API address is checked when you create, update or test the provider. Use your server's LAN or Docker network address rather than localhost. Existing providers keep issuing certificates. Use https over untrusted networks. - NetBird/ZeroTier: self-hosted controllers must be reachable at a public https address. ZeroTier network IDs must be 16 lowercase hex characters. - Webhook DNS provider: addresses with embedded credentials, a query string or a fragment are refused (except localhost); keep secrets in the auth fields. A provider saved earlier with a query string will fail on its next update. - The bundled PowerDNS plugin has been rebuilt. If you pin plugin signatures with CHARON_PLUGIN_SIGNATURES, recompute the SHA-256 (sha256sum powerdns.so) and update the allowlist. --- ARCHITECTURE.md | 4 +- docs/development/plugin-development.md | 70 +++++++++++++++++++++----- docs/features.md | 2 + docs/features/dns-providers.md | 26 ++++++++++ docs/features/hecate.md | 6 +++ docs/features/plugin-security.md | 12 +++++ 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c0cf75265..3f75439bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -209,6 +209,7 @@ graph TB │ │ │ └── data/changelog.json # Build-time generated changelog data (see "Release Workflow") │ │ └── utils/ # Helper functions │ ├── pkg/ # Public reusable packages +│ │ └── safehttp/ # Validated outbound HTTP helpers for in-tree and community DNS provider plugins │ ├── integration/ # Integration tests │ ├── go.mod # Go module definition │ └── go.sum # Go dependency checksums @@ -937,7 +938,8 @@ pin (the stage already carries ~40 such pins). **Additional Protections:** - **SSRF Prevention:** Block requests to private IP ranges in webhooks/URL - validation. `network.NewSafeHTTPClient` disables HTTP keep-alives by default; + validation. DNS provider plugins, which cannot import `internal/`, use the + public `backend/pkg/safehttp` facade over the same checks. `network.NewSafeHTTPClient` disables HTTP keep-alives by default; the uptime worker pool opts into a pooled variant via `network.WithKeepAlive(100, 4, 30s)`, where `safeDialer` still re-validates every new connection and the 30 s idle timeout bounds how long a reused diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index f5363ea9a..9420aab6d 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -37,9 +37,10 @@ Charon uses Go's plugin system to dynamically load DNS provider implementations. ### Build Requirements - **CGO:** Must be enabled (`CGO_ENABLED=1`) -- **Go Version:** Must match Charon's Go version exactly (currently 1.25.6+) +- **Go Version:** Must match Charon's Go version exactly (currently 1.27.1+) - **Compiler:** GCC/Clang for Linux, Xcode tools for macOS - **Build Mode:** Must use `-buildmode=plugin` +- **Build Flags:** No `-trimpath`, `-race` or `-cover`; use the same Go toolchain and dependency versions as the Charon binary ## Interface Specification @@ -180,10 +181,16 @@ func (p *PowerDNSProvider) ValidateCredentials(creds map[string]string) error { if creds["api_key"] == "" { return fmt.Errorf("api_key is required") } + // Syntax and literal-address checks only (no DNS lookups) + if _, err := safehttp.ValidateURLSyntax(creds["api_url"], safehttp.PrivateNetworkOK()); err != nil { + return fmt.Errorf("api_url is not allowed: %w", err) + } return nil } ``` +See [Validated Outbound Requests](#validated-outbound-requests) below. + #### `TestCredentials(creds map[string]string) error` Verifies credentials work with the provider API (may make network calls). @@ -196,17 +203,27 @@ func (p *PowerDNSProvider) TestCredentials(creds map[string]string) error { return err } - // Test API connectivity - url := creds["api_url"] + "/api/v1/servers" - req, _ := http.NewRequest("GET", url, nil) + // Test API connectivity through the validated helpers + policy := safehttp.PrivateNetworkOK() + base, err := safehttp.ValidateURL(creds["api_url"], policy) + if err != nil { + return err + } + target, err := safehttp.JoinPath(base, "api", "v1", "servers") + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodGet, target, http.NoBody) + if err != nil { + return err + } req.Header.Set("X-API-Key", creds["api_key"]) - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + resp, err := safehttp.NewClient(policy, 10*time.Second).Do(req) if err != nil { return fmt.Errorf("API connection failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("API returned status %d", resp.StatusCode) @@ -405,7 +422,7 @@ my-provider-plugin/ ```go module github.com/yourname/charon-plugin-myprovider -go 1.25 +go 1.27 require ( github.com/Wikid82/charon v0.0.0-20240101000000-abcdef123456 @@ -418,14 +435,41 @@ require ( replace github.com/Wikid82/charon => /path/to/charon ``` +## Validated Outbound Requests + +Plugins cannot import Charon's internal packages. For checking user-supplied addresses and making requests, import the small public package `github.com/Wikid82/charon/backend/pkg/safehttp`: + +```go +policy := safehttp.PrivateNetworkOK() // or safehttp.PublicHTTPSOnly() + +base, err := safehttp.ValidateURL(apiURL, policy) // early, readable error +if err != nil { + return err +} +target, err := safehttp.JoinPath(base, "api", "v1", "servers", id) +if err != nil { + return err +} +req, _ := http.NewRequest(http.MethodGet, target, http.NoBody) +resp, err := safehttp.NewClient(policy, 10*time.Second).Do(req) +``` + +- `PublicHTTPSOnly()` allows only `https` to public addresses. `PrivateNetworkOK()` also allows `http` and LAN or Tailscale-style addresses. Both refuse the host itself (loopback), link-local and other reserved addresses. +- `ValidateURLSyntax` checks the address shape and literal IPs without any DNS lookup, and refuses unusual numeric spellings such as `127.1` or `0x7f000001`; use it when saving credentials. +- `ValidateURL` is an early check for friendlier errors. It also refuses ports below 1024 other than 80 and 443; `ValidateURLSyntax` does not apply that port rule. The connection-time check inside `NewClient` is authoritative: it re-checks every connection and never follows redirects. +- Test for blocked addresses with `errors.Is(err, safehttp.ErrBlockedAddress)`. +- Use `JoinPath` to add path pieces; it rejects `..` and separators. + ## Building Plugins ### Build Command ```bash -CGO_ENABLED=1 go build -buildmode=plugin -o myprovider.so main.go +CGO_ENABLED=1 go build -buildmode=plugin -o myprovider.so . ``` +For the bundled PowerDNS plugin, use `make plugin-powerdns` (build and test) and `make plugin-powerdns-smoke` (check it loads into Charon). + ### Build Requirements 1. **CGO must be enabled:** @@ -434,14 +478,16 @@ CGO_ENABLED=1 go build -buildmode=plugin -o myprovider.so main.go export CGO_ENABLED=1 ``` -2. **Go version must match Charon:** +2. **Same flags and versions as Charon:** do not use `-trimpath`, `-race` or `-cover`, and keep dependency versions identical to the Charon binary (an in-repo plugin gets this from the workspace `go.work`). Otherwise Charon refuses to load the plugin. + +3. **Go version must match Charon:** ```bash go version # Must match Charon's build Go version ``` -3. **Architecture must match:** +4. **Architecture must match:** ```bash # For cross-compilation @@ -485,7 +531,7 @@ set -e PLUGIN_NAME="myprovider" GO_VERSION=$(go version | awk '{print $3}') -CHARON_GO_VERSION="go1.25.6" +CHARON_GO_VERSION="go1.27.1" # Verify Go version if [ "$GO_VERSION" != "$CHARON_GO_VERSION" ]; then diff --git a/docs/features.md b/docs/features.md index 5c227b306..5ae8a19a6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -54,6 +54,8 @@ Need to secure `*.example.com` with a single certificate? Charon now supports DN - Cloudflare, AWS Route 53, DigitalOcean, Google Cloud DNS, Azure DNS - Namecheap, GoDaddy, Hetzner, Vultr, DNSimple +Charon checks the addresses you enter for DNS providers and refuses ones that point back at the Charon host itself or at cloud-internal addresses. + Your credentials are stored securely with encryption and automatic key rotation. More providers are on the roadmap — see the [DNS Providers Guide](guides/dns-providers.md) for details. → [Learn More](features/dns-challenge.md) diff --git a/docs/features/dns-providers.md b/docs/features/dns-providers.md index 3d56079bf..b0cf5616c 100644 --- a/docs/features/dns-providers.md +++ b/docs/features/dns-providers.md @@ -172,12 +172,38 @@ Content-Type: application/json ### Security Notes - **HTTPS Required**: Non-localhost URLs must use HTTPS +- **Keep secrets out of the address**: Addresses containing a username and password (`user:pass@`), a `?query` or a `#fragment` are refused, including for `localhost`. Put secrets in the `auth_header` and `auth_value` fields instead. +- **Checked when saved**: Charon checks each address when you save the provider. If you saved a provider earlier with a `?query` in its address, you will see the error the next time you edit and save it. Remove the query and move the secret into the auth fields. - **Authentication**: Always use `auth_header` and `auth_value` for production - **Timeouts**: Set appropriate timeouts to avoid blocking certificate issuance - **`insecure_skip_verify`**: Never enable in production; only for local development with self-signed certs --- +## PowerDNS + +PowerDNS is available as a plugin. Enter the address of your PowerDNS API and your API key. + +### Which Addresses Work + +| Address | Allowed? | +|---|---| +| Your server's LAN address (like `192.168.1.20`) or a Docker network address | Yes | +| Tailscale-style addresses (`100.64.x.x`) | Yes | +| `localhost`, `127.0.0.1`, `::1` (the Charon host itself) | No — use your server's LAN or Docker network address instead | +| Private IPv6 addresses (like `fd00::` or `fd7a::`) | No — use an IPv4 LAN or `100.64.x.x` address | +| Cloud-internal addresses | No | + +- Charon checks the address when you create, update or test the provider. +- Charon does not follow redirects. +- When you press **Test**, Charon also refuses ports below 1024 other than 80 and 443 (for example `:22`). Saving a provider does not apply this port rule. +- Charon also refuses unusual ways of writing an IP address, such as `127.1` or `0x7f000001`. Use the normal form (`192.168.1.20`) or a hostname. +- Providers you already saved keep issuing certificates as before. The check applies when you test or save. +- Use `https` if the connection crosses a network you do not fully trust, because your API key is sent with every request. Plain `http` is still allowed. +- Charon refuses connections to a few internal-only address ranges everywhere it makes outgoing connections. + +--- + ## Script Provider The Script provider executes shell scripts to manage DNS records, enabling integration with legacy systems or tools without API access. diff --git a/docs/features/hecate.md b/docs/features/hecate.md index 2e2228ce6..743b13786 100644 --- a/docs/features/hecate.md +++ b/docs/features/hecate.md @@ -71,6 +71,10 @@ These VPN and tunnel services work with Provider Mode: | Cloudflare | Cloudflare Tunnel credentials | | ZeroTier | ZeroTier network ID + node details | +**Self-hosted NetBird or ZeroTier controllers:** your controller must be reachable at a public `https` address. Controllers that can only be reached through a private or VPN-style address (including Tailscale-style `100.64.x.x`) are refused. The default hosted services are not affected. + +**ZeroTier network IDs** must be exactly 16 lowercase letters and digits (`0-9`, `a-f`). Anything else is rejected with an "invalid network id" message. + To add a provider: **Settings → Tunnel Providers → Add Provider** → choose your type → enter credentials → save. --- @@ -99,6 +103,8 @@ Remote servers managed through Orthrus agents work with [Uptime Monitoring](upti | Agent Mode address not filling in | No network assignment set on the agent | Open the agent → assign a Provider + Device → save | | Tunnel keeps restarting | VPN provider is temporarily unreachable | This is normal — Hecate retries automatically with increasing delays | | Device not listed in Provider Mode | Provider not yet configured | Add the provider in Settings first | +| Self-hosted NetBird/ZeroTier controller refused | Controller is only reachable on a private or VPN address | Make it reachable at a public `https` address | +| "invalid network id" | ZeroTier network ID is not 16 lowercase letters/digits | Copy the ID again from your ZeroTier dashboard | --- diff --git a/docs/features/plugin-security.md b/docs/features/plugin-security.md index 63df2830b..8c5bc222c 100644 --- a/docs/features/plugin-security.md +++ b/docs/features/plugin-security.md @@ -85,6 +85,14 @@ export CHARON_PLUGIN_SIGNATURES='{"myplugin": "sha256:a1b2c3d4e5f6g7h8i9j0k1l2m3 --- +## Building Compatible Plugins + +A plugin only loads if it was built the same way as Charon itself: `CGO_ENABLED=1 go build -buildmode=plugin`, with no `-trimpath`, `-race` or `-cover`, using the same Go version and the same dependency versions as the Charon binary. Rebuilding always produces a new checksum, so update your allowlist afterwards. See the [plugin development guide](../development/plugin-development.md#building-plugins). + +## Outgoing Connections + +Plugins that call outside services should use Charon's validated helpers (see the plugin development guide). Charon refuses connections to a few internal-only address ranges. + ## Container Deployment Recommendations ### Read-Only Plugin Mount (Critical) @@ -192,6 +200,10 @@ docker compose restart charon > **⚠️ Warning**: `cp` followed by direct write to the plugin directory is **not atomic** and creates a window for exploitation. +### Bundled PowerDNS Plugin Was Rebuilt + +The bundled PowerDNS plugin has been rebuilt. If you pin plugin signatures with `CHARON_PLUGIN_SIGNATURES`, recompute the SHA-256 (`sha256sum powerdns.so`) and update the allowlist. Until you do, the PowerDNS provider will not load. + ### 3. Signature Re-Verification on Reload After updating plugins, always update your `CHARON_PLUGIN_SIGNATURES` with the new hash before restarting. From b8470470328cb2ae673ad106692700f68fee79c6 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 00:15:26 +0000 Subject: [PATCH 28/57] chore: teach code scanning about the shared outbound request helpers Add a repo-local CodeQL model pack, auto-loaded by code scanning, that declares the validated-URL helpers in backend/pkg/safehttp as barriers, and use it in the local Go scan script too. Clarify that the older custom model file is documentation only. --- .github/codeql-custom-model.yml | 6 ++++++ .../charon-safehttp-models/codeql-pack.yml | 12 +++++++++++ .../models/safehttp.model.yml | 21 +++++++++++++++++++ scripts/pre-commit-hooks/codeql-go-scan.sh | 3 +++ 4 files changed, 42 insertions(+) create mode 100644 .github/codeql/extensions/charon-safehttp-models/codeql-pack.yml create mode 100644 .github/codeql/extensions/charon-safehttp-models/models/safehttp.model.yml diff --git a/.github/codeql-custom-model.yml b/.github/codeql-custom-model.yml index 9b2d597e0..d0f79e610 100644 --- a/.github/codeql-custom-model.yml +++ b/.github/codeql-custom-model.yml @@ -16,6 +16,12 @@ # - IPv6 Unique Local: fc00::/7 # # Reference: /docs/plans/current_spec.md +# +# NOTE: this file is NOT loaded by code scanning. Data extensions are only +# picked up from model packs under .github/codeql/extensions/ (see +# charon-safehttp-models there). Entries below are kept for documentation; note +# that a sinkModel marks a request-forgery sink rather than a sanitizer, so +# they should not be moved into a pack as-is (use barrierModel instead). extensions: # ============================================================================= # SSRF SANITIZER MODELS diff --git a/.github/codeql/extensions/charon-safehttp-models/codeql-pack.yml b/.github/codeql/extensions/charon-safehttp-models/codeql-pack.yml new file mode 100644 index 000000000..3268cf95b --- /dev/null +++ b/.github/codeql/extensions/charon-safehttp-models/codeql-pack.yml @@ -0,0 +1,12 @@ +# CodeQL model pack: data extensions for Charon's own helpers. +# +# Code scanning (github/codeql-action) automatically discovers model packs +# under .github/codeql/extensions/, so this pack applies to CI without being +# listed in .github/codeql/codeql-config.yml. +name: charon/safehttp-models +version: 0.0.1 +library: true +extensionTargets: + codeql/go-all: "*" +dataExtensions: + - models/**/*.yml diff --git a/.github/codeql/extensions/charon-safehttp-models/models/safehttp.model.yml b/.github/codeql/extensions/charon-safehttp-models/models/safehttp.model.yml new file mode 100644 index 000000000..96a70044b --- /dev/null +++ b/.github/codeql/extensions/charon-safehttp-models/models/safehttp.model.yml @@ -0,0 +1,21 @@ +# Barrier models for the shared outbound-request helpers in +# backend/pkg/safehttp. +# +# The values returned by these functions are safe to use as a request URL: +# - ValidateURLSyntax / ValidateURL reject non-HTTP(S) schemes, embedded +# credentials, and literal blocked addresses (ValidateURL also resolves +# hostnames), and return a parsed *url.URL. +# - JoinPath only appends individually escaped path segments to a validated +# base URL (dot segments, separators, and control characters are rejected), +# so the scheme and host of the result always come from that base. +# The dialer inside safehttp.NewClient re-validates every connection address +# and is the authoritative control; these barriers only tell the query where +# validation has already happened. +extensions: + - addsTo: + pack: codeql/go-all + extensible: barrierModel + data: + - ["github.com/Wikid82/charon/backend/pkg/safehttp", "", False, "ValidateURLSyntax", "", "", "ReturnValue[0]", "request-forgery", "manual"] + - ["github.com/Wikid82/charon/backend/pkg/safehttp", "", False, "ValidateURL", "", "", "ReturnValue[0]", "request-forgery", "manual"] + - ["github.com/Wikid82/charon/backend/pkg/safehttp", "", False, "JoinPath", "", "", "ReturnValue[0]", "request-forgery", "manual"] diff --git a/scripts/pre-commit-hooks/codeql-go-scan.sh b/scripts/pre-commit-hooks/codeql-go-scan.sh index 298b4eb91..e54863152 100755 --- a/scripts/pre-commit-hooks/codeql-go-scan.sh +++ b/scripts/pre-commit-hooks/codeql-go-scan.sh @@ -31,8 +31,11 @@ echo "" echo "📊 Analyzing with security-and-quality suite..." ANALYZE_LOG=$(mktemp) # Analyze with CI-aligned suite (mirrors codeql.yml queries: security-and-quality) +# plus the repo model pack that code scanning auto-loads from .github/codeql/extensions codeql database analyze codeql-db-go \ codeql/go-queries:codeql-suites/go-security-and-quality.qls \ + --additional-packs=.github/codeql/extensions \ + --model-packs=charon/safehttp-models@0.0.1 \ --format=sarif-latest \ --output=codeql-results-go.sarif \ --sarif-add-baseline-file-info \ From 88440b7f1da2d643166ddd34ad9558431d682ea8 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Sat, 3 Oct 2026 20:25:38 -0400 Subject: [PATCH 29/57] chore(docker): set ownership at copy time instead of recursive chown of /app The trailing chown -R over /app re-stored the Charon binary, frontend dist, scripts and GeoIP database in a second layer on every release, roughly doubling the per-update pull. Apply ownership with COPY --chown (and in the GeoIP RUN) so each payload lands in exactly one layer. --- Dockerfile | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4cec9cb03..875931339 100644 --- a/Dockerfile +++ b/Dockerfile @@ -999,7 +999,8 @@ RUN mkdir -p /app/data/geoip && \ echo "⚠️ GeoIP download failed or empty — skipping"; \ touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \ fi; \ - fi + fi && \ + chown -R charon:charon /app/data # Copy Caddy binary from caddy-builder (overwriting the one from base image) COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy @@ -1088,7 +1089,7 @@ COPY configs/crowdsec/register_bouncer.sh /usr/local/bin/register_bouncer.sh RUN chmod +x /usr/local/bin/install_hub_items.sh /usr/local/bin/register_bouncer.sh # Copy Go binary from backend builder -COPY --from=backend-builder /app/backend/charon /app/charon +COPY --from=backend-builder --chown=charon:charon /app/backend/charon /app/charon RUN ln -s /app/charon /app/cpmp || true # Copy Delve stub/binary from backend-builder. # Security (GO-2026-5024): production builds (BUILD_DEBUG=0) receive a harmless shell @@ -1099,14 +1100,14 @@ RUN ln -s /app/charon /app/cpmp || true COPY --from=backend-builder /go/bin/dlv /usr/local/bin/dlv # Copy frontend build from frontend builder -COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist +COPY --from=frontend-builder --chown=charon:charon /app/frontend/dist /app/frontend/dist # Copy startup script COPY .docker/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh # Copy utility scripts (used for DB recovery and maintenance) -COPY scripts/ /app/scripts/ +COPY --chown=charon:charon scripts/ /app/scripts/ RUN chmod +x /app/scripts/db-recovery.sh # Set default environment variables @@ -1127,10 +1128,13 @@ RUN mkdir -p /app/data /app/data/caddy /config /app/data/crowdsec # This satisfies the PluginLoaderService security check (mode & 0002 == 0) RUN mkdir -p /app/plugins && chmod 755 /app/plugins -# Security: Set ownership of all application directories to non-root charon user +# Security: Set ownership of all application directories to non-root charon user. +# The large /app payloads (binary, frontend, scripts, GeoIP DB) are chowned where they are +# created (COPY --chown / the GeoIP RUN) — a recursive chown of /app here would re-store +# them in a second layer on every release. Only the small, root-created dirs need it. # Note: /etc/crowdsec will be created as a symlink at runtime, not owned directly # Note: /app/plugins has 755 permissions (NOT world-writable) for security -RUN chown -R charon:charon /app /config /var/log/crowdsec /var/log/caddy && \ +RUN chown -R charon:charon /app/data/caddy /app/data/crowdsec /app/plugins /app/frontend /config /var/log/crowdsec /var/log/caddy && \ chown -R charon:charon /etc/crowdsec.dist 2>/dev/null || true && \ chown -R charon:charon /var/lib/crowdsec 2>/dev/null || true From 1315289968dc232dff46d7f0e622b58d04cc5f90 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 00:40:27 +0000 Subject: [PATCH 30/57] test: handle ignored errors flagged by the full linter set --- backend/internal/network/safeclient_policy_test.go | 2 +- backend/pkg/safehttp/safehttp_test.go | 10 +++++----- plugins/powerdns/main_test.go | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/internal/network/safeclient_policy_test.go b/backend/internal/network/safeclient_policy_test.go index 2273d70c7..06712ef17 100644 --- a/backend/internal/network/safeclient_policy_test.go +++ b/backend/internal/network/safeclient_policy_test.go @@ -317,7 +317,7 @@ func TestNewSafeHTTPClient_BlockOptionsWired(t *testing.T) { ) resp, err := client.Get("http://" + net.JoinHostPort(host, port) + "/") if err == nil { - resp.Body.Close() + _ = resp.Body.Close() t.Fatalf("%s: expected request to be blocked", host) } if !errors.Is(err, ErrBlockedAddress) { diff --git a/backend/pkg/safehttp/safehttp_test.go b/backend/pkg/safehttp/safehttp_test.go index e9efa1c8c..c0921dfa8 100644 --- a/backend/pkg/safehttp/safehttp_test.go +++ b/backend/pkg/safehttp/safehttp_test.go @@ -201,7 +201,7 @@ func TestNewClient_HardenedTransportForEveryPolicy(t *testing.T) { resp, err := client.Get(srv.URL) if err == nil { - resp.Body.Close() + _ = resp.Body.Close() t.Errorf("%s: loopback request succeeded", name) continue } @@ -230,7 +230,7 @@ func TestNewClient_NamedLoopbackBlockedAtDial(t *testing.T) { for _, host := range []string{"localhost", "localhost."} { resp, err := NewClient(PrivateNetworkOK(), 2*time.Second).Get("http://" + host + ":" + port + "/") if err == nil { - resp.Body.Close() + _ = resp.Body.Close() t.Fatalf("%s: request succeeded", host) } if hits.Load() != 0 { @@ -260,7 +260,7 @@ func TestClient_RedirectsAreNotFollowed(t *testing.T) { if resp.StatusCode != code { t.Errorf("code %d target %s: got status %d, want the 3xx surfaced", code, target, resp.StatusCode) } - resp.Body.Close() + _ = resp.Body.Close() first.Close() } if secondHits.Load() != 0 { @@ -303,7 +303,7 @@ func TestClient_IgnoresProxyEnvironment(t *testing.T) { if err != nil { t.Fatalf("request failed: %v", err) } - resp.Body.Close() + _ = resp.Body.Close() if hits.Load() != 1 { t.Errorf("server hits = %d, want 1 (direct connection)", hits.Load()) } @@ -322,7 +322,7 @@ func TestClient_TimeoutAndBodyNotRequired(t *testing.T) { start := time.Now() resp, err := newClient(PrivateNetworkOK(), 100*time.Millisecond, network.WithAllowLocalhost()).Get(slow.URL) if err == nil { - resp.Body.Close() + _ = resp.Body.Close() t.Fatal("expected timeout") } if elapsed := time.Since(start); elapsed > 400*time.Millisecond { diff --git a/plugins/powerdns/main_test.go b/plugins/powerdns/main_test.go index c81dc4f76..d4bb6ef85 100644 --- a/plugins/powerdns/main_test.go +++ b/plugins/powerdns/main_test.go @@ -217,8 +217,8 @@ func TestProbe_StatusMappingAndNoBodyEcho(t *testing.T) { t.Errorf("api key header = %v", gotKey.Load()) } - for _, code := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusFound, http.StatusInternalServerError} { - status.Store(int32(code)) + for _, code := range []int32{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusFound, http.StatusInternalServerError} { + status.Store(code) err := p.probe(hc, probeTarget(t, srv.URL), "localhost", "k") if err == nil { t.Errorf("status %d should fail", code) From 11b9792c69041ae976e78a46f705eb716e6509b5 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 00:52:18 +0000 Subject: [PATCH 31/57] chore: ignore plugin build leftovers in the powerdns module A plain go build in the plugin directory leaves an executable, and code scanning autobuild can generate a go.sum the workspace does not need. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 7c366b950..66933493c 100644 --- a/.gitignore +++ b/.gitignore @@ -350,3 +350,9 @@ charon-scan.tar .claude/worktrees/ scripts/tempCodeRunnerFile.sh backend/internal/api/routes/keys/hecate-ca.crt + +# PowerDNS plugin module: a plain `go build` there leaves an extensionless +# executable, and tooling (e.g. CodeQL autobuild) can generate a go.sum that +# the workspace build does not need. +/plugins/powerdns/powerdns +/plugins/powerdns/go.sum From 16351cbf350f91b13344de8a8721c75f7ef5d550 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Sat, 3 Oct 2026 20:56:00 -0400 Subject: [PATCH 32/57] chore(deps): bump otelhttp to v0.72.0 and add powerdns to updater Update otelhttp to v0.72.0 and otel sdk and sdk/metric to v1.47.0 in the backend module. Also include plugins/powerdns in the modules processed by go_dep_updater.sh. --- backend/go.mod | 2 +- backend/go.sum | 12 ++++++------ scripts/go/go_dep_updater.sh | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index dd821e956..2de07ad17 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -99,7 +99,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect go.opentelemetry.io/otel v1.47.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 96a9fbe55..cad41b937 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -198,18 +198,18 @@ go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUM go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= -go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/scripts/go/go_dep_updater.sh b/scripts/go/go_dep_updater.sh index f851bedb0..32df777ac 100644 --- a/scripts/go/go_dep_updater.sh +++ b/scripts/go/go_dep_updater.sh @@ -17,6 +17,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" GO_MODULES=( "$REPO_ROOT/backend" "$REPO_ROOT/agent" + "$REPO_ROOT/plugins/powerdns" ) for MODULE in "${GO_MODULES[@]}"; do From 4312d8810b3e9a24a05cc71f2880e3c330db48f1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:30:04 +0000 Subject: [PATCH 33/57] chore: update dependency anchore/grype to v0.120.0 --- .github/workflows/supply-chain-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml index 3e4f883aa..a9cf2ced2 100644 --- a/.github/workflows/supply-chain-pr.yml +++ b/.github/workflows/supply-chain-pr.yml @@ -347,7 +347,7 @@ jobs: run: | MAX_ATTEMPTS=3 for attempt in $(seq 1 "$MAX_ATTEMPTS"); do - if curl -sSfL https://raw.githubusercontent.com/anchore/grype/b6f5194537747ee7f705f4113069ac9eb269919f/install.sh | sh -s -- -b /usr/local/bin v0.119.0; then + if curl -sSfL https://raw.githubusercontent.com/anchore/grype/b6f5194537747ee7f705f4113069ac9eb269919f/install.sh | sh -s -- -b /usr/local/bin v0.120.0; then break fi if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then From 34653e6c40cf68c2e5f202b7925d20168a980f28 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 01:36:59 +0000 Subject: [PATCH 34/57] chore(deps): bump eslint-plugin-unicorn to v77 Update eslint-plugin-unicorn from ^76.0.0 to ^77.0.0 in the frontend and refresh the lockfile with the new transitive dependency versions. Add an update script under scripts/npm/frontend for future upgrades. --- frontend/package-lock.json | 20 +++++++++---------- frontend/package.json | 2 +- scripts/npm/frontend/eslint-plugin-unicorn.sh | 17 ++++++++++++++++ 3 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 scripts/npm/frontend/eslint-plugin-unicorn.sh diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6880a6bef..fe473e64d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -70,7 +70,7 @@ "eslint-plugin-security": "^4.2.0", "eslint-plugin-sonarjs": "^4.2.2", "eslint-plugin-testing-library": "^7.16.2", - "eslint-plugin-unicorn": "^76.0.0", + "eslint-plugin-unicorn": "^77.0.0", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "30.0.1", "knip": "^6.39.0", @@ -6490,32 +6490,32 @@ } }, "node_modules/eslint-plugin-unicorn": { - "version": "76.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-76.0.0.tgz", - "integrity": "sha512-3ywJrCMHKYhcBP5yFzGaOOX3dY7+iR3hvpuoS/0EnhRFgVWLejly+t4fOdJvWQxJdHZl1C+QBW7hybXPDld4Pw==", + "version": "77.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-77.0.0.tgz", + "integrity": "sha512-aayznMCKTXlPAsoyUjRok862sz3BJAfxpQX/t79AT/uVQTmWsCx3FXfXo+9hfhL8aR2mMZQYyXoDZo3/ly6lJw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.10.1", - "@eslint/css-tree": "^4.0.5", - "browserslist": "^4.28.8", + "@eslint/css-tree": "^4.1.1", + "browserslist": "^4.29.1", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.50.0", "detect-indent": "^7.0.2", - "entities": "^8.0.0", + "entities": "^8.1.0", "find-up-simple": "^1.0.1", - "globals": "^17.11.0", + "globals": "^17.12.0", "identifier-regex": "^1.1.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "pluralize": "^8.0.0", "quote-js-string": "^0.1.0", - "regjsparser": "^0.13.2", + "regjsparser": "^0.13.3", "reserved-identifiers": "^1.2.0", "semver": "^7.8.5", "strip-indent": "^4.1.1", - "yaml": "^2.9.0" + "yaml": "^2.9.1" }, "engines": { "node": ">=22" diff --git a/frontend/package.json b/frontend/package.json index 8ac40702f..245e24745 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -90,7 +90,7 @@ "eslint-plugin-security": "^4.2.0", "eslint-plugin-sonarjs": "^4.2.2", "eslint-plugin-testing-library": "^7.16.2", - "eslint-plugin-unicorn": "^76.0.0", + "eslint-plugin-unicorn": "^77.0.0", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "30.0.1", "knip": "^6.39.0", diff --git a/scripts/npm/frontend/eslint-plugin-unicorn.sh b/scripts/npm/frontend/eslint-plugin-unicorn.sh new file mode 100644 index 000000000..21b66a4be --- /dev/null +++ b/scripts/npm/frontend/eslint-plugin-unicorn.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +NPM_MODULES=( + "$REPO_ROOT/frontend" + ) + +for MODULE in "${NPM_MODULES[@]}"; do + echo "============================================================================" + echo "Updating: $MODULE" + echo "============================================================================" + + cd "$MODULE" || exit 1 + npm install eslint-plugin-unicorn@latest --save-dev +done From faea809db3684b6aa951bb0785598b3151588501 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 01:37:48 +0000 Subject: [PATCH 35/57] chore(deps): bump lucide-react from 1.50.0 to 1.51.0 --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fe473e64d..b6edd2522 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "date-fns": "^4.4.0", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.50.0", + "lucide-react": "^1.51.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-hook-form": "^7.89.0", @@ -8510,9 +8510,9 @@ } }, "node_modules/lucide-react": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.50.0.tgz", - "integrity": "sha512-RqHPQtKX6S9IE1xOL4oTHHsVQbcwJWTnmlxI6AdI9IpOwKiy7q+COPCQyMepI/Qu1x3h7sunpyEMvqtgFbgNiQ==", + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz", + "integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==", "license": "ISC", "peerDependencies": { "@types/react": "*", diff --git a/frontend/package.json b/frontend/package.json index 245e24745..4c926fdce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,7 +43,7 @@ "date-fns": "^4.4.0", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.50.0", + "lucide-react": "^1.51.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-hook-form": "^7.89.0", From 9353d7e9e3a5bded709b116f5aa4276895858686 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 01:56:30 +0000 Subject: [PATCH 36/57] chore(deps): override sitemap and uuid in docs site --- docs-site/package-lock.json | 44 +++++++++++++++++++++++-------------- docs-site/package.json | 4 +++- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index a01d794ec..c058a75b5 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -18386,28 +18386,37 @@ "license": "MIT" }, "node_modules/sitemap": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.3.tgz", - "integrity": "sha512-tAjEd+wt/YwnEbfNB2ht51ybBJxbEWwe5ki/Z//Wh0rpBFTCUSj46GnxUKEWzhfuJTsee8x3lybHxFgUMig2hw==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", "license": "MIT", "dependencies": { - "@types/node": "^17.0.5", + "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", - "sax": "^1.2.4" + "sax": "^1.4.1" }, "bin": { - "sitemap": "dist/cli.js" + "sitemap": "dist/esm/cli.js" }, "engines": { - "node": ">=12.0.0", - "npm": ">=5.6.0" + "node": ">=20.19.5", + "npm": ">=10.8.2" } }, "node_modules/sitemap/node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/sitemap/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/skin-tone": { @@ -19615,13 +19624,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/value-equal": { diff --git a/docs-site/package.json b/docs-site/package.json index e4bac5acf..9e73842a3 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -37,7 +37,9 @@ "typescript": "~6.0.3" }, "overrides": { - "serialize-javascript": "^7.1.2" + "serialize-javascript": "^7.1.2", + "sitemap": "^9.0.1", + "uuid": "^11.1.1" }, "engines": { "node": "^24.21.0" From b70837d5f317f4ebba9cdee431b2c2a720ed7bee Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 02:02:11 +0000 Subject: [PATCH 37/57] docs: fix broken links to contributor-only pages in the docs site --- docs/features.md | 2 +- docs/features/plugin-security.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features.md b/docs/features.md index 5ae8a19a6..50e16bfe2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -298,7 +298,7 @@ Know immediately when something goes wrong. Charon continuously monitors your ap → [Learn More](features/uptime-monitoring.md) -For contributors: how the uptime summary queries behave on large installs is measured in [Database performance](performance/database.md). +For contributors: how the uptime summary queries behave on large installs is measured in [Database performance](https://github.com/Wikid82/Charon/blob/main/docs/performance/database.md). ### 🗄️ Automatic Database Maintenance diff --git a/docs/features/plugin-security.md b/docs/features/plugin-security.md index 8c5bc222c..a5ac4404b 100644 --- a/docs/features/plugin-security.md +++ b/docs/features/plugin-security.md @@ -87,7 +87,7 @@ export CHARON_PLUGIN_SIGNATURES='{"myplugin": "sha256:a1b2c3d4e5f6g7h8i9j0k1l2m3 ## Building Compatible Plugins -A plugin only loads if it was built the same way as Charon itself: `CGO_ENABLED=1 go build -buildmode=plugin`, with no `-trimpath`, `-race` or `-cover`, using the same Go version and the same dependency versions as the Charon binary. Rebuilding always produces a new checksum, so update your allowlist afterwards. See the [plugin development guide](../development/plugin-development.md#building-plugins). +A plugin only loads if it was built the same way as Charon itself: `CGO_ENABLED=1 go build -buildmode=plugin`, with no `-trimpath`, `-race` or `-cover`, using the same Go version and the same dependency versions as the Charon binary. Rebuilding always produces a new checksum, so update your allowlist afterwards. See the [plugin development guide](https://github.com/Wikid82/Charon/blob/main/docs/development/plugin-development.md#building-plugins). ## Outgoing Connections From dc66f2c95852bf4a0c0238d46aa642d1e278e758 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 02:02:11 +0000 Subject: [PATCH 38/57] chore(deps): allowlist unpatched braces and http-cache-semantics advisories in docs site --- docs-site/audit-ci.json | 5 ++++- docs-site/package.json | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs-site/audit-ci.json b/docs-site/audit-ci.json index 109318976..8d7618979 100644 --- a/docs-site/audit-ci.json +++ b/docs-site/audit-ci.json @@ -1,5 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/IBM/audit-ci/main/docs/schema.json", "high": true, - "allowlist": [] + "allowlist": [ + "GHSA-vfj7-8cjw-p6xm", + "GHSA-ch52-4w7c-c8xp" + ] } diff --git a/docs-site/package.json b/docs-site/package.json index 9e73842a3..b76717627 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -39,7 +39,9 @@ "overrides": { "serialize-javascript": "^7.1.2", "sitemap": "^9.0.1", - "uuid": "^11.1.1" + "uuid": "^11.1.1", + "braces": "^3.0.3", + "http-cache-semantics": "^4.2.0" }, "engines": { "node": "^24.21.0" From 7eb6181c04af5bf2364e95c1efa6024f558918b9 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 02:15:30 +0000 Subject: [PATCH 39/57] fix(security): pin pgproto3/v2 to v2.3.3 in crowdsec build Add a go get step for github.com/jackc/pgproto3/v2@v2.3.3 before go mod tidy to address CVE-2026-32286, a buffer overflow in pgproto3/v2. The bundled crowdsec and cscli binaries otherwise resolve to a vulnerable version via MVS. --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 875931339..d4c8f89a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -878,6 +878,9 @@ RUN set -e; \ # the transparency log). Affects /usr/local/bin/crowdsec and /usr/local/bin/cscli — go mod # tidy's MVS resolution otherwise lands on v0.38.0. Fix available at v0.40.0. # renovate: datasource=go depName=golang.org/x/mod + # CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch) + # renovate: datasource=go depName=github.com/jackc/pgproto3/v2 + _retry go get github.com/jackc/pgproto3/v2@v2.3.3; \ _retry go get golang.org/x/mod@v0.40.0; \ _retry go mod tidy From 937a7199384e8e9498084905d215dce5ba91336a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:18:00 +0000 Subject: [PATCH 40/57] chore: update dependency uuid to v14 --- docs-site/package-lock.json | 8 ++++---- docs-site/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index c058a75b5..8ffc76136 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -19624,16 +19624,16 @@ } }, "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/value-equal": { diff --git a/docs-site/package.json b/docs-site/package.json index b76717627..1728266ad 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -39,7 +39,7 @@ "overrides": { "serialize-javascript": "^7.1.2", "sitemap": "^9.0.1", - "uuid": "^11.1.1", + "uuid": "^14.0.2", "braces": "^3.0.3", "http-cache-semantics": "^4.2.0" }, From 3d88540a70be9382706afc1814f972bf7b0dd50f Mon Sep 17 00:00:00 2001 From: Wikid82 <176516789+Wikid82@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:31:02 +0000 Subject: [PATCH 41/57] chore(docker): refresh bundled proxy toolchain image Rebuilds the prebuilt Caddy/CrowdSec toolchain image so the shipped binaries pick up upstream fixes, and bumps the digest pin in the Dockerfile. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d4c8f89a4..85d627524 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,8 @@ ARG CHARON_TOOLCHAIN_IMAGE=ghcr.io/wikid82/charon-toolchain # NOT Renovate-tracked (a content-hash tag has no series to follow, N7) — the # toolchain-image.yml bot owns these two lines. DIGEST is the arch-independent # manifest-list (OCI index) digest, so one pin covers linux/amd64 + linux/arm64. -ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-afa4dcd430cdd6fd -ARG CHARON_TOOLCHAIN_DIGEST=sha256:789e1264534fd18d94b961963778a62747825fab521fe7f94a7137b138d7f320 +ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-7f3fa653939a4329 +ARG CHARON_TOOLCHAIN_DIGEST=sha256:98fcfdb72d9159209de0cebffde76b1ae439f8fb074e1cc047d52a660781bc3f # Stage selector — default consumes the prebuilt toolchain image (no compile). # Fork PRs / bootstrap / offline builds pass From 78b0026ab313b78d84541d8d2ec0734ff9f4cfcc Mon Sep 17 00:00:00 2001 From: Wikid82 <176516789+Wikid82@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:51:32 +0000 Subject: [PATCH 42/57] chore(docker): update GeoLite2-Country.mmdb checksum Automated checksum update for GeoLite2-Country.mmdb database. Old: aa10ad6c6dc7daa32344954a9bdfae83d8e791540b7d17f7d06086aeb5b630cc New: ff539785596f72ac2a07048f08506b38134bcf3b568182fd65f4534817bf91d7 Auto-generated by: .github/workflows/update-geolite2.yml --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 85d627524..cee03d93c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -980,7 +980,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"] # Note: In production, users should provide their own MaxMind license key # This uses the publicly available GeoLite2 database # In CI, timeout quickly rather than retrying to save build time -ARG GEOLITE2_COUNTRY_SHA256=aa10ad6c6dc7daa32344954a9bdfae83d8e791540b7d17f7d06086aeb5b630cc +ARG GEOLITE2_COUNTRY_SHA256=ff539785596f72ac2a07048f08506b38134bcf3b568182fd65f4534817bf91d7 RUN mkdir -p /app/data/geoip && \ if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \ echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \ From 07a8e68eb4442749ef896d4a3bc3a7eae6e93b1f Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 02:52:09 +0000 Subject: [PATCH 43/57] chore(ci): stop persisting git credentials in test and scan workflow checkouts --- .github/workflows/benchmark.yml | 1 + .github/workflows/codecov-upload.yml | 3 +++ .github/workflows/codeql.yml | 1 + .github/workflows/docker-lint.yml | 2 ++ .github/workflows/e2e-tests-split.yml | 7 +++++++ .github/workflows/integration-tests.yml | 12 ++++++++++++ .github/workflows/pr-checklist.yml | 2 ++ .github/workflows/quality-checks.yml | 13 +++++++++++++ .github/workflows/repo-health.yml | 1 + .github/workflows/semgrep.yml | 1 + .github/workflows/supply-chain-pr.yml | 2 ++ .github/workflows/supply-chain-verify.yml | 6 ++++++ 12 files changed, 51 insertions(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 49a060009..7d5934161 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -32,6 +32,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false # For pull_request events, use the branch HEAD SHA (not the ephemeral merge # commit that github.sha resolves to), which is directly fetchable by SHA. # For workflow_run events fall back to the triggering HEAD SHA. diff --git a/.github/workflows/codecov-upload.yml b/.github/workflows/codecov-upload.yml index 2fb26dd36..500280e76 100644 --- a/.github/workflows/codecov-upload.yml +++ b/.github/workflows/codecov-upload.yml @@ -46,6 +46,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} @@ -177,6 +178,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} @@ -214,6 +216,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8344780b0..a70030f49 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,6 +40,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false # Use github.ref (full ref path) instead of github.ref_name: # - push/schedule: resolves to refs/heads/, checking out latest HEAD # - pull_request: resolves to refs/pull//merge, the correct PR merge ref diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml index 79249db37..8849bacf8 100644 --- a/.github/workflows/docker-lint.yml +++ b/.github/workflows/docker-lint.yml @@ -15,6 +15,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Run Hadolint uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0 diff --git a/.github/workflows/e2e-tests-split.yml b/.github/workflows/e2e-tests-split.yml index 38ef0f24a..1f050d192 100644 --- a/.github/workflows/e2e-tests-split.yml +++ b/.github/workflows/e2e-tests-split.yml @@ -176,6 +176,7 @@ jobs: if: steps.resolve-image.outputs.image_source == 'build' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} # Dev/test-only "What's New" changelog fixture injection. Mirrors the @@ -290,6 +291,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} - name: Set up Node.js @@ -547,6 +549,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} - name: Set up Node.js @@ -827,6 +830,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} - name: Set up Node.js @@ -1135,6 +1139,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} - name: Set up Node.js @@ -1406,6 +1411,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} - name: Set up Node.js @@ -1697,6 +1703,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.sha }} - name: Set up Node.js diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 19cafe7b2..4c5f6333a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -35,6 +35,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Build Docker image (Local) uses: ./.github/actions/build-charon-image with: @@ -62,6 +64,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Download Charon image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -163,6 +167,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Download Charon image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -246,6 +252,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Download Charon image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -342,6 +350,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Download Charon image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -425,6 +435,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Download Charon image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/pr-checklist.yml b/.github/workflows/pr-checklist.yml index b9d35fe03..f71792a93 100644 --- a/.github/workflows/pr-checklist.yml +++ b/.github/workflows/pr-checklist.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Validate PR checklist (only for history-rewrite changes) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 16aaa0dcf..eed96965a 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -27,6 +27,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} @@ -63,6 +64,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} @@ -82,6 +84,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Enforce Codecov trigger and comment parity run: | @@ -92,6 +96,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install bats + shellcheck run: | @@ -114,6 +120,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install bats + shellcheck run: | @@ -139,6 +147,8 @@ jobs: packages: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install regctl uses: iarekylew00t/regctl-installer@c2202c17a65fe59371c71ecc169c9e58c3710a15 # v4.0.16 @@ -163,6 +173,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} @@ -382,6 +393,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.sha }} @@ -456,6 +468,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 - name: Repo health check diff --git a/.github/workflows/repo-health.yml b/.github/workflows/repo-health.yml index 9af4dca6e..88bdb9854 100644 --- a/.github/workflows/repo-health.yml +++ b/.github/workflows/repo-health.yml @@ -20,6 +20,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 lfs: true diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index e0c2ed7a1..813478ea9 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -36,6 +36,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.ref }} - name: Configure git safe.directory diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml index a9cf2ced2..d2826cf21 100644 --- a/.github/workflows/supply-chain-pr.yml +++ b/.github/workflows/supply-chain-pr.yml @@ -47,6 +47,8 @@ jobs: - name: Checkout repository # actions/checkout v4.2.2 uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + persist-credentials: false - name: Extract PR number from workflow_run id: pr-number diff --git a/.github/workflows/supply-chain-verify.yml b/.github/workflows/supply-chain-verify.yml index 5ce402369..3e6d82f04 100644 --- a/.github/workflows/supply-chain-verify.yml +++ b/.github/workflows/supply-chain-verify.yml @@ -41,6 +41,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Debug: Log workflow_run context for initial validation (can be removed after confidence) - name: Debug Workflow Run Context @@ -695,6 +697,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -795,6 +799,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 From e4e6947900c02c5f538c336bc66d8071f0f61ea4 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 02:52:27 +0000 Subject: [PATCH 44/57] chore(ci): stop persisting git credentials in build and release workflow checkouts --- .github/workflows/build-offline.yml | 2 ++ .github/workflows/container-prune.yml | 4 ++++ .github/workflows/docker-build.yml | 6 ++++++ .github/workflows/docs-deploy.yml | 2 ++ .github/workflows/orthrus-build.yml | 2 ++ .github/workflows/release-please.yml | 1 + .github/workflows/security-weekly-rebuild.yml | 1 + .github/workflows/update-geolite2.yml | 2 ++ .github/workflows/weekly-nightly-promotion.yml | 1 + 9 files changed, 21 insertions(+) diff --git a/.github/workflows/build-offline.yml b/.github/workflows/build-offline.yml index 28fdf61f5..5863eb6fb 100644 --- a/.github/workflows/build-offline.yml +++ b/.github/workflows/build-offline.yml @@ -56,6 +56,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 diff --git a/.github/workflows/container-prune.yml b/.github/workflows/container-prune.yml index 72a96b13f..bb9144c04 100644 --- a/.github/workflows/container-prune.yml +++ b/.github/workflows/container-prune.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install tools run: | @@ -124,6 +126,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install tools run: | diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 68e922dab..8c578665f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -113,6 +113,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ env.TRIGGER_HEAD_SHA }} fetch-depth: 0 - name: Normalize image name @@ -448,6 +449,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ env.TRIGGER_HEAD_SHA }} - name: Normalize image name run: | @@ -534,6 +536,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ env.TRIGGER_HEAD_SHA }} - name: Normalize image name run: | @@ -647,6 +650,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ env.TRIGGER_HEAD_SHA }} - name: Normalize image name run: | @@ -1199,6 +1203,8 @@ jobs: steps: - name: Checkout repository for Trivy ignore rules uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Normalize image name run: | diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 26ca02767..1affa50d0 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -32,6 +32,8 @@ jobs: # Step 1: Get the code - name: 📥 Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false # Step 2: Set up Node.js - name: 🔧 Set up Node.js diff --git a/.github/workflows/orthrus-build.yml b/.github/workflows/orthrus-build.yml index 03e8560bc..aaf2ee396 100644 --- a/.github/workflows/orthrus-build.yml +++ b/.github/workflows/orthrus-build.yml @@ -66,6 +66,8 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Normalize image name run: | diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b9fa2eaab..7ef1585d2 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -29,6 +29,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - name: Fetch nightly diff --git a/.github/workflows/security-weekly-rebuild.yml b/.github/workflows/security-weekly-rebuild.yml index 5bc846dc2..4c6d26b2f 100644 --- a/.github/workflows/security-weekly-rebuild.yml +++ b/.github/workflows/security-weekly-rebuild.yml @@ -62,6 +62,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ github.ref_name }} - name: Normalize image ref diff --git a/.github/workflows/update-geolite2.yml b/.github/workflows/update-geolite2.yml index c9bc681fd..dbf148b73 100644 --- a/.github/workflows/update-geolite2.yml +++ b/.github/workflows/update-geolite2.yml @@ -15,6 +15,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Download and calculate checksum id: checksum diff --git a/.github/workflows/weekly-nightly-promotion.yml b/.github/workflows/weekly-nightly-promotion.yml index 57b39309a..d3b76daf2 100644 --- a/.github/workflows/weekly-nightly-promotion.yml +++ b/.github/workflows/weekly-nightly-promotion.yml @@ -297,6 +297,7 @@ jobs: - name: Checkout Repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} From bfc19875e39b8cfcb3a577b80177845b24929e67 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 02:53:08 +0000 Subject: [PATCH 45/57] chore(ci): push with an explicit token instead of persisted checkout credentials --- .github/workflows/docs-to-issues.yml | 7 ++++++- .github/workflows/nightly-build.yml | 11 ++++++++++- .github/workflows/toolchain-image.yml | 10 +++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs-to-issues.yml b/.github/workflows/docs-to-issues.yml index 1649ba52c..982752bab 100644 --- a/.github/workflows/docs-to-issues.yml +++ b/.github/workflows/docs-to-issues.yml @@ -47,6 +47,7 @@ jobs: with: fetch-depth: 2 ref: ${{ github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 @@ -346,6 +347,7 @@ jobs: if: steps.process.outputs.created_count != '0' && github.event.inputs.dry_run != 'true' env: BRANCH_NAME: ${{ github.event.workflow_run.head_branch || github.ref_name }} + PUSH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" @@ -353,7 +355,10 @@ jobs: # Removed [skip ci] to allow CI checks to run on PRs # Infinite loop protection: path filter excludes docs/issues/created/** AND github.actor guard prevents bot loops git diff --staged --quiet || git commit -m "chore: move processed issue files to created/" - git push origin "HEAD:refs/heads/${BRANCH_NAME}" + # Authenticate this push with an explicit token (checkout does not persist one). + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 -w0)" + echo "::add-mask::${AUTH_HEADER#AUTHORIZATION: basic }" + git -c http.https://github.com/.extraheader="$AUTH_HEADER" push origin "HEAD:refs/heads/${BRANCH_NAME}" - name: Summary if: always() diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index bca515ca0..362039470 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -50,6 +50,7 @@ jobs: ref: nightly fetch-depth: 0 token: ${{ secrets.CHARON_CI_TRIGGER_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false - name: Configure Git run: | @@ -60,6 +61,7 @@ jobs: id: sync env: HAS_TRIGGER_TOKEN: ${{ secrets.CHARON_CI_TRIGGER_TOKEN != '' }} + PUSH_TOKEN: ${{ secrets.CHARON_CI_TRIGGER_TOKEN || secrets.GITHUB_TOKEN }} run: | # Fetch both branches to ensure we have the latest remote state git fetch origin development @@ -93,7 +95,10 @@ jobs: echo "::warning title=Using GITHUB_TOKEN fallback::Set CHARON_CI_TRIGGER_TOKEN to ensure push-triggered workflows run on nightly." fi # Force push to handle cases where nightly diverged from development - git push --force origin nightly + # Authenticate this push with an explicit token (checkout does not persist one). + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 -w0)" + echo "::add-mask::${AUTH_HEADER#AUTHORIZATION: basic }" + git -c http.https://github.com/.extraheader="$AUTH_HEADER" push --force origin nightly echo "has_changes=true" >> "$GITHUB_OUTPUT" fi @@ -186,6 +191,7 @@ jobs: - name: Checkout nightly branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }} fetch-depth: 0 @@ -442,6 +448,7 @@ jobs: - name: Checkout nightly branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }} fetch-depth: 0 @@ -539,6 +546,7 @@ jobs: - name: Checkout nightly branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }} - name: Set lowercase image name @@ -608,6 +616,7 @@ jobs: - name: Checkout nightly branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }} - name: Set lowercase image name diff --git a/.github/workflows/toolchain-image.yml b/.github/workflows/toolchain-image.yml index 9f4f3f585..26ffe5871 100644 --- a/.github/workflows/toolchain-image.yml +++ b/.github/workflows/toolchain-image.yml @@ -116,6 +116,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false # Non-PR entry points build/verify whichever branch actually # triggered the run (push to development or main, or a # workflow_dispatch/workflow_call fired against a specific ref) — @@ -305,6 +306,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false # Match build-toolchain: on non-PR triggers, check out the same # branch that triggered the run, so the CRITICAL/HIGH gate scores # the image against that branch's own .trivyignore suppressions. @@ -398,12 +400,14 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - name: Sync ARG CHARON_TOOLCHAIN_TAG / DIGEST if stale env: KEY: ${{ needs.build-toolchain.outputs.key }} DIGEST: ${{ needs.build-toolchain.outputs.digest }} HEAD_REF: ${{ github.event.pull_request.head.ref }} + PUSH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail cur_tag="$(grep -E '^ARG CHARON_TOOLCHAIN_TAG=' Dockerfile | cut -d= -f2)" @@ -422,7 +426,10 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Dockerfile git commit -m "chore(docker): sync toolchain image pin to ${KEY}" - git push origin "HEAD:${HEAD_REF}" + # Authenticate this push with an explicit token (checkout does not persist one). + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 -w0)" + echo "::add-mask::${AUTH_HEADER#AUTHORIZATION: basic }" + git -c http.https://github.com/.extraheader="$AUTH_HEADER" push origin "HEAD:${HEAD_REF}" echo "::notice::Toolchain pin synced to ${KEY} @ ${DIGEST}. Re-run the 'Toolchain pin freshness' check (GITHUB_TOKEN pushes do not auto-retrigger PR checks)." # Off the PR path only: if the rebuilt digest differs from the Dockerfile pin, @@ -446,6 +453,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false # Evaluate + bump the triggering branch's Dockerfile pin (this job's # PR targets that same branch as `base:`, computed identically # below). Never runs on pull_request. Mirrors build-toolchain. From 3b4f00b4d82470975d40bb5c1e5a8b1bc354f2cc Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 03:10:14 +0000 Subject: [PATCH 46/57] chore(ci): scope workflow token permissions per job and guard workflow_run against forks --- .github/workflows/docker-build.yml | 2 +- .github/workflows/docs-to-issues.yml | 8 +++++--- .github/workflows/e2e-tests-split.yml | 4 ++++ .github/workflows/propagate-changes.yml | 9 ++++++--- .github/workflows/release-please.yml | 6 ++++-- .github/workflows/renovate_prune.yml | 6 ++++-- .github/workflows/stale-branch-prune.yml | 6 ++++-- .github/workflows/update-geolite2.yml | 8 +++++--- 8 files changed, 33 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 8c578665f..47a6fae95 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -93,7 +93,7 @@ jobs: # Actions' `on:` block cannot reference expressions/env, so there is no shared source). # `nightly` is deliberately excluded — it is not in `push.branches`, so a PR headed at # `nightly` never races a `push`-triggered run of this workflow. - if: ${{ (github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.name == 'Docker Lint' && github.event.workflow_run.path == '.github/workflows/docker-lint.yml')) && (github.event_name != 'pull_request' || !contains(fromJSON('["main","development"]'), github.head_ref)) }} + if: ${{ (github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.name == 'Docker Lint' && github.event.workflow_run.path == '.github/workflows/docker-lint.yml' && github.event.workflow_run.head_repository.full_name == github.repository)) && (github.event_name != 'pull_request' || !contains(fromJSON('["main","development"]'), github.head_ref)) }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/.github/workflows/docs-to-issues.yml b/.github/workflows/docs-to-issues.yml index 982752bab..9802a09d9 100644 --- a/.github/workflows/docs-to-issues.yml +++ b/.github/workflows/docs-to-issues.yml @@ -26,13 +26,15 @@ env: NODE_VERSION: '24.21.0' permissions: - contents: write - issues: write - pull-requests: write + contents: read jobs: convert-docs: name: Convert Markdown to Issues + permissions: + contents: write # push moved issue files back to the branch + issues: write + pull-requests: write runs-on: ubuntu-latest if: >- github.actor != 'github-actions[bot]' && diff --git a/.github/workflows/e2e-tests-split.yml b/.github/workflows/e2e-tests-split.yml index 1f050d192..2f6e760e3 100644 --- a/.github/workflows/e2e-tests-split.yml +++ b/.github/workflows/e2e-tests-split.yml @@ -126,6 +126,10 @@ env: PLAYWRIGHT_DEBUG: '1' CI_LOG_LEVEL: 'verbose' +permissions: + contents: read + packages: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/propagate-changes.yml b/.github/workflows/propagate-changes.yml index 60084f796..8c4497f61 100644 --- a/.github/workflows/propagate-changes.yml +++ b/.github/workflows/propagate-changes.yml @@ -28,18 +28,21 @@ env: NODE_VERSION: '24.21.0' permissions: - contents: write - pull-requests: write - issues: write + contents: read jobs: propagate: name: Create PR to synchronize branches + permissions: + contents: read + pull-requests: write + issues: write runs-on: ubuntu-latest if: >- github.actor != 'github-actions[bot]' && (github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development'))) steps: - name: Set up Node (for github-script) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7ef1585d2..d3cc8c0b4 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -5,8 +5,7 @@ on: branches: [main] permissions: - contents: write - pull-requests: write + contents: read jobs: # Guards against the release-please date-ordering stranding bug (see @@ -46,6 +45,9 @@ jobs: name: Release Please needs: guard-nightly-sequencing if: needs.guard-nightly-sequencing.outputs.defer != 'true' + permissions: + contents: write + pull-requests: write runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 diff --git a/.github/workflows/renovate_prune.yml b/.github/workflows/renovate_prune.yml index 09f632336..8abec3530 100644 --- a/.github/workflows/renovate_prune.yml +++ b/.github/workflows/renovate_prune.yml @@ -6,11 +6,13 @@ on: - cron: '0 3 * * *' # daily at 03:00 UTC permissions: - contents: write # required to delete branch refs - pull-requests: read + contents: read jobs: prune: + permissions: + contents: write # required to delete branch refs + pull-requests: read runs-on: ubuntu-latest concurrency: group: prune-renovate-branches diff --git a/.github/workflows/stale-branch-prune.yml b/.github/workflows/stale-branch-prune.yml index fc4353641..e7ff66302 100644 --- a/.github/workflows/stale-branch-prune.yml +++ b/.github/workflows/stale-branch-prune.yml @@ -27,8 +27,7 @@ on: type: boolean permissions: - contents: write # required to delete branch refs - pull-requests: read # required to list open PR head branches + contents: read concurrency: group: stale-branch-prune @@ -36,6 +35,9 @@ concurrency: jobs: prune: + permissions: + contents: write # required to delete branch refs + pull-requests: read # required to list open PR head branches runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-geolite2.yml b/.github/workflows/update-geolite2.yml index dbf148b73..84b7e79cc 100644 --- a/.github/workflows/update-geolite2.yml +++ b/.github/workflows/update-geolite2.yml @@ -6,12 +6,14 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write - issues: write + contents: read jobs: update-checksum: + permissions: + contents: write + pull-requests: write + issues: write runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 From d77293e96e3a0495216308855f31f60461bb485e Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 03:19:56 +0000 Subject: [PATCH 47/57] chore(ci): scope package write permissions to the jobs that publish --- .github/workflows/container-prune.yml | 4 +++- .github/workflows/orthrus-build.yml | 4 +++- .github/workflows/toolchain-image.yml | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/container-prune.yml b/.github/workflows/container-prune.yml index bb9144c04..a67b6b072 100644 --- a/.github/workflows/container-prune.yml +++ b/.github/workflows/container-prune.yml @@ -19,11 +19,13 @@ on: default: '30' permissions: - packages: write contents: read jobs: prune-ghcr: + permissions: + contents: read + packages: write # delete old GHCR image versions runs-on: ubuntu-latest if: github.event_name != 'workflow_dispatch' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) strategy: diff --git a/.github/workflows/orthrus-build.yml b/.github/workflows/orthrus-build.yml index aaf2ee396..002fce1d3 100644 --- a/.github/workflows/orthrus-build.yml +++ b/.github/workflows/orthrus-build.yml @@ -43,10 +43,12 @@ env: permissions: contents: read - packages: write jobs: build-and-push: + permissions: + contents: read + packages: write # push the image to GHCR env: HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }} runs-on: ubuntu-latest diff --git a/.github/workflows/toolchain-image.yml b/.github/workflows/toolchain-image.yml index 26ffe5871..8337ebcb0 100644 --- a/.github/workflows/toolchain-image.yml +++ b/.github/workflows/toolchain-image.yml @@ -81,9 +81,6 @@ concurrency: permissions: contents: read - packages: write - security-events: write - pull-requests: write env: TOOLCHAIN_IMAGE: ghcr.io/wikid82/charon-toolchain @@ -95,6 +92,9 @@ env: jobs: build-toolchain: name: Build & publish toolchain image + permissions: + contents: read + packages: write # push the toolchain image to GHCR runs-on: ubuntu-latest timeout-minutes: 45 outputs: From 80aab8643c6974a7bca2541d557070ccc19f0c5f Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 03:28:00 +0000 Subject: [PATCH 48/57] chore(ci): drop workflow_run trigger from PR security scan --- .github/workflows/security-pr.yml | 155 ++++++++---------------------- 1 file changed, 39 insertions(+), 116 deletions(-) diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index 8814dec80..d802332c9 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -1,12 +1,15 @@ # Security Scan for Pull Requests -# Runs Trivy security scanning on PR Docker images after the build workflow completes -# This workflow extracts the charon binary from the container and performs filesystem scanning +# Runs Trivy security scanning on the Docker image built for a PR, a push to main, or a +# manual replay (workflow_dispatch + pr_number, which scans the PR image artifact published by +# docker-build.yml). This workflow extracts the charon binary from the container and performs +# filesystem scanning. +# +# Deliberately NOT triggered by workflow_run: that trigger runs with the default-branch token +# in the context of the upstream run, and would check out and consume PR-built artifacts. +# pull_request builds and scans the same image directly with a read-scoped token. name: Security Scan (PR) on: - workflow_run: - workflows: ["Docker Build, Publish & Test"] - types: [completed] workflow_dispatch: inputs: pr_number: @@ -19,7 +22,7 @@ on: concurrency: - group: security-pr-${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }} + group: security-pr-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true permissions: @@ -31,16 +34,6 @@ jobs: name: Trivy Binary Scan runs-on: ubuntu-latest timeout-minutes: 20 # 20m: same-repo warm build ~6-8m (toolchain image is COPY --from, not compiled); fork PRs compile the toolchain inline (~14m) + scan, which sets the floor (B6). - # Run for manual dispatch, direct PR/push, or successful upstream workflow_run - if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'pull_request' || - github.event_name == 'push' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.head_repository.full_name == github.repository && - github.event.workflow_run.status == 'completed' && - github.event.workflow_run.conclusion == 'success') permissions: contents: read @@ -52,26 +45,17 @@ jobs: REPOSITORY: ${{ github.repository }} PR_EVENT_NUMBER: ${{ github.event.pull_request.number }} INPUT_PR_NUMBER: ${{ inputs.pr_number }} - UPSTREAM_EVENT: ${{ github.event.workflow_run.event }} - UPSTREAM_NAME: ${{ github.event.workflow_run.name }} - UPSTREAM_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} - UPSTREAM_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || '' }} - UPSTREAM_RUN_ID: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || '' }} - CONTEXT_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }} - CONTEXT_BRANCH: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }} + CONTEXT_BRANCH: ${{ github.ref_name }} steps: - name: Checkout repository # actions/checkout v4.2.2 uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 with: - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} persist-credentials: false - - name: Extract PR number from workflow_run + - name: Resolve PR number id: pr-info - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [[ "${EVENT_NAME}" == "push" ]]; then echo "pr_number=" >> "$GITHUB_OUTPUT" @@ -106,66 +90,8 @@ jobs: exit 0 fi - if [[ "${EVENT_NAME}" == "workflow_run" ]]; then - if [[ "${UPSTREAM_EVENT}" != "pull_request" ]]; then - # Explicit contract validation happens in the dedicated guard step. - echo "pr_number=" >> "$GITHUB_OUTPUT" - echo "is_push=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ -n "${UPSTREAM_PR_NUMBER}" ]]; then - echo "pr_number=${UPSTREAM_PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "is_push=false" >> "$GITHUB_OUTPUT" - echo "✅ Found PR number from workflow_run payload: ${UPSTREAM_PR_NUMBER}" - exit 0 - fi - fi - - # Extract PR number from context - HEAD_SHA="${CONTEXT_HEAD_SHA}" - echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}" - - # Query GitHub API for PR associated with this commit - PR_NUMBER=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${REPOSITORY}/commits/${HEAD_SHA}/pulls" \ - --jq '.[0].number // empty' 2>/dev/null || echo "") - - if [[ -n "${PR_NUMBER}" ]]; then - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "is_push=false" >> "$GITHUB_OUTPUT" - echo "✅ Found PR number: ${PR_NUMBER}" - else - echo "❌ Could not determine PR number for workflow_run SHA: ${HEAD_SHA}" - exit 1 - fi - - - name: Validate workflow_run trust boundary and event contract - if: github.event_name == 'workflow_run' - run: | - if [[ "${UPSTREAM_NAME}" != "Docker Build, Publish & Test" ]]; then - echo "❌ reason_category=unexpected_upstream_workflow" - echo "workflow_name=${UPSTREAM_NAME}" - exit 1 - fi - - if [[ "${UPSTREAM_EVENT}" != "pull_request" ]]; then - echo "❌ reason_category=unsupported_upstream_event" - echo "upstream_event=${UPSTREAM_EVENT}" - echo "run_id=${UPSTREAM_RUN_ID}" - exit 1 - fi - - if [[ "${UPSTREAM_HEAD_REPO}" != "${REPOSITORY}" ]]; then - echo "❌ reason_category=untrusted_upstream_repository" - echo "upstream_head_repository=${UPSTREAM_HEAD_REPO}" - echo "expected_repository=${REPOSITORY}" - exit 1 - fi - - echo "✅ workflow_run trust boundary and event contract validated" + echo "❌ Unsupported event: ${EVENT_NAME}" + exit 1 - name: Build Docker image (Local) if: github.event_name == 'push' || github.event_name == 'pull_request' @@ -178,7 +104,7 @@ jobs: - name: Check for PR image artifact id: check-artifact - if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_INFO_NUMBER: ${{ steps.pr-info.outputs.pr_number }} @@ -191,36 +117,33 @@ jobs: fi ARTIFACT_NAME="pr-image-${PR_NUMBER}" - RUN_ID="${UPSTREAM_RUN_ID}" echo "🔍 Checking for artifact: ${ARTIFACT_NAME}" - if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then - # Manual replay path: find latest successful docker-build pull_request run for this PR. - RUNS_JSON=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${REPOSITORY}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1) - RUNS_STATUS=$? - - if [[ ${RUNS_STATUS} -ne 0 ]]; then - echo "❌ reason_category=api_error" - echo "reason=Failed to query workflow runs for PR lookup" - echo "upstream_run_id=unknown" - echo "artifact_name=${ARTIFACT_NAME}" - echo "api_output=${RUNS_JSON}" - exit 1 - fi + # Manual replay path: find latest successful docker-build pull_request run for this PR. + RUNS_JSON=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPOSITORY}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1) + RUNS_STATUS=$? + + if [[ ${RUNS_STATUS} -ne 0 ]]; then + echo "❌ reason_category=api_error" + echo "reason=Failed to query workflow runs for PR lookup" + echo "upstream_run_id=unknown" + echo "artifact_name=${ARTIFACT_NAME}" + echo "api_output=${RUNS_JSON}" + exit 1 + fi - RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1) + RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1) - if [[ -z "${RUN_ID}" ]]; then - echo "❌ reason_category=not_found" - echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}" - echo "upstream_run_id=unknown" - echo "artifact_name=${ARTIFACT_NAME}" - exit 1 - fi + if [[ -z "${RUN_ID}" ]]; then + echo "❌ reason_category=not_found" + echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}" + echo "upstream_run_id=unknown" + echo "artifact_name=${ARTIFACT_NAME}" + exit 1 fi echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" @@ -260,7 +183,7 @@ jobs: echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})" - name: Download PR image artifact - if: (github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch') && steps.check-artifact.outputs.artifact_exists == 'true' + if: github.event_name == 'workflow_dispatch' && steps.check-artifact.outputs.artifact_exists == 'true' # actions/download-artifact v4.1.8 uses: actions/download-artifact@484a0b528fb4d7bd804637ccb632e47a0e638317 with: @@ -269,7 +192,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Load Docker image - if: (github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch') && steps.check-artifact.outputs.artifact_exists == 'true' + if: github.event_name == 'workflow_dispatch' && steps.check-artifact.outputs.artifact_exists == 'true' id: load-image run: | echo "📦 Loading Docker image..." @@ -353,7 +276,7 @@ jobs: exit 0 fi - # For workflow_run artifact path, always use locally tagged image from loaded artifact. + # workflow_dispatch artifact path: always use the locally tagged image from the loaded artifact. IMAGE_REF="${LOADED_IMAGE_REF}" if [[ -z "${IMAGE_REF}" ]]; then echo "❌ ERROR: Loaded artifact image reference is empty" From 4a714509c95a9807d03903d0793798575ef9c7df Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 03:44:03 +0000 Subject: [PATCH 49/57] chore: move serialize-javascript script to docs-site directory --- scripts/npm/{overrides => docs-site}/serialize-javascript.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/npm/{overrides => docs-site}/serialize-javascript.sh (100%) diff --git a/scripts/npm/overrides/serialize-javascript.sh b/scripts/npm/docs-site/serialize-javascript.sh similarity index 100% rename from scripts/npm/overrides/serialize-javascript.sh rename to scripts/npm/docs-site/serialize-javascript.sh From b4dac5a78232256d2e3c46d5c3d6bf61f3ac8f3e Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sun, 4 Oct 2026 03:44:32 +0000 Subject: [PATCH 50/57] chore(deps): bump http-cache-semantics override to 4.3.0 Update the docs-site npm override and lockfile to http-cache-semantics 4.3.0. Add scripts/npm/docs-site/http-cache-semantics.sh to automate the bump. It refuses to add an override that is not already declared and preserves the existing range prefix. --- docs-site/package-lock.json | 6 ++-- docs-site/package.json | 2 +- scripts/npm/docs-site/http-cache-semantics.sh | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 scripts/npm/docs-site/http-cache-semantics.sh diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index 8ffc76136..766f81037 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -10875,9 +10875,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/http-deceiver": { diff --git a/docs-site/package.json b/docs-site/package.json index 1728266ad..ff383ca14 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -41,7 +41,7 @@ "sitemap": "^9.0.1", "uuid": "^14.0.2", "braces": "^3.0.3", - "http-cache-semantics": "^4.2.0" + "http-cache-semantics": "^4.3.0" }, "engines": { "node": "^24.21.0" diff --git a/scripts/npm/docs-site/http-cache-semantics.sh b/scripts/npm/docs-site/http-cache-semantics.sh new file mode 100644 index 000000000..5f0917012 --- /dev/null +++ b/scripts/npm/docs-site/http-cache-semantics.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +PACKAGE="http-cache-semantics" + +# Only modules that already declare this override. `npm pkg get` prints "{}" +# for a missing key, so it cannot be used as an existence check. +NPM_MODULES=( + "$REPO_ROOT/docs-site" + ) + +for MODULE in "${NPM_MODULES[@]}"; do + echo "============================================================================" + echo "Updating: $MODULE" + echo "============================================================================" + + cd "$MODULE" || exit 1 + + CURRENT="$(node -p "(require('./package.json').overrides || {})['$PACKAGE'] || ''")" + if [ -z "$CURRENT" ]; then + echo "No overrides.$PACKAGE in $MODULE; refusing to add one." >&2 + exit 1 + fi + + # Preserve the existing range prefix (^ or ~). + PREFIX="$(echo "$CURRENT" | grep -o '^[\^~]' || true)" + LATEST="$(npm view "$PACKAGE" version)" + npm pkg set "overrides.$PACKAGE=${PREFIX}${LATEST}" + npm install +done From 4e5c7b5199cfd8a61715722b61d3acc6a6b429a3 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Sun, 4 Oct 2026 09:00:37 -0400 Subject: [PATCH 51/57] chore(deps): bump lucide-react from 1.51.0 to 1.52.0 --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6edd2522..32eced02f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "date-fns": "^4.4.0", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.51.0", + "lucide-react": "^1.52.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-hook-form": "^7.89.0", @@ -8510,9 +8510,9 @@ } }, "node_modules/lucide-react": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz", - "integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", "license": "ISC", "peerDependencies": { "@types/react": "*", diff --git a/frontend/package.json b/frontend/package.json index 4c926fdce..deb3b05dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,7 +43,7 @@ "date-fns": "^4.4.0", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.51.0", + "lucide-react": "^1.52.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-hook-form": "^7.89.0", From 518526beb1771feefb5f068cc8b5b2e0fd8edb8a Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Mon, 5 Oct 2026 00:10:54 +0000 Subject: [PATCH 52/57] chore(docs-site): override feed to ^6.0.0 and add update script Pin the transitive `feed` dependency to ^6.0.0 via npm overrides and refresh the lockfile. Add scripts/npm/docs-site/feed.sh to bump the override to the latest release, refusing to run in modules that don't already declare it. --- docs-site/package-lock.json | 9 +++++---- docs-site/package.json | 3 ++- scripts/npm/docs-site/feed.sh | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 scripts/npm/docs-site/feed.sh diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index 766f81037..402456f96 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -9948,15 +9948,16 @@ } }, "node_modules/feed": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", - "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/feed/-/feed-6.0.0.tgz", + "integrity": "sha512-rvFOF1POce0ijAKy3tZN5T8PuprgIit/VbMyXJWcoYwcMOTSIJ7NqZb8DTLPJr77yiEO27kidm/fw0kcSIV+ww==", "license": "MIT", "dependencies": { "xml-js": "^1.6.11" }, "engines": { - "node": ">=0.4.0" + "node": ">=20", + "pnpm": ">=10" } }, "node_modules/file-loader": { diff --git a/docs-site/package.json b/docs-site/package.json index ff383ca14..e545e79a2 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -41,7 +41,8 @@ "sitemap": "^9.0.1", "uuid": "^14.0.2", "braces": "^3.0.3", - "http-cache-semantics": "^4.3.0" + "http-cache-semantics": "^4.3.0", + "feed": "^6.0.0" }, "engines": { "node": "^24.21.0" diff --git a/scripts/npm/docs-site/feed.sh b/scripts/npm/docs-site/feed.sh new file mode 100644 index 000000000..92945046a --- /dev/null +++ b/scripts/npm/docs-site/feed.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +PACKAGE="feed" + +# Only modules that already declare this override. `npm pkg get` prints "{}" +# for a missing key, so it cannot be used as an existence check. +NPM_MODULES=( + "$REPO_ROOT/docs-site" + ) + +for MODULE in "${NPM_MODULES[@]}"; do + echo "============================================================================" + echo "Updating: $MODULE" + echo "============================================================================" + + cd "$MODULE" || exit 1 + + CURRENT="$(node -p "(require('./package.json').overrides || {})['$PACKAGE'] || ''")" + if [ -z "$CURRENT" ]; then + echo "No overrides.$PACKAGE in $MODULE; refusing to add one." >&2 + exit 1 + fi + + # Preserve the existing range prefix (^ or ~). + PREFIX="$(echo "$CURRENT" | grep -o '^[\^~]' || true)" + LATEST="$(npm view "$PACKAGE" version)" + npm pkg set "overrides.$PACKAGE=${PREFIX}${LATEST}" + npm install +done From 735549c7db82bf17a9d084f39036f11e4d4ec782 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Mon, 5 Oct 2026 06:02:49 +0000 Subject: [PATCH 53/57] docs: add Aikido security audit report badge to README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index e718d0c49..1be489139 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ MIT License

+

+ + Aikido Security Audit Report + +

--- From c1f9bf0d08859d229daa8a7d3449f7328de7f410 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Mon, 5 Oct 2026 01:50:49 -0400 Subject: [PATCH 54/57] docs: replace example emergency tokens with placeholders --- docs/configuration/emergency-setup.md | 4 ++-- docs/getting-started.md | 4 ++-- docs/implementation/e2e_remediation_complete.md | 8 ++++---- .../phase1_emergency_token_investigation_COMPLETE.md | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/configuration/emergency-setup.md b/docs/configuration/emergency-setup.md index c3343815d..813e7eb5f 100644 --- a/docs/configuration/emergency-setup.md +++ b/docs/configuration/emergency-setup.md @@ -52,11 +52,11 @@ python3 -c "import secrets; print(secrets.token_hex(32))" head -c 32 /dev/urandom | xxd -p -c 64 ``` -**Example:** +**Example** (generate your own token with `openssl rand -hex 32`; never reuse an example value): ```yaml environment: - - CHARON_EMERGENCY_TOKEN=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 + - CHARON_EMERGENCY_TOKEN= ``` **Validation:** diff --git a/docs/getting-started.md b/docs/getting-started.md index 2a2e9d97c..5e02da04f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -256,10 +256,10 @@ Add to `.env` file in project root: CHARON_EMERGENCY_TOKEN= ``` -**Example:** +**Example** (generate your own token with `openssl rand -hex 32`; never reuse an example value): ```bash -CHARON_EMERGENCY_TOKEN=7b3b8a36a6fad839f1b3122131ed4b1f05453118a91b53346482415796e740e2 +CHARON_EMERGENCY_TOKEN= ``` **Verify:** diff --git a/docs/implementation/e2e_remediation_complete.md b/docs/implementation/e2e_remediation_complete.md index 6351e494f..065a06b73 100644 --- a/docs/implementation/e2e_remediation_complete.md +++ b/docs/implementation/e2e_remediation_complete.md @@ -27,10 +27,10 @@ All 7 tasks from the E2E remediation plan have been successfully implemented wit ```bash # Generated token with openssl openssl rand -hex 32 -# Output: 7b3b8a36a6fad839f1b3122131ed4b1f05453118a91b53346482415796e740e2 +# Output: <64-character hex string> # Added to .env file -CHARON_EMERGENCY_TOKEN=7b3b8a36a6fad839f1b3122131ed4b1f05453118a91b53346482415796e740e2 +CHARON_EMERGENCY_TOKEN= ``` **Validation:** @@ -39,7 +39,7 @@ $ echo -n "$(grep CHARON_EMERGENCY_TOKEN .env | cut -d= -f2)" | wc -c 64 ✅ Correct length $ cat .env | grep CHARON_EMERGENCY_TOKEN -CHARON_EMERGENCY_TOKEN=7b3b8a36a6fad839f1b3122131ed4b1f05453118a91b53346482415796e740e2 +CHARON_EMERGENCY_TOKEN= ✅ Token present in .env file ``` @@ -601,7 +601,7 @@ $ echo -n "$(grep CHARON_EMERGENCY_TOKEN .env | cut -d= -f2)" | wc -c 64 ✅ PASS $ grep CHARON_EMERGENCY_TOKEN .env -CHARON_EMERGENCY_TOKEN=7b3b8a36a6fad839f1b3122131ed4b1f05453118a91b53346482415796e740e2 +CHARON_EMERGENCY_TOKEN= ✅ PASS ``` diff --git a/docs/implementation/phase1_emergency_token_investigation_COMPLETE.md b/docs/implementation/phase1_emergency_token_investigation_COMPLETE.md index 9ab13a02d..f4420200b 100644 --- a/docs/implementation/phase1_emergency_token_investigation_COMPLETE.md +++ b/docs/implementation/phase1_emergency_token_investigation_COMPLETE.md @@ -79,7 +79,7 @@ func redactToken(token string) string { ```bash $ docker exec charon-e2e env | grep CHARON_EMERGENCY -CHARON_EMERGENCY_TOKEN=f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b +CHARON_EMERGENCY_TOKEN= CHARON_EMERGENCY_SERVER_ENABLED=true CHARON_EMERGENCY_BIND=0.0.0.0:2020 CHARON_EMERGENCY_USERNAME=admin @@ -118,7 +118,7 @@ $ docker logs charon-e2e 2>&1 | grep -i emergency ```bash $ curl -X POST http://localhost:2020/emergency/security-reset \ -u admin:changeme \ - -H "X-Emergency-Token: f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b" \ + -H "X-Emergency-Token: " \ -v < HTTP/1.1 200 OK @@ -131,7 +131,7 @@ $ curl -X POST http://localhost:2020/emergency/security-reset \ ```bash $ curl -X POST http://localhost:8080/api/v1/emergency/security-reset \ - -H "X-Emergency-Token: f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b" \ + -H "X-Emergency-Token: " \ -H "Content-Type: application/json" \ -d '{"reason": "Testing"}' From 82cc160bea399c2463037bb04183b01e4194c95e Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Mon, 5 Oct 2026 01:50:50 -0400 Subject: [PATCH 55/57] test: use obviously fake token in emergency server redaction test --- backend/internal/server/emergency_server_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/emergency_server_test.go b/backend/internal/server/emergency_server_test.go index 841f4100d..6b23d660e 100644 --- a/backend/internal/server/emergency_server_test.go +++ b/backend/internal/server/emergency_server_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "strings" "testing" "time" @@ -425,8 +426,8 @@ func TestEmergencyServer_TokenRedaction(t *testing.T) { }, { //nolint:gosec // test fixture demonstrating token masking format name: "ValidToken", - token: "f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b", - expected: "[EMERGENCY_TOKEN:f51d...346b]", + token: strings.Repeat("a", 64), + expected: "[EMERGENCY_TOKEN:aaaa...aaaa]", }, } From 4da6d8696bdfb7a9847a43cb504f1ee560e4c456 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Mon, 5 Oct 2026 02:07:42 -0400 Subject: [PATCH 56/57] docs: scrub example emergency token from archived docs --- docs/plans/archive/e2e_emergency_token_fix.md | 4 ++-- docs/reports/archive/e2e_validation_report.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/archive/e2e_emergency_token_fix.md b/docs/plans/archive/e2e_emergency_token_fix.md index 440171c8a..596a9c755 100644 --- a/docs/plans/archive/e2e_emergency_token_fix.md +++ b/docs/plans/archive/e2e_emergency_token_fix.md @@ -24,7 +24,7 @@ The backend emergency token endpoint returns: **But the environment variable IS set:** ```bash $ docker exec charon-e2e env | grep CHARON_EMERGENCY_TOKEN -CHARON_EMERGENCY_TOKEN=f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b +CHARON_EMERGENCY_TOKEN= ``` **Impact**: @@ -279,7 +279,7 @@ docker exec charon-e2e env | grep CHARON_ # Should return 200 OK curl -X POST http://localhost:2020/emergency/security-reset \ -H "Authorization: Basic YWRtaW46Y2hhbmdlbWU=" \ - -H "X-Emergency-Token: f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b" + -H "X-Emergency-Token: " # Should return 401 Unauthorized curl -X POST http://localhost:2020/emergency/security-reset \ diff --git a/docs/reports/archive/e2e_validation_report.md b/docs/reports/archive/e2e_validation_report.md index ba610f337..70a44f893 100644 --- a/docs/reports/archive/e2e_validation_report.md +++ b/docs/reports/archive/e2e_validation_report.md @@ -139,7 +139,7 @@ grep CHARON_EMERGENCY_TOKEN .env # 2. Test emergency reset manually curl -u admin:changeme \ - -H "X-Emergency-Token: f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b" \ + -H "X-Emergency-Token: " \ -X POST http://localhost:2020/emergency/security-reset \ -H "Content-Type: application/json" \ -d '{"reason":"Manual validation"}' From 68a8d2f77667442d605c426fa0887c785e52820e Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Mon, 5 Oct 2026 02:07:43 -0400 Subject: [PATCH 57/57] chore: remove Aikido API key from tracked Claude settings --- .claude/settings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 07013c085..d808f4670 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,5 @@ "semgrep@claude-plugins-official": true, "pr-review-toolkit@claude-plugins-official": true, "aikido@claude-plugins-official": true - }, - "env": { "AIKIDO_API_KEY": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhaWtpZG8uZGV2IiwiYXVkIjoiaWRlLmFpa2lkbyIsImlhdCI6MTc5MTA1MDg0MSwibmJmIjoxNzkxMDUwODMxLCJleHAiOjI1Nzk5NjkyNDEsImlzX2lkZV90b2tlbiI6dHJ1ZSwidXNlcl9pZCI6MjkzNjIxLCJ0b2tlbl9pZCI6NTAxMzUsInJlZ2lvbiI6ImV1In0.bJmRJ1UeI480Q6GEyWrZMWn4wrFGKXJyJl0rFPHWQs0" } + } }