336 lines
10 KiB
Go
336 lines
10 KiB
Go
// Package crowdsec provides integration with CrowdSec for security decisions and remediation.
|
|
package crowdsec
|
|
|
|
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"
|
|
)
|
|
|
|
// BouncerRegistration holds information about a registered bouncer.
|
|
type BouncerRegistration struct {
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
Valid bool `json:"valid"`
|
|
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:
|
|
// 1. Uses only http/https schemes
|
|
// 2. Points to localhost OR is explicitly within allowed private networks
|
|
// 3. Does not point to arbitrary external URLs
|
|
//
|
|
// Returns: error if URL is invalid or suspicious
|
|
func validateLAPIURL(lapiURL string) error {
|
|
// Empty URL defaults to localhost, which is safe
|
|
if lapiURL == "" {
|
|
return nil
|
|
}
|
|
|
|
parsed, err := neturl.Parse(lapiURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid LAPI URL format: %w", err)
|
|
}
|
|
|
|
// Only allow http/https
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("LAPI URL must use http or https scheme (got: %s)", parsed.Scheme)
|
|
}
|
|
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("missing hostname in LAPI URL")
|
|
}
|
|
|
|
// Allow localhost addresses (CrowdSec typically runs locally)
|
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
|
return nil
|
|
}
|
|
|
|
// For non-localhost, the LAPI URL should be explicitly configured
|
|
// and point to an internal service. We accept RFC 1918 private IPs
|
|
// but log a warning for operational visibility.
|
|
// This prevents accidental/malicious configuration to external URLs.
|
|
|
|
// Parse IP to check if it's in private range
|
|
// If not an IP, it's a hostname - for security, we only allow
|
|
// localhost hostnames or IPs. Custom hostnames could resolve to
|
|
// arbitrary locations via DNS.
|
|
|
|
// Note: This is a conservative approach. If you need to allow
|
|
// specific internal hostnames, add them to an allowlist.
|
|
|
|
return fmt.Errorf("LAPI URL must be localhost for security (got: %s). For remote LAPI, ensure it's on a trusted internal network", host)
|
|
}
|
|
|
|
// EnsureBouncerRegistered checks if a caddy bouncer is registered with CrowdSec LAPI.
|
|
// If not registered and cscli is available, it will attempt to register one.
|
|
// Returns the API key for the bouncer (from env var or newly registered).
|
|
func EnsureBouncerRegistered(ctx context.Context, lapiURL string) (string, error) {
|
|
// CRITICAL FIX: Validate LAPI URL before making requests (MEDIUM-001)
|
|
if err := validateLAPIURL(lapiURL); err != nil {
|
|
return "", fmt.Errorf("LAPI URL validation failed: %w", err)
|
|
}
|
|
|
|
// First check if API key is provided via environment
|
|
apiKey := getBouncerAPIKey()
|
|
if apiKey != "" {
|
|
return apiKey, nil
|
|
}
|
|
|
|
// Check if cscli is available
|
|
if !hasCSCLI() {
|
|
return "", fmt.Errorf("no API key provided and cscli not available for bouncer registration")
|
|
}
|
|
|
|
// Check if bouncer already exists
|
|
existing, err := getExistingBouncer(ctx, defaultRegistrationName)
|
|
if err == nil && existing.APIKey != "" {
|
|
return existing.APIKey, nil
|
|
}
|
|
|
|
// Register new bouncer using cscli
|
|
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
|
|
envVars := []string{
|
|
"CROWDSEC_API_KEY",
|
|
"CROWDSEC_BOUNCER_API_KEY",
|
|
"CERBERUS_SECURITY_CROWDSEC_API_KEY",
|
|
"CHARON_SECURITY_CROWDSEC_API_KEY",
|
|
"CPM_SECURITY_CROWDSEC_API_KEY",
|
|
}
|
|
|
|
for _, key := range envVars {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// hasCSCLI checks if cscli command is available.
|
|
func hasCSCLI() bool {
|
|
_, err := exec.LookPath("cscli")
|
|
return err == nil
|
|
}
|
|
|
|
// getExistingBouncer retrieves an existing bouncer registration by name.
|
|
func getExistingBouncer(ctx context.Context, name string) (BouncerRegistration, error) {
|
|
cmd := exec.CommandContext(ctx, "cscli", "bouncers", "list", "-o", "json")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return BouncerRegistration{}, fmt.Errorf("list bouncers: %w", err)
|
|
}
|
|
|
|
var bouncers []struct {
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
IPAddress string `json:"ip_address"`
|
|
Valid bool `json:"valid"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &bouncers); err != nil {
|
|
return BouncerRegistration{}, fmt.Errorf("parse bouncers: %w", err)
|
|
}
|
|
|
|
for _, b := range bouncers {
|
|
if b.Name == name {
|
|
var createdAt time.Time
|
|
if b.CreatedAt != "" {
|
|
createdAt, _ = time.Parse(time.RFC3339, b.CreatedAt)
|
|
}
|
|
return BouncerRegistration{
|
|
Name: b.Name,
|
|
APIKey: b.APIKey,
|
|
IPAddress: b.IPAddress,
|
|
Valid: b.Valid,
|
|
CreatedAt: createdAt,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return BouncerRegistration{}, fmt.Errorf("bouncer %q not found", name)
|
|
}
|
|
|
|
// registerBouncer registers a new bouncer with CrowdSec using cscli.
|
|
func registerBouncer(ctx context.Context, name string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "cscli", "bouncers", "add", name, "-o", "raw")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("register bouncer: %w", err)
|
|
}
|
|
|
|
apiKey := strings.TrimSpace(string(output))
|
|
if apiKey == "" {
|
|
return "", fmt.Errorf("empty API key returned from bouncer registration")
|
|
}
|
|
|
|
return apiKey, nil
|
|
}
|