Skip to content
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,4 +756,4 @@ We recognize security researchers who help improve Charon:

---

**Last Updated**: 2026-03-24
**Last Updated**: 2026-05-18
24 changes: 12 additions & 12 deletions backend/internal/api/handlers/crowdsec_coverage_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,16 +283,16 @@ func TestRegisterBouncerExecutionFailure(t *testing.T) {

// TestGetAcquisitionConfigFileError tests file read error
func TestGetAcquisitionConfigNotPresent(t *testing.T) {
t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", filepath.Join(t.TempDir(), "nonexistent.yaml"))
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody)
r.ServeHTTP(w, req)

// File won't exist in test env, should give 404
require.Equal(t, http.StatusNotFound, w.Code)
t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", filepath.Join(t.TempDir(), "nonexistent.yaml"))
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody)
r.ServeHTTP(w, req)

// File won't exist in test env, should give 404
require.Equal(t, http.StatusNotFound, w.Code)
}
40 changes: 20 additions & 20 deletions backend/internal/api/handlers/crowdsec_stop_lapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,10 @@ func TestGetLAPIDecisions_WithMockServer(t *testing.T) {

secSvc := createTestSecurityService(t, db)
h := &CrowdsecHandler{
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
validateLAPIURL: permissiveLAPIURLValidator,
}

Expand Down Expand Up @@ -210,10 +210,10 @@ func TestGetLAPIDecisions_Unauthorized(t *testing.T) {

secSvc := createTestSecurityService(t, db)
h := &CrowdsecHandler{
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
validateLAPIURL: permissiveLAPIURLValidator,
}

Expand Down Expand Up @@ -245,10 +245,10 @@ func TestGetLAPIDecisions_NullResponse(t *testing.T) {

secSvc := createTestSecurityService(t, db)
h := &CrowdsecHandler{
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
validateLAPIURL: permissiveLAPIURLValidator,
}

Expand Down Expand Up @@ -323,10 +323,10 @@ func TestCheckLAPIHealth_WithMockServer(t *testing.T) {

secSvc := createTestSecurityService(t, db)
h := &CrowdsecHandler{
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
validateLAPIURL: permissiveLAPIURLValidator,
}

Expand Down Expand Up @@ -369,10 +369,10 @@ func TestCheckLAPIHealth_FallbackToDecisions(t *testing.T) {

secSvc := createTestSecurityService(t, db)
h := &CrowdsecHandler{
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
DB: db,
Security: secSvc,
CmdExec: &mockCommandExecutor{},
DataDir: t.TempDir(),
validateLAPIURL: permissiveLAPIURLValidator,
}

Expand Down
2 changes: 0 additions & 2 deletions backend/internal/api/handlers/crowdsec_wave5_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ func TestCrowdsecWave5_GetLAPIDecisions_Unauthorized(t *testing.T) {
}))
t.Cleanup(server.Close)


require.NoError(t, db.Create(&models.SecurityConfig{UUID: "default", CrowdSecAPIURL: server.URL}).Error)

h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
Expand Down Expand Up @@ -63,7 +62,6 @@ func TestCrowdsecWave5_GetLAPIDecisions_NonJSONContentTypeFallsBack(t *testing.T
}))
t.Cleanup(server.Close)


require.NoError(t, db.Create(&models.SecurityConfig{UUID: "default", CrowdSecAPIURL: server.URL}).Error)

h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
Expand Down
39 changes: 36 additions & 3 deletions backend/internal/api/handlers/docker_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"reflect"
"strings"

"github.com/Wikid82/charon/backend/internal/api/middleware"
Expand All @@ -22,9 +23,14 @@ type remoteServerGetter interface {
GetByUUID(uuidStr string) (*models.RemoteServer, error)
}

type orthrusProxyResolver interface {
GetProxyAddr(agentUUID string) (string, bool)
}

type DockerHandler struct {
dockerService dockerContainerLister
remoteServerService remoteServerGetter
orthrusResolver orthrusProxyResolver
}

func NewDockerHandler(dockerService dockerContainerLister, remoteServerService remoteServerGetter) *DockerHandler {
Expand All @@ -34,6 +40,17 @@ func NewDockerHandler(dockerService dockerContainerLister, remoteServerService r
}
}

func (h *DockerHandler) SetOrthrusResolver(r orthrusProxyResolver) {
// Guard against the Go typed-nil trap: a nil *T passed as an interface
// produces a non-nil interface (type descriptor present, data nil), which
// would bypass the h.orthrusResolver == nil guard and panic in GetProxyAddr.
if r == nil || (reflect.ValueOf(r).Kind() == reflect.Ptr && reflect.ValueOf(r).IsNil()) {
h.orthrusResolver = nil
return
}
h.orthrusResolver = r
}

func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/docker/containers", h.ListContainers)
}
Expand Down Expand Up @@ -62,9 +79,25 @@ func (h *DockerHandler) ListContainers(c *gin.Context) {
}

// Construct Docker host string
// Assuming TCP for now as that's what RemoteServer supports (Host/Port)
// TODO: Support SSH if/when RemoteServer supports it
host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)
switch server.ConnectionType {
case models.ConnectionTypeOrthrus:
if h.orthrusResolver == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Orthrus subsystem unavailable"})
return
}
if server.OrthrusAgentUUID == nil || *server.OrthrusAgentUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Remote server has no Orthrus agent UUID configured"})
return
}
proxyAddr, ok := h.orthrusResolver.GetProxyAddr(*server.OrthrusAgentUUID)
if !ok {
c.JSON(http.StatusBadGateway, gin.H{"error": "Orthrus agent is not currently connected"})
return
}
host = "tcp://" + proxyAddr
default:
host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)
}
}

containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
Expand Down
167 changes: 167 additions & 0 deletions backend/internal/api/handlers/docker_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/orthrus"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -392,3 +393,169 @@ func TestDockerHandler_ListContainers_503DetailsWithGroupGuidance(t *testing.T)
assert.Contains(t, w.Body.String(), "--group-add 988")
assert.Contains(t, w.Body.String(), "group_add")
}

type fakeOrthrusResolver struct {
addr string
ok bool
}

func (f *fakeOrthrusResolver) GetProxyAddr(_ string) (string, bool) {
return f.addr, f.ok
}

func TestDockerHandler_ListContainers_OrthrusAgentConnected(t *testing.T) {
router := gin.New()
agentUUID := "agent-uuid-123"
dockerSvc := &fakeDockerService{ret: []services.DockerContainer{}}
remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{
ConnectionType: models.ConnectionTypeOrthrus,
OrthrusAgentUUID: &agentUUID,
}}
h := NewDockerHandler(dockerSvc, remoteSvc)
h.SetOrthrusResolver(&fakeOrthrusResolver{addr: "127.0.0.1:54321", ok: true})

api := router.Group("/api/v1")
h.RegisterRoutes(api)

req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=srv-1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.True(t, dockerSvc.called)
assert.Equal(t, "tcp://127.0.0.1:54321", dockerSvc.host)
assert.Equal(t, http.StatusOK, w.Code)
}

func TestDockerHandler_ListContainers_OrthrusAgentOffline(t *testing.T) {
router := gin.New()
agentUUID := "agent-offline-uuid"
dockerSvc := &fakeDockerService{}
remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{
ConnectionType: models.ConnectionTypeOrthrus,
OrthrusAgentUUID: &agentUUID,
}}
h := NewDockerHandler(dockerSvc, remoteSvc)
h.SetOrthrusResolver(&fakeOrthrusResolver{ok: false})

api := router.Group("/api/v1")
h.RegisterRoutes(api)

req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=srv-1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "Orthrus agent is not currently connected")
assert.False(t, dockerSvc.called)
}

func TestDockerHandler_ListContainers_OrthrusSubsystemUnavailable(t *testing.T) {
router := gin.New()
agentUUID := "agent-uuid-svc"
dockerSvc := &fakeDockerService{}
remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{
ConnectionType: models.ConnectionTypeOrthrus,
OrthrusAgentUUID: &agentUUID,
}}
h := NewDockerHandler(dockerSvc, remoteSvc)
// orthrusResolver intentionally not set (nil)

api := router.Group("/api/v1")
h.RegisterRoutes(api)

req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=srv-1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "Orthrus subsystem unavailable")
assert.False(t, dockerSvc.called)
}

func TestDockerHandler_ListContainers_OrthrusMissingAgentUUID(t *testing.T) {
router := gin.New()
dockerSvc := &fakeDockerService{}
remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{
ConnectionType: models.ConnectionTypeOrthrus,
OrthrusAgentUUID: nil,
}}
h := NewDockerHandler(dockerSvc, remoteSvc)
h.SetOrthrusResolver(&fakeOrthrusResolver{ok: true, addr: "127.0.0.1:1234"})

api := router.Group("/api/v1")
h.RegisterRoutes(api)

req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=srv-1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "no Orthrus agent UUID configured")
assert.False(t, dockerSvc.called)
}

func TestDockerHandler_SetOrthrusResolver_Nil(t *testing.T) {
h := NewDockerHandler(&fakeDockerService{}, &fakeRemoteServerService{})
h.SetOrthrusResolver(nil)
assert.Nil(t, h.orthrusResolver)
}

// TestDockerHandler_ListContainers_OrthrusEmptyAgentUUID verifies that a
// non-nil pointer to an empty string for OrthrusAgentUUID is rejected with 400.
// This covers the right-hand side of the || condition in the UUID guard.
func TestDockerHandler_ListContainers_OrthrusEmptyAgentUUID(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
emptyUUID := ""
dockerSvc := &fakeDockerService{}
remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{
ConnectionType: models.ConnectionTypeOrthrus,
OrthrusAgentUUID: &emptyUUID,
}}
h := NewDockerHandler(dockerSvc, remoteSvc)
h.SetOrthrusResolver(&fakeOrthrusResolver{ok: true, addr: "127.0.0.1:1234"})

api := router.Group("/api/v1")
h.RegisterRoutes(api)

req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=srv-1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "no Orthrus agent UUID configured")
assert.False(t, dockerSvc.called)
}

// TestDockerHandler_SetOrthrusResolver_TypedNil verifies the Go typed-nil trap:
// passing a nil *orthrus.OrthrusServer to SetOrthrusResolver would normally box
// into a non-nil interface (type descriptor present, data pointer nil), which
// defeats the h.orthrusResolver == nil guard and panics inside GetProxyAddr.
// SetOrthrusResolver must normalise such values to a truly-nil interface so the
// 503 path is taken instead.
func TestDockerHandler_SetOrthrusResolver_TypedNil(t *testing.T) {
gin.SetMode(gin.TestMode)

var typedNil *orthrus.OrthrusServer
uuid := "test-agent-uuid"
remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{
ConnectionType: models.ConnectionTypeOrthrus,
OrthrusAgentUUID: &uuid,
}}
h := NewDockerHandler(&fakeDockerService{}, remoteSvc)
h.SetOrthrusResolver(typedNil)

// Resolver must be a truly-nil interface, not a non-nil typed-nil.
assert.Nil(t, h.orthrusResolver)

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

req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=srv-1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req) // must NOT panic

assert.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "Orthrus subsystem unavailable")
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ func TestR6_LegacySecuritySettingsWrite410Gone(t *testing.T) {
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)


// Test canonical endpoint: PUT /api/v1/notifications/settings/security
t.Run("CanonicalEndpoint", func(t *testing.T) {
reqBody := map[string]interface{}{
Expand Down Expand Up @@ -203,7 +202,6 @@ func TestR6_LegacyWrite410GoneNoMutation(t *testing.T) {
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)


// Attempt PUT to canonical endpoint
reqBody := map[string]interface{}{
"security_waf_enabled": true,
Expand Down Expand Up @@ -237,7 +235,6 @@ func TestProviderCRUD_SecurityEventsIncludeCrowdSec(t *testing.T) {
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)


// Test CREATE
t.Run("CreatePersistsCrowdSec", func(t *testing.T) {
reqBody := notificationProviderUpsertRequest{
Expand Down
Loading
Loading