Files
Charon/backend/internal/crowdsec/registration_test.go
GitHub Actions c9d9c52657 fix(security): eliminate SSRF vulnerability with comprehensive test coverage (CWE-918)
Resolves Critical severity CodeQL finding in url_testing.go by implementing
connection-time IP validation via custom DialContext. Added comprehensive
test coverage for all SSRF protection mechanisms across the codebase.

Technical changes:
- Created ssrfSafeDialer() with atomic DNS resolution and IP validation
- Refactored TestURLConnectivity() to use secure http.Transport
- Added scheme validation (http/https only)
- Prevents access to 13+ blocked CIDR ranges

Test coverage improvements:
- Backend: 85.1% → 86.5% (+1.4%)
- Patch coverage: 70% → 86.5% (+16.5%)
- Added 38 new test cases across 7 functions
- docker_service.go: 0% → 85.2%
- update_service.go: 26% → 95.2%
- crowdsec/registration.go: 18% → 92.3%

Security impact:
- Prevents SSRF attacks (CWE-918)
- Blocks DNS rebinding
- Protects cloud metadata endpoints
- Validates all URL inputs with comprehensive tests

Testing:
- All 1172+ tests passing
- govulncheck: zero vulnerabilities
- Trivy: zero issues
- Pre-commit: passing

Refs: #450
2025-12-23 17:42:21 +00:00

415 lines
10 KiB
Go

package crowdsec
import (
"context"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func writeFakeCSCLI(t *testing.T, script string) (binDir string) {
t.Helper()
binDir = t.TempDir()
path := filepath.Join(binDir, "cscli")
requireMode := fs.FileMode(0o755)
err := os.WriteFile(path, []byte(script), requireMode)
if err != nil {
t.Fatalf("failed to write fake cscli: %v", err)
}
return binDir
}
func withEnv(t *testing.T, key, value string, fn func()) {
t.Helper()
old, had := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
fn()
}
func withPath(t *testing.T, newPath string, fn func()) {
t.Helper()
old, had := os.LookupEnv("PATH")
_ = os.Setenv("PATH", newPath)
t.Cleanup(func() {
if had {
_ = os.Setenv("PATH", old)
} else {
_ = os.Unsetenv("PATH")
}
})
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")
defer func() {
if original != "" {
_ = os.Setenv("CROWDSEC_API_KEY", original)
} else {
_ = os.Unsetenv("CROWDSEC_API_KEY")
}
}()
// Set test value
_ = os.Setenv("CROWDSEC_API_KEY", "test-api-key-123")
key := getBouncerAPIKey()
assert.Equal(t, "test-api-key-123", key)
}
func TestGetBouncerAPIKey_Empty(t *testing.T) {
// Save and restore original env vars
envVars := []string{
"CROWDSEC_API_KEY",
"CROWDSEC_BOUNCER_API_KEY",
"CERBERUS_SECURITY_CROWDSEC_API_KEY",
"CHARON_SECURITY_CROWDSEC_API_KEY",
"CPM_SECURITY_CROWDSEC_API_KEY",
}
originals := make(map[string]string)
for _, key := range envVars {
originals[key] = os.Getenv(key)
_ = os.Unsetenv(key)
}
defer func() {
for key, val := range originals {
if val != "" {
_ = os.Setenv(key, val)
}
}
}()
key := getBouncerAPIKey()
assert.Empty(t, key)
}
func TestGetBouncerAPIKey_Fallback(t *testing.T) {
// Test fallback to secondary env var
envVars := []string{
"CROWDSEC_API_KEY",
"CROWDSEC_BOUNCER_API_KEY",
"CERBERUS_SECURITY_CROWDSEC_API_KEY",
"CHARON_SECURITY_CROWDSEC_API_KEY",
"CPM_SECURITY_CROWDSEC_API_KEY",
}
originals := make(map[string]string)
for _, key := range envVars {
originals[key] = os.Getenv(key)
_ = os.Unsetenv(key)
}
defer func() {
for key, val := range originals {
if val != "" {
_ = os.Setenv(key, val)
}
}
}()
// Set only the fallback env var
_ = os.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "fallback-key-456")
key := getBouncerAPIKey()
assert.Equal(t, "fallback-key-456", key)
}
func TestEnsureBouncerRegistered_UsesEnvKey(t *testing.T) {
withEnv(t, "CROWDSEC_API_KEY", "env-key", func() {
key, err := EnsureBouncerRegistered(context.Background(), "")
assert.NoError(t, err)
assert.Equal(t, "env-key", key)
})
}
func TestEnsureBouncerRegistered_NoEnvNoCSCLI(t *testing.T) {
// Ensure all key env vars are empty
for _, k := range []string{"CROWDSEC_API_KEY", "CROWDSEC_BOUNCER_API_KEY", "CERBERUS_SECURITY_CROWDSEC_API_KEY", "CHARON_SECURITY_CROWDSEC_API_KEY", "CPM_SECURITY_CROWDSEC_API_KEY"} {
withEnv(t, k, "", func() {})
}
withPath(t, "", func() {
_, err := EnsureBouncerRegistered(context.Background(), "")
assert.Error(t, err)
})
}
func TestEnsureBouncerRegistered_ReturnsExistingBouncerKey(t *testing.T) {
for _, k := range []string{"CROWDSEC_API_KEY", "CROWDSEC_BOUNCER_API_KEY", "CERBERUS_SECURITY_CROWDSEC_API_KEY", "CHARON_SECURITY_CROWDSEC_API_KEY", "CPM_SECURITY_CROWDSEC_API_KEY"} {
withEnv(t, k, "", func() {})
}
origPath := os.Getenv("PATH")
binDir := writeFakeCSCLI(t, `#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "bouncers" && "$2" == "list" ]]; then
echo '[{"name":"caddy-bouncer","api_key":"existing-key","ip_address":"","valid":true,"created_at":"2025-01-01T00:00:00Z"}]'
exit 0
fi
echo "unexpected args" >&2
exit 2
`)
withPath(t, binDir+":"+origPath, func() {
key, err := EnsureBouncerRegistered(context.Background(), "")
assert.NoError(t, err)
assert.Equal(t, "existing-key", key)
})
}
func TestEnsureBouncerRegistered_RegistersNewWhenNoneExists(t *testing.T) {
for _, k := range []string{"CROWDSEC_API_KEY", "CROWDSEC_BOUNCER_API_KEY", "CERBERUS_SECURITY_CROWDSEC_API_KEY", "CHARON_SECURITY_CROWDSEC_API_KEY", "CPM_SECURITY_CROWDSEC_API_KEY"} {
withEnv(t, k, "", func() {})
}
origPath := os.Getenv("PATH")
binDir := writeFakeCSCLI(t, `#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "bouncers" && "$2" == "list" ]]; then
echo '[]'
exit 0
fi
if [[ "$1" == "bouncers" && "$2" == "add" ]]; then
echo 'new-key'
exit 0
fi
echo "unexpected args" >&2
exit 2
`)
withPath(t, binDir+":"+origPath, func() {
key, err := EnsureBouncerRegistered(context.Background(), "")
assert.NoError(t, err)
assert.Equal(t, "new-key", key)
})
}
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
url string
wantErr bool
errContains string
}{
{
name: "valid localhost with port",
url: "http://localhost:8085",
wantErr: false,
},
{
name: "valid 127.0.0.1",
url: "http://127.0.0.1:8085",
wantErr: false,
},
{
name: "external URL blocked",
url: "http://evil.com",
wantErr: true,
errContains: "must be localhost",
},
{
name: "HTTPS localhost",
url: "https://localhost:8085",
wantErr: false,
},
{
name: "invalid scheme",
url: "ftp://localhost:8085",
wantErr: true,
errContains: "scheme",
},
{
name: "no scheme",
url: "localhost:8085",
wantErr: true,
errContains: "scheme",
},
{
name: "empty URL allowed (defaults to localhost)",
url: "",
wantErr: false,
},
{
name: "IPv6 localhost",
url: "http://[::1]:8085",
wantErr: false,
},
{
name: "private IP 192.168.x.x blocked (security)",
url: "http://192.168.1.100:8085",
wantErr: true,
errContains: "must be localhost",
},
{
name: "private IP 10.x.x.x blocked (security)",
url: "http://10.0.0.50:8085",
wantErr: true,
errContains: "must be localhost",
},
{
name: "missing hostname",
url: "http://:8085",
wantErr: true,
errContains: "missing hostname",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateLAPIURL(tt.url)
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestEnsureBouncerRegistered_InvalidURL(t *testing.T) {
// Test that SSRF validation is applied
tests := []struct {
name string
url string
errContains string
}{
{
name: "external URL rejected",
url: "http://attacker.com:8085",
errContains: "must be localhost",
},
{
name: "invalid scheme rejected",
url: "ftp://localhost:8085",
errContains: "scheme",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := EnsureBouncerRegistered(context.Background(), tt.url)
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
})
}
}