Skip to content
Merged
2 changes: 2 additions & 0 deletions backend/internal/api/handlers/update_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestUpdateHandler_Check(t *testing.T) {
svc := services.NewUpdateService()
err := svc.SetAPIURL(server.URL + "/releases/latest")
assert.NoError(t, err)
svc.SetHTTPClient(server.Client())

// Setup Handler
h := NewUpdateHandler(svc)
Expand Down Expand Up @@ -58,6 +59,7 @@ func TestUpdateHandler_Check(t *testing.T) {
svcError := services.NewUpdateService()
err = svcError.SetAPIURL(serverError.URL)
assert.NoError(t, err)
svcError.SetHTTPClient(serverError.Client())
hError := NewUpdateHandler(svcError)

rError := gin.New()
Expand Down
24 changes: 17 additions & 7 deletions backend/internal/crowdsec/hub_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,18 @@ type HubService struct {
ApplyTimeout time.Duration
}

// hubAllowLoopback is a test-only seam. It is always false in production and is
// only toggled from _test.go files. Tests that toggle it must not call
// t.Parallel(), as it is shared package state.
var hubAllowLoopback bool

// validateHubURL validates a hub URL for security (SSRF protection - HIGH-001).
// This function prevents Server-Side Request Forgery by:
// 1. Enforcing HTTPS for production hub URLs
// 2. Allowlisting known CrowdSec hub domains
// 3. Allowing localhost/test URLs for development and testing
// 1. Enforcing HTTPS for production hub URLs
// 2. Allowlisting known CrowdSec hub domains
// 3. Accepting localhost/test hostnames at this layer; the dial layer
// (network.NewSafeHTTPClient) still blocks loopback and private targets
// unless the test-only hubAllowLoopback seam is set
//
// Returns: error if URL is invalid or not allowlisted
func validateHubURL(rawURL string) error {
Expand Down Expand Up @@ -175,18 +182,21 @@ func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubSe
// Hub URLs are validated by validateHubURL() which:
// - Enforces HTTPS for production
// - Allowlists known CrowdSec domains (hub-data.crowdsec.net, hub.crowdsec.net, raw.githubusercontent.com)
// - Allows localhost for testing
// - Blocks loopback unless the test-only hubAllowLoopback seam is set
// Using network.NewSafeHTTPClient provides defense-in-depth at the connection level.
func newHubHTTPClient(timeout time.Duration) *http.Client {
return network.NewSafeHTTPClient(
opts := []network.Option{
network.WithTimeout(timeout),
network.WithAllowLocalhost(), // Allow localhost for testing
network.WithAllowedDomains(
"hub-data.crowdsec.net",
"hub.crowdsec.net",
"raw.githubusercontent.com",
),
)
}
if hubAllowLoopback {
opts = append(opts, network.WithAllowLocalhost())
}
return network.NewSafeHTTPClient(opts...)
}

func normalizeHubBaseURL(raw string) string {
Expand Down
30 changes: 30 additions & 0 deletions backend/internal/crowdsec/hub_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2511,3 +2511,33 @@ func TestFindIndexEntry_EmptySlug(t *testing.T) {
_, found := findIndexEntry(idx, " ")
require.False(t, found)
}

// setHubAllowLoopbackForTest toggles the package-level seam. Callers must not
// use t.Parallel().
func setHubAllowLoopbackForTest(t *testing.T, v bool) {
t.Helper()
prev := hubAllowLoopback
hubAllowLoopback = v
t.Cleanup(func() { hubAllowLoopback = prev })
}

// Must not run in parallel: relies on shared seam state.
func TestNewHubHTTPClient_LoopbackSeam(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

get := func() error {
resp, err := newHubHTTPClient(2 * time.Second).Get(srv.URL)
if err == nil {
_ = resp.Body.Close()
}
return err
}

require.Error(t, get(), "default client must reject loopback")

setHubAllowLoopbackForTest(t, true)
require.NoError(t, get(), "seam permits loopback in tests")
}
146 changes: 0 additions & 146 deletions backend/internal/crowdsec/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,17 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"strings"
"time"

"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/network"
)

const (
// defaultLAPIURL is the default CrowdSec LAPI URL.
// Port 8085 is used to avoid conflict with Charon management API on port 8080.
defaultLAPIURL = "http://127.0.0.1:8085"
defaultHealthTimeout = 5 * time.Second
defaultRegistrationName = "caddy-bouncer"
)

Expand All @@ -34,12 +28,6 @@ type BouncerRegistration struct {
CreatedAt time.Time `json:"created_at,omitempty"`
}

// LAPIHealthResponse represents the health check response from CrowdSec LAPI.
type LAPIHealthResponse struct {
Message string `json:"message,omitempty"`
Version string `json:"version,omitempty"`
}

// validateLAPIURL validates a CrowdSec LAPI URL for security (SSRF protection - MEDIUM-001).
// CrowdSec LAPI typically runs on localhost or within an internal network.
// This function ensures the URL:
Expand Down Expand Up @@ -120,140 +108,6 @@ func EnsureBouncerRegistered(ctx context.Context, lapiURL string) (string, error
return registerBouncer(ctx, defaultRegistrationName)
}

// CheckLAPIHealth verifies CrowdSec LAPI is responding.
func CheckLAPIHealth(lapiURL string) bool {
if lapiURL == "" {
lapiURL = defaultLAPIURL
}

ctx, cancel := context.WithTimeout(context.Background(), defaultHealthTimeout)
defer cancel()

// Try the /health endpoint first (standard LAPI health check)
healthURL := strings.TrimRight(lapiURL, "/") + "/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, http.NoBody)
if err != nil {
return false
}

// Use SSRF-safe HTTP client with localhost allowed (LAPI is localhost-only)
client := network.NewSafeHTTPClient(
network.WithTimeout(defaultHealthTimeout),
network.WithAllowLocalhost(), // LAPI validated to be localhost only
)
resp, err := client.Do(req)
if err != nil {
// Fallback: try the /v1/decisions endpoint with a HEAD request
return checkDecisionsEndpoint(ctx, lapiURL)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
logger.Log().WithError(closeErr).Warn("Failed to close response body")
}
}()

// Check content-type to ensure we're getting JSON from actual LAPI (not HTML from frontend)
contentType := resp.Header.Get("Content-Type")
if contentType != "" && !strings.Contains(contentType, "application/json") {
// Not JSON response, likely hitting a frontend/proxy
return false
}

// LAPI returns 200 OK for healthy status
if resp.StatusCode == http.StatusOK {
return true
}

// If health endpoint returned non-OK, try decisions endpoint fallback
if resp.StatusCode == http.StatusNotFound {
return checkDecisionsEndpoint(ctx, lapiURL)
}

return false
}

// GetLAPIVersion retrieves the CrowdSec LAPI version.
func GetLAPIVersion(ctx context.Context, lapiURL string) (string, error) {
if lapiURL == "" {
lapiURL = defaultLAPIURL
}

versionURL := strings.TrimRight(lapiURL, "/") + "/v1/version"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, http.NoBody)
if err != nil {
return "", fmt.Errorf("create version request: %w", err)
}

// Use SSRF-safe HTTP client with localhost allowed (LAPI is localhost-only)
client := network.NewSafeHTTPClient(
network.WithTimeout(defaultHealthTimeout),
network.WithAllowLocalhost(), // LAPI validated to be localhost only
)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("version request failed: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
logger.Log().WithError(closeErr).Warn("Failed to close response body")
}
}()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("version request returned status %d", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read version response: %w", err)
}

var versionResp struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &versionResp); err != nil {
// Some versions return plain text
return strings.TrimSpace(string(body)), nil
}

return versionResp.Version, nil
}

// checkDecisionsEndpoint is a fallback health check using the decisions endpoint.
func checkDecisionsEndpoint(ctx context.Context, lapiURL string) bool {
decisionsURL := strings.TrimRight(lapiURL, "/") + "/v1/decisions"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, decisionsURL, http.NoBody)
if err != nil {
return false
}

// Use SSRF-safe HTTP client with localhost allowed (LAPI is localhost-only)
client := network.NewSafeHTTPClient(
network.WithTimeout(defaultHealthTimeout),
network.WithAllowLocalhost(), // LAPI validated to be localhost only
)
resp, err := client.Do(req)
if err != nil {
return false
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Log().WithError(err).Warn("Failed to close response body")
}
}()

// Check content-type to avoid false positives from HTML responses
contentType := resp.Header.Get("Content-Type")
if contentType != "" && !strings.Contains(contentType, "application/json") {
// Not JSON response, likely hitting a frontend/proxy
return false
}

// 401 is expected without auth, but indicates LAPI is running
// 200 with empty array is also valid (no decisions)
return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized
}

// getBouncerAPIKey returns the bouncer API key from environment variables.
func getBouncerAPIKey() string {
// Check multiple possible env var names for the API key
Expand Down
97 changes: 0 additions & 97 deletions backend/internal/crowdsec/registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package crowdsec
import (
"context"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -58,67 +56,6 @@ func withPath(t *testing.T, newPath string, fn func()) {
fn()
}

func TestCheckLAPIHealth_Healthy(t *testing.T) {
// Create a mock LAPI server that returns 200 OK with JSON content-type
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

healthy := CheckLAPIHealth(server.URL)
assert.True(t, healthy, "LAPI should be healthy")
}

func TestCheckLAPIHealth_Unhealthy(t *testing.T) {
// Create a mock LAPI server that returns 500
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

healthy := CheckLAPIHealth(server.URL)
assert.False(t, healthy, "LAPI should be unhealthy")
}

func TestCheckLAPIHealth_Unreachable(t *testing.T) {
// Use an invalid URL that won't connect
healthy := CheckLAPIHealth("http://127.0.0.1:19999")
assert.False(t, healthy, "LAPI should be unreachable")
}

func TestCheckLAPIHealth_FallbackToDecisions(t *testing.T) {
// Create a mock LAPI server where /health fails but /v1/decisions returns 401
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Path == "/v1/decisions" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) // Expected without auth
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

healthy := CheckLAPIHealth(server.URL)
// Should fallback to decisions endpoint check which returns 401 (indicates running)
assert.True(t, healthy, "LAPI should be healthy via decisions fallback")
}

func TestCheckLAPIHealth_DefaultURL(t *testing.T) {
// With empty URL, should use default (which won't be running in test)
healthy := CheckLAPIHealth("")
assert.False(t, healthy, "Default LAPI should not be running in test environment")
}

func TestGetBouncerAPIKey_FromEnv(t *testing.T) {
// Save and restore original env
original := os.Getenv("CROWDSEC_API_KEY")
Expand Down Expand Up @@ -266,40 +203,6 @@ exit 2
})
}

func TestGetLAPIVersion_JSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/version" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"version":"1.2.3"}`))
}))
defer server.Close()

ver, err := GetLAPIVersion(context.Background(), server.URL)
assert.NoError(t, err)
assert.Equal(t, "1.2.3", ver)
}

func TestGetLAPIVersion_PlainText(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/version" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("vX.Y.Z\n"))
}))
defer server.Close()

ver, err := GetLAPIVersion(context.Background(), server.URL)
assert.NoError(t, err)
assert.Equal(t, "vX.Y.Z", ver)
}

func TestValidateLAPIURL(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading