Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,26 @@ CVE-2026-39824
# Review by: 2026-08-08
# exp: 2026-08-08
GO-2026-5932

# GHSA-gcjh-h69q-9w9g: cel-go JSON private fields exposed via NativeTypes/ParseStructTag("json")
# Severity: MEDIUM — Package: github.com/google/cel-go v0.28.1, embedded in /usr/bin/caddy
# ext.NativeTypes(ParseStructTag("json")) does not honor the "json:\"-\"" skip directive: fields
# tagged json:"-" are registered under the literal CEL field name "-" and become readable from any
# CEL expression via dyn(obj)["-"]. Fixed upstream at v0.29.0.
# No safe upgrade path exists yet: bumping cel-go to v0.29.0 breaks Caddy v2.11.4's own
# modules/caddyhttp/celmatcher.go, which calls interpreter.NewCall(id, function, overload,
# []interpreter.Interpretable, impl) — v0.29.0 changed that parameter's type to
# []interpreter.InterpretableV2, a superset interface that additionally requires an
# Exec(*ExecutionFrame) method. This is a genuine source-incompatible breaking change in cel-go
# itself, not a simple version bump, and no Caddy release newer than v2.11.4 (the current latest
# tag as of this writing) exists with celmatcher.go updated for the new interpreter API.
# Exploitability: Caddy's CEL matcher (`expression` directive) only ever evaluates admin-authored,
# static expressions from the Caddyfile/JSON config against Caddy's own fixed request-context
# fields — it does not accept or evaluate arbitrary CEL source submitted by untrusted end users,
# and does not register attacker-controlled native Go struct types via NativeTypes at runtime. The
# advisory's exploit precondition (untrusted CEL expressions querying developer-registered native
# types that carry json:"-" fields) is not met by how Charon's Caddy build actually uses cel-go.
# Review by: 2026-08-22
# See also: Dockerfile caddy-builder stage comment above the (skipped) cel-go pin for the same detail.
# exp: 2026-08-22
GHSA-gcjh-h69q-9w9g
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,15 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# Affects /usr/bin/caddy (transitive via caddy-crowdsec-bouncer -> crowdsec). Fix available at v1.2.0.
# renovate: datasource=go depName=github.com/buger/jsonparser
_retry go get github.com/buger/jsonparser@v1.2.0; \
# GHSA-gcjh-h69q-9w9g: cel-go JSON private fields exposed via NativeTypes/ParseStructTag("json").
# NOT pinned here: bumping to the v0.29.0 fix breaks Caddy v2.11.4's own
# modules/caddyhttp/celmatcher.go, which calls interpreter.NewCall(..., []interpreter.Interpretable, ...) —
# v0.29.0 changed that parameter to []interpreter.InterpretableV2, a superset interface requiring an
# additional Exec(*ExecutionFrame) method, so this is a real source-incompatible break, not a version
# bump. No Caddy release newer than v2.11.4 exists yet with celmatcher.go updated for the new API.
# Suppressed in .trivyignore with full risk justification; see that file for exploitability analysis.
# TODO(renovate): bump github.com/google/cel-go to >= v0.29.0 once Caddy ships a release whose
# celmatcher.go is compatible with the new InterpretableV2 API; remove the .trivyignore entry then.
# CVE-2026-44982 (GHSA-rw47-hm26-6wr7): CrowdSec AppSec silently drops HTTP request
# body for chunked/HTTP-2 requests, bypassing WAF body inspection rules.
# caddy-crowdsec-bouncer@v0.12.1 was built against crowdsec v1.6.3 whose
Expand Down Expand Up @@ -578,6 +587,9 @@ RUN set -e; \
# Fix available at v1.2.0.
# renovate: datasource=go depName=github.com/buger/jsonparser
_retry go get github.com/buger/jsonparser@v1.2.0; \
# GHSA-r277-6w6q-xmqw: kin-openapi ValidationHandler.Load() Fail-Open Authentication Bypass via NoopAuthenticationFunc Default
# renovate: datasource=go depName=github.com/getkin/kin-openapi
_retry go get github.com/getkin/kin-openapi@v0.144.0; \
_retry go mod tidy

# Fix compatibility issues with expr-lang v1.17.7
Expand Down
26 changes: 26 additions & 0 deletions backend/cmd/pending-restore-harness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,32 @@ func boot(dbPath string) int {
return 2
}

// database.Connect below launches its post-connect integrity check
// (PRAGMA quick_check) on its own background goroutine and its own
// separate SQLite connection by default — see database.Connect's
// launchQuickCheck. That goroutine is fire-and-forget: nothing in
// Connect waits for it to finish or for its connection to close.
//
// The real server (cmd/api/main.go) never exits right after Connect, so
// that connection eventually closes (triggering SQLite's WAL checkpoint,
// which removes -wal/-shm) long before the process ever does. This
// harness is different: boot() calls os.Exit shortly after Connect
// returns, with no server loop in between. Without this call, whether
// the goroutine's connection has closed by the time os.Exit runs is a
// race — sometimes -wal/-shm are gone, sometimes they're still on disk,
// which is exactly what made
// TestPendingRestoreBootSwap_AcrossRealProcessBoundary's
// require.NoFileExists(dbPath+"-wal") assertion flaky in CI.
//
// SyncIntegrityCheckForTesting makes the check run synchronously within
// Connect instead, so by the time Connect returns the check's connection
// is already closed — the same fix this package's own TestMain
// (database_test.go) and other callers (cmd/api/main_test.go,
// internal/api/handlers/testmain_test.go) already apply. It only takes
// effect in this process's memory; the outer test binary's TestMain has
// no effect here since this is a separately exec'd process.
database.SyncIntegrityCheckForTesting()

// This is the exact call main.go makes immediately before
// database.Connect — see main.go's comment at the call site for why the
// ordering matters (no live WAL pool must exist when the swap happens).
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/api/handlers/proxy_host_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,15 @@ func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.
}
}

// SetCertificateService wires an optional certificate cache invalidator into
// the underlying proxy host service, so that creating, updating, or deleting
// a proxy host immediately refreshes the certificates list's "in_use" status
// instead of waiting out CertificateService's scan TTL. Safe to leave unset
// in tests/import flows that don't need this.
func (h *ProxyHostHandler) SetCertificateService(certService services.CertificateCacheInvalidator) {
h.service.SetCertificateService(certService)
}

// RegisterRoutes registers proxy host routes.
func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/proxy-hosts", h.List)
Expand Down
48 changes: 48 additions & 0 deletions backend/internal/api/handlers/proxy_host_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2796,3 +2796,51 @@ func TestProxyHostHandler_BulkUpdateGroup_CaddyApplyError(t *testing.T) {
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
require.Contains(t, result["error"].(string), "Failed to apply")
}

// fakeCertCacheInvalidator is a test double satisfying services.CertificateCacheInvalidator.
type fakeCertCacheInvalidator struct {
calls int
}

func (f *fakeCertCacheInvalidator) InvalidateCache() {
f.calls++
}

// TestProxyHostHandler_SetCertificateService_InvalidatesOnCreate verifies the
// handler-level wiring (routes.go calls h.SetCertificateService(certService))
// actually reaches the underlying ProxyHostService and fires on a real
// create request through the HTTP layer, closing the loop on the
// certificates-list "in_use" staleness bug this wiring fixes.
func TestProxyHostHandler_SetCertificateService_InvalidatesOnCreate(t *testing.T) {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.Notification{},
&models.NotificationProvider{},
))

ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, nil, ns, nil)
fake := &fakeCertCacheInvalidator{}
h.SetCertificateService(fake)

r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)

body, _ := json.Marshal(map[string]any{
"domain_names": "cert-invalidation.example.com",
"forward_host": "127.0.0.1",
"forward_port": 8080,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 1, fake.calls, "creating a proxy host via the HTTP handler should invalidate the wired certificate cache")
}
5 changes: 5 additions & 0 deletions backend/internal/api/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,11 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg

// Proxy Hosts & Remote Servers
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
// Keep the certificates list's "in_use" flag fresh: without this, creating,
// updating, or deleting a proxy host's certificate_id link doesn't invalidate
// CertificateService's cache, and the delete-button's disabled/tooltip state
// can be stale for up to the cache's scan TTL (5 minutes).
proxyHostHandler.SetCertificateService(certService)
proxyHostHandler.RegisterRoutes(management)

proxyGroupHandler := handlers.NewProxyGroupHandler(db)
Expand Down
47 changes: 42 additions & 5 deletions backend/internal/services/proxyhost_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,41 @@ import (
"github.com/Wikid82/charon/backend/internal/models"
)

// CertificateCacheInvalidator is the minimal interface ProxyHostService needs
// to keep the certificate list's "in_use" status fresh. Satisfied by
// *CertificateService; kept as an interface (rather than a direct dependency)
// to avoid a hard coupling between the two services.
type CertificateCacheInvalidator interface {
InvalidateCache()
}

// ProxyHostService encapsulates business logic for proxy host management.
type ProxyHostService struct {
db *gorm.DB
db *gorm.DB
certService CertificateCacheInvalidator
}

// NewProxyHostService creates a new proxy host service.
func NewProxyHostService(db *gorm.DB) *ProxyHostService {
return &ProxyHostService{db: db}
}

// SetCertificateService wires an optional certificate cache invalidator.
// When set, creating, updating, or deleting a proxy host invalidates the
// certificate service's cache so the "in_use" flag reflects the change
// immediately instead of waiting out the cache's scan TTL.
func (s *ProxyHostService) SetCertificateService(certService CertificateCacheInvalidator) {
s.certService = certService
}

// invalidateCertCache notifies the certificate service (if wired) that a
// certificate<->proxy-host association may have changed.
func (s *ProxyHostService) invalidateCertCache() {
if s.certService != nil {
s.certService.InvalidateCache()
}
}

// ValidateUniqueDomain ensures no duplicate domains exist before creation/update.
func (s *ProxyHostService) ValidateUniqueDomain(domainNames string, excludeID uint) error {
var count int64
Expand Down Expand Up @@ -175,7 +200,11 @@ func (s *ProxyHostService) Create(host *models.ProxyHost) error {
}
}

return s.db.Create(host).Error
if err := s.db.Create(host).Error; err != nil {
return err
}
s.invalidateCertCache()
return nil
}

// Update validates and updates an existing proxy host.
Expand Down Expand Up @@ -204,15 +233,23 @@ func (s *ProxyHostService) Update(host *models.ProxyHost) error {

// Use Updates to handle nullable foreign keys properly
// Must use Select to explicitly allow setting nullable fields to nil
return s.db.Model(&models.ProxyHost{}).
if err := s.db.Model(&models.ProxyHost{}).
Where("id = ?", host.ID).
Select("*").
Updates(host).Error
Updates(host).Error; err != nil {
return err
}
s.invalidateCertCache()
return nil
}

// Delete removes a proxy host.
func (s *ProxyHostService) Delete(id uint) error {
return s.db.Delete(&models.ProxyHost{}, id).Error
if err := s.db.Delete(&models.ProxyHost{}, id).Error; err != nil {
return err
}
s.invalidateCertCache()
return nil
}

// GetByID retrieves a proxy host by ID.
Expand Down
54 changes: 54 additions & 0 deletions backend/internal/services/proxyhost_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,57 @@ func TestProxyHostService_List_DBError(t *testing.T) {
_, err = service.List()
assert.Error(t, err)
}

// fakeCertCacheInvalidator is a test double for CertificateCacheInvalidator
// that records how many times InvalidateCache was called.
type fakeCertCacheInvalidator struct {
calls int
}

func (f *fakeCertCacheInvalidator) InvalidateCache() {
f.calls++
}

func TestProxyHostService_InvalidatesCertificateCache(t *testing.T) {
t.Parallel()

db := setupProxyHostTestDB(t)
service := NewProxyHostService(db)
fake := &fakeCertCacheInvalidator{}
service.SetCertificateService(fake)

host := &models.ProxyHost{
DomainNames: "cache-invalidation.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8080,
}

require.NoError(t, service.Create(host))
assert.Equal(t, 1, fake.calls, "Create should invalidate the certificate cache")

host.ForwardPort = 8081
require.NoError(t, service.Update(host))
assert.Equal(t, 2, fake.calls, "Update should invalidate the certificate cache")

require.NoError(t, service.Delete(host.ID))
assert.Equal(t, 3, fake.calls, "Delete should invalidate the certificate cache")
}

func TestProxyHostService_NoCertificateService_DoesNotPanic(t *testing.T) {
t.Parallel()

db := setupProxyHostTestDB(t)
service := NewProxyHostService(db)
// No SetCertificateService call — invalidateCertCache must be a no-op, not
// a nil-pointer panic, so import flows that don't wire a cert service keep
// working unchanged.

host := &models.ProxyHost{
DomainNames: "no-cert-service.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8080,
}
require.NoError(t, service.Create(host))
require.NoError(t, service.Update(host))
require.NoError(t, service.Delete(host.ID))
}
Loading
Loading