diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index d40c2d33..327bb16d 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -2,46 +2,10 @@ # See: https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning name: "Charon CodeQL Config" -# Query filters to exclude specific alerts with documented justification -query-filters: - # =========================================================================== - # SSRF False Positive Exclusion - # =========================================================================== - # File: backend/internal/utils/url_testing.go (line 276) - # Rule: go/request-forgery - # - # JUSTIFICATION: This file implements comprehensive 4-layer SSRF protection: - # - # Layer 1: Format Validation (utils.ValidateURL) - # - Validates URL scheme (http/https only) - # - Parses and validates URL structure - # - # Layer 2: Security Validation (security.ValidateExternalURL) - # - Performs DNS resolution with timeout - # - Blocks 13+ private/reserved IP CIDR ranges: - # * RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 - # * Loopback: 127.0.0.0/8, ::1/128 - # * Link-Local: 169.254.0.0/16 (AWS/GCP/Azure metadata), fe80::/10 - # * Reserved: 0.0.0.0/8, 240.0.0.0/4, 255.255.255.255/32 - # * IPv6 ULA: fc00::/7 - # - # Layer 3: Connection-Time Validation (ssrfSafeDialer) - # - Re-resolves DNS at connection time (prevents DNS rebinding) - # - Re-validates all resolved IPs against blocklist - # - Blocks requests if any IP is private/reserved - # - # Layer 4: Request Execution (TestURLConnectivity) - # - HEAD request only (minimal data exposure) - # - 5-second timeout - # - Max 2 redirects with redirect target validation - # - # Security Review: Approved - defense-in-depth prevents SSRF attacks - # Last Review Date: 2026-01-01 - # =========================================================================== - - exclude: - id: go/request-forgery - # Paths to ignore from all analysis (use sparingly - prefer query-filters) -# paths-ignore: -# - "**/vendor/**" -# - "**/testdata/**" +paths-ignore: + - "frontend/coverage/**" + - "frontend/dist/**" + - "playwright-report/**" + - "test-results/**" + - "coverage/**" diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index 56b634e8..68e8813c 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -67,7 +67,7 @@ Before proposing ANY code change or fix, you must build a mental map of the feat ## Documentation -- **Features**: Update `docs/features.md` when adding capabilities. +- **Features**: Update `docs/features.md` when adding capabilities. This is a short "marketing" style list. Keep details to their individual docs. - **Links**: Use GitHub Pages URLs (`https://wikid82.github.io/charon/`) for docs and GitHub blob links for repo files. ## CI/CD & Commit Conventions diff --git a/.github/skills/security-scan-codeql-scripts/run.sh b/.github/skills/security-scan-codeql-scripts/run.sh index 4f53ca5b..6fda60a0 100755 --- a/.github/skills/security-scan-codeql-scripts/run.sh +++ b/.github/skills/security-scan-codeql-scripts/run.sh @@ -17,6 +17,12 @@ source "${SKILLS_SCRIPTS_DIR}/_error_handling_helpers.sh" # shellcheck source=../scripts/_environment_helpers.sh source "${SKILLS_SCRIPTS_DIR}/_environment_helpers.sh" +# Some helper scripts may not define ANSI color variables; ensure they exist +# before using them later in this script (set -u is enabled). +RED="${RED:-\033[0;31m}" +GREEN="${GREEN:-\033[0;32m}" +NC="${NC:-\033[0m}" + PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" # Set defaults @@ -89,12 +95,18 @@ run_codeql_scan() { local source_root=$2 local db_name="codeql-db-${lang}" local sarif_file="codeql-results-${lang}.sarif" - local query_suite="" + local build_mode_args=() + local codescanning_config="${PROJECT_ROOT}/.github/codeql/codeql-config.yml" - if [[ "${lang}" == "go" ]]; then - query_suite="codeql/go-queries:codeql-suites/go-security-and-quality.qls" - else - query_suite="codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls" + # Remove generated artifacts that can create noisy/false findings during CodeQL analysis + rm -rf "${PROJECT_ROOT}/frontend/coverage" \ + "${PROJECT_ROOT}/frontend/dist" \ + "${PROJECT_ROOT}/playwright-report" \ + "${PROJECT_ROOT}/test-results" \ + "${PROJECT_ROOT}/coverage" + + if [[ "${lang}" == "javascript" ]]; then + build_mode_args=(--build-mode=none) fi log_step "CODEQL" "Scanning ${lang} code in ${source_root}/" @@ -106,7 +118,9 @@ run_codeql_scan() { log_info "Creating CodeQL database..." if ! codeql database create "${db_name}" \ --language="${lang}" \ + "${build_mode_args[@]}" \ --source-root="${source_root}" \ + --codescanning-config="${codescanning_config}" \ --threads="${CODEQL_THREADS}" \ --overwrite 2>&1 | while read -r line; do # Filter verbose output, show important messages @@ -121,9 +135,8 @@ run_codeql_scan() { fi # Run analysis - log_info "Analyzing with security-and-quality suite..." + log_info "Analyzing with Code Scanning config (CI-aligned query filters)..." if ! codeql database analyze "${db_name}" \ - "${query_suite}" \ --format=sarif-latest \ --output="${sarif_file}" \ --sarif-add-baseline-file-info \ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 250c648d..9f5e85d9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -6,22 +6,14 @@ "type": "shell", "command": "docker build -t charon:local . && docker compose -f docker-compose.test.yml up -d && echo 'Charon running at http://localhost:8080'", "group": "build", - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "new" - } + "problemMatcher": [] }, { "label": "Build & Run: Local Docker Image No-Cache", "type": "shell", "command": "docker build --no-cache -t charon:local . && docker compose -f docker-compose.test.yml up -d && echo 'Charon running at http://localhost:8080'", "group": "build", - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "new" - } + "problemMatcher": [] }, { "label": "Build: Backend", @@ -41,6 +33,8 @@ "label": "Build: All", "type": "shell", "dependsOn": ["Build: Backend", "Build: Frontend"], + "dependsOrder": "sequence", + "command": "echo 'Build complete'", "group": { "kind": "build", "isDefault": true @@ -52,7 +46,7 @@ "type": "shell", "command": ".github/skills/scripts/skill-runner.sh test-backend-unit", "group": "test", - "problemMatcher": ["$go"] + "problemMatcher": [] }, { "label": "Test: Backend with Coverage", @@ -73,26 +67,14 @@ "type": "shell", "command": ".github/skills/scripts/skill-runner.sh test-frontend-coverage", "group": "test", - "problemMatcher": [], - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "new", - "showReuseMessage": false, - "clear": true - } + "problemMatcher": [] }, { "label": "Lint: Pre-commit (All Files)", "type": "shell", "command": ".github/skills/scripts/skill-runner.sh qa-precommit-all", "group": "test", - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "shared" - } + "problemMatcher": [] }, { "label": "Lint: Go Vet", @@ -174,38 +156,23 @@ { "label": "Security: CodeQL Go Scan (CI-Aligned) [~60s]", "type": "shell", - "command": "bash -c 'set -e && \\\n echo \"πŸ” Creating CodeQL database for Go...\" && \\\n rm -rf codeql-db-go && \\\n codeql database create codeql-db-go \\\n --language=go \\\n --source-root=backend \\\n --overwrite \\\n --threads=0 && \\\n echo \"\" && \\\n echo \"πŸ“Š Running CodeQL analysis (security-and-quality suite)...\" && \\\n codeql database analyze codeql-db-go \\\n codeql/go-queries:codeql-suites/go-security-and-quality.qls \\\n --format=sarif-latest \\\n --output=codeql-results-go.sarif \\\n --sarif-add-baseline-file-info \\\n --threads=0 && \\\n echo \"\" && \\\n echo \"βœ… CodeQL scan complete. Results: codeql-results-go.sarif\" && \\\n echo \"\" && \\\n echo \"πŸ“‹ Summary of findings:\" && \\\n codeql database interpret-results codeql-db-go \\\n --format=text \\\n --output=/dev/stdout \\\n codeql/go-queries:codeql-suites/go-security-and-quality.qls 2>/dev/null || \\\n (echo \"⚠️ Use SARIF Viewer extension to view detailed results\" && jq -r \".runs[].results[] | \\\"\\(.level): \\(.message.text) (\\(.locations[0].physicalLocation.artifactLocation.uri):\\(.locations[0].physicalLocation.region.startLine))\\\"\" codeql-results-go.sarif 2>/dev/null | head -20 || echo \"No findings or jq not available\")'", + "command": "rm -rf codeql-db-go && codeql database create codeql-db-go --language=go --source-root=backend --codescanning-config=.github/codeql/codeql-config.yml --overwrite --threads=0 && codeql database analyze codeql-db-go --additional-packs=codeql-custom-queries-go --format=sarif-latest --output=codeql-results-go.sarif --sarif-add-baseline-file-info --threads=0", "group": "test", - "problemMatcher": [], - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared", - "showReuseMessage": false, - "clear": false - } + "problemMatcher": [] }, { "label": "Security: CodeQL JS Scan (CI-Aligned) [~90s]", "type": "shell", - "command": "bash -c 'set -e && \\\n echo \"πŸ” Creating CodeQL database for JavaScript/TypeScript...\" && \\\n rm -rf codeql-db-js && \\\n codeql database create codeql-db-js \\\n --language=javascript \\\n --source-root=frontend \\\n --overwrite \\\n --threads=0 && \\\n echo \"\" && \\\n echo \"πŸ“Š Running CodeQL analysis (security-and-quality suite)...\" && \\\n codeql database analyze codeql-db-js \\\n codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls \\\n --format=sarif-latest \\\n --output=codeql-results-js.sarif \\\n --sarif-add-baseline-file-info \\\n --threads=0 && \\\n echo \"\" && \\\n echo \"βœ… CodeQL scan complete. Results: codeql-results-js.sarif\" && \\\n echo \"\" && \\\n echo \"πŸ“‹ Summary of findings:\" && \\\n codeql database interpret-results codeql-db-js \\\n --format=text \\\n --output=/dev/stdout \\\n codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls 2>/dev/null || \\\n (echo \"⚠️ Use SARIF Viewer extension to view detailed results\" && jq -r \".runs[].results[] | \\\"\\(.level): \\(.message.text) (\\(.locations[0].physicalLocation.artifactLocation.uri):\\(.locations[0].physicalLocation.region.startLine))\\\"\" codeql-results-js.sarif 2>/dev/null | head -20 || echo \"No findings or jq not available\")'", + "command": "rm -rf codeql-db-js && codeql database create codeql-db-js --language=javascript --build-mode=none --source-root=frontend --codescanning-config=.github/codeql/codeql-config.yml --overwrite --threads=0 && codeql database analyze codeql-db-js --format=sarif-latest --output=codeql-results-js.sarif --sarif-add-baseline-file-info --threads=0", "group": "test", - "problemMatcher": [], - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared", - "showReuseMessage": false, - "clear": false - } + "problemMatcher": [] }, { "label": "Security: CodeQL All (CI-Aligned)", "type": "shell", "dependsOn": ["Security: CodeQL Go Scan (CI-Aligned) [~60s]", "Security: CodeQL JS Scan (CI-Aligned) [~90s]"], "dependsOrder": "sequence", + "command": "echo 'CodeQL complete'", "group": "test", "problemMatcher": [] }, @@ -214,11 +181,7 @@ "type": "shell", "command": ".github/skills/scripts/skill-runner.sh security-scan-codeql", "group": "test", - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "shared" - } + "problemMatcher": [] }, { "label": "Security: Go Vulnerability Check", @@ -275,11 +238,7 @@ "type": "shell", "command": ".github/skills/scripts/skill-runner.sh integration-test-all", "group": "test", - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "new" - } + "problemMatcher": [] }, { "label": "Integration: Coraza WAF", @@ -335,11 +294,7 @@ "type": "shell", "command": ".github/skills/scripts/skill-runner.sh utility-db-recovery", "group": "none", - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "new" - } + "problemMatcher": [] } ] } diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 596b43a6..d75fb93a 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -19,6 +20,8 @@ import ( "github.com/Wikid82/charon/backend/internal/crowdsec" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/network" + "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" @@ -1048,6 +1051,15 @@ type lapiDecision struct { Until string `json:"until,omitempty"` } +const ( + // Default CrowdSec LAPI port to avoid conflict with Charon management API on port 8080. + defaultCrowdsecLAPIPort = 8085 +) + +func validateCrowdsecLAPIBaseURL(raw string) (*url.URL, error) { + return security.ValidateInternalServiceBaseURL(raw, defaultCrowdsecLAPIPort, security.InternalServiceHostAllowlist()) +} + // GetLAPIDecisions queries CrowdSec LAPI directly for current decisions. // This is an alternative to ListDecisions which uses cscli. // Query params: @@ -1065,23 +1077,29 @@ func (h *CrowdsecHandler) GetLAPIDecisions(c *gin.Context) { } } - // Build query string - queryParams := make([]string, 0) - if ip := c.Query("ip"); ip != "" { - queryParams = append(queryParams, "ip="+ip) - } - if scope := c.Query("scope"); scope != "" { - queryParams = append(queryParams, "scope="+scope) - } - if decisionType := c.Query("type"); decisionType != "" { - queryParams = append(queryParams, "type="+decisionType) + baseURL, err := validateCrowdsecLAPIBaseURL(lapiURL) + if err != nil { + logger.Log().WithError(err).WithField("lapi_url", lapiURL).Warn("Blocked CrowdSec LAPI URL by internal allowlist policy") + // Fallback to cscli-based method. + h.ListDecisions(c) + return } - // Build request URL - reqURL := strings.TrimRight(lapiURL, "/") + "/v1/decisions" - if len(queryParams) > 0 { - reqURL += "?" + strings.Join(queryParams, "&") + q := url.Values{} + if ip := strings.TrimSpace(c.Query("ip")); ip != "" { + q.Set("ip", ip) } + if scope := strings.TrimSpace(c.Query("scope")); scope != "" { + q.Set("scope", scope) + } + if decisionType := strings.TrimSpace(c.Query("type")); decisionType != "" { + q.Set("type", decisionType) + } + + endpoint := baseURL.ResolveReference(&url.URL{Path: "/v1/decisions"}) + endpoint.RawQuery = q.Encode() + // Use validated+rebuilt URL for request construction (taint break). + reqURL := endpoint.String() // Get API key apiKey := getLAPIKey() @@ -1104,10 +1122,10 @@ func (h *CrowdsecHandler) GetLAPIDecisions(c *gin.Context) { req.Header.Set("Accept", "application/json") // Execute request - client := &http.Client{Timeout: 10 * time.Second} + client := network.NewInternalServiceHTTPClient(10 * time.Second) resp, err := client.Do(req) if err != nil { - logger.Log().WithError(err).WithField("lapi_url", lapiURL).Warn("Failed to query LAPI decisions") + logger.Log().WithError(err).WithField("lapi_url", baseURL.String()).Warn("Failed to query LAPI decisions") // Fallback to cscli-based method h.ListDecisions(c) return @@ -1120,7 +1138,7 @@ func (h *CrowdsecHandler) GetLAPIDecisions(c *gin.Context) { return } if resp.StatusCode != http.StatusOK { - logger.Log().WithField("status", resp.StatusCode).WithField("lapi_url", lapiURL).Warn("LAPI returned non-OK status") + logger.Log().WithField("status", resp.StatusCode).WithField("lapi_url", baseURL.String()).Warn("LAPI returned non-OK status") // Fallback to cscli-based method h.ListDecisions(c) return @@ -1129,7 +1147,7 @@ func (h *CrowdsecHandler) GetLAPIDecisions(c *gin.Context) { // Check content-type to ensure we're getting JSON (not HTML from a proxy/frontend) contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.Contains(contentType, "application/json") { - logger.Log().WithField("content_type", contentType).WithField("lapi_url", lapiURL).Warn("LAPI returned non-JSON content-type, falling back to cscli") + logger.Log().WithField("content_type", contentType).WithField("lapi_url", baseURL.String()).Warn("LAPI returned non-JSON content-type, falling back to cscli") // Fallback to cscli-based method h.ListDecisions(c) return @@ -1213,36 +1231,42 @@ func (h *CrowdsecHandler) CheckLAPIHealth(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() - healthURL := strings.TrimRight(lapiURL, "/") + "/health" + baseURL, err := validateCrowdsecLAPIBaseURL(lapiURL) + if err != nil { + c.JSON(http.StatusOK, gin.H{"healthy": false, "error": "invalid LAPI URL (blocked by SSRF policy)", "lapi_url": lapiURL}) + return + } + + healthURL := baseURL.ResolveReference(&url.URL{Path: "/health"}).String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, http.NoBody) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"healthy": false, "error": "failed to create request"}) return } - client := &http.Client{Timeout: 5 * time.Second} + client := network.NewInternalServiceHTTPClient(5 * time.Second) resp, err := client.Do(req) if err != nil { // Try decisions endpoint as fallback health check - decisionsURL := strings.TrimRight(lapiURL, "/") + "/v1/decisions" + decisionsURL := baseURL.ResolveReference(&url.URL{Path: "/v1/decisions"}).String() req2, _ := http.NewRequestWithContext(ctx, http.MethodHead, decisionsURL, http.NoBody) resp2, err2 := client.Do(req2) if err2 != nil { - c.JSON(http.StatusOK, gin.H{"healthy": false, "error": "LAPI unreachable", "lapi_url": lapiURL}) + c.JSON(http.StatusOK, gin.H{"healthy": false, "error": "LAPI unreachable", "lapi_url": baseURL.String()}) return } defer resp2.Body.Close() // 401 is expected without auth but indicates LAPI is running if resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized { - c.JSON(http.StatusOK, gin.H{"healthy": true, "lapi_url": lapiURL, "note": "health endpoint unavailable, verified via decisions endpoint"}) + c.JSON(http.StatusOK, gin.H{"healthy": true, "lapi_url": baseURL.String(), "note": "health endpoint unavailable, verified via decisions endpoint"}) return } - c.JSON(http.StatusOK, gin.H{"healthy": false, "error": "unexpected status", "status": resp2.StatusCode, "lapi_url": lapiURL}) + c.JSON(http.StatusOK, gin.H{"healthy": false, "error": "unexpected status", "status": resp2.StatusCode, "lapi_url": baseURL.String()}) return } defer resp.Body.Close() - c.JSON(http.StatusOK, gin.H{"healthy": resp.StatusCode == http.StatusOK, "lapi_url": lapiURL, "status": resp.StatusCode}) + c.JSON(http.StatusOK, gin.H{"healthy": resp.StatusCode == http.StatusOK, "lapi_url": baseURL.String(), "status": resp.StatusCode}) } // ListDecisions calls cscli to get current decisions (banned IPs) diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index a1547029..6163ab5a 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -163,7 +163,7 @@ func TestProxyHostErrors(t *testing.T) { // Setup Caddy Manager tmpDir := t.TempDir() - client := caddy.NewClient(caddyServer.URL) + client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) // Setup Handler @@ -443,7 +443,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) { // Setup Caddy Manager tmpDir := t.TempDir() - client := caddy.NewClient(caddyServer.URL) + client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) // Setup Handler @@ -1677,7 +1677,7 @@ func TestUpdate_IntegrationCaddyConfig(t *testing.T) { require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{})) tmpDir := t.TempDir() - client := caddy.NewClient(caddyServer.URL) + client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) ns := services.NewNotificationService(db) diff --git a/backend/internal/api/handlers/security_handler_rules_decisions_test.go b/backend/internal/api/handlers/security_handler_rules_decisions_test.go index c6ff5790..5339a39d 100644 --- a/backend/internal/api/handlers/security_handler_rules_decisions_test.go +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -131,7 +131,7 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) { })) defer caddyServer.Close() - client := caddy.NewClient(caddyServer.URL) + client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) tmp := t.TempDir() m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) diff --git a/backend/internal/api/handlers/ssrf_test_helpers_test.go b/backend/internal/api/handlers/ssrf_test_helpers_test.go new file mode 100644 index 00000000..2d92f59f --- /dev/null +++ b/backend/internal/api/handlers/ssrf_test_helpers_test.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "net/url" + "strconv" + "testing" +) + +func expectedPortFromURL(t *testing.T, raw string) int { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("failed to parse url %q: %v", raw, err) + } + p := u.Port() + if p == "" { + t.Fatalf("expected explicit port in url %q", raw) + } + port, err := strconv.Atoi(p) + if err != nil { + t.Fatalf("failed to parse port %q from url %q: %v", p, raw, err) + } + return port +} diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 72ae3b45..11707e5b 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -174,3 +174,53 @@ func TestRegister_ProxyHostsRequireAuth(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), "Authorization header required") } + +func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_dnsproviders_missing"), &gorm.Config{}) + require.NoError(t, err) + + cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: ""} + require.NoError(t, Register(router, db, cfg)) + + for _, r := range router.Routes() { + assert.NotContains(t, r.Path, "/api/v1/dns-providers") + } +} + +func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyInvalid(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_dnsproviders_invalid"), &gorm.Config{}) + require.NoError(t, err) + + cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: "not-base64"} + require.NoError(t, Register(router, db, cfg)) + + for _, r := range router.Routes() { + assert.NotContains(t, r.Path, "/api/v1/dns-providers") + } +} + +func TestRegister_DNSProviders_RegisteredWhenEncryptionKeyValid(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_dnsproviders_valid"), &gorm.Config{}) + require.NoError(t, err) + + // 32-byte all-zero key in base64 + cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} + require.NoError(t, Register(router, db, cfg)) + + paths := make(map[string]bool) + for _, r := range router.Routes() { + paths[r.Path] = true + } + + assert.True(t, paths["/api/v1/dns-providers"], "dns providers list route should be registered") + assert.True(t, paths["/api/v1/dns-providers/types"], "dns providers types route should be registered") +} diff --git a/backend/internal/caddy/client.go b/backend/internal/caddy/client.go index 51a4ad4b..fc98a519 100644 --- a/backend/internal/caddy/client.go +++ b/backend/internal/caddy/client.go @@ -8,7 +8,11 @@ import ( "fmt" "io" "net/http" + "net/url" "time" + + "github.com/Wikid82/charon/backend/internal/network" + "github.com/Wikid82/charon/backend/internal/security" ) // Test hook for json marshalling to allow simulating failures in tests @@ -16,29 +20,63 @@ var jsonMarshalClient = json.Marshal // Client wraps the Caddy admin API. type Client struct { - baseURL string + baseURL *url.URL httpClient *http.Client + initErr error } // NewClient creates a Caddy API client. func NewClient(adminAPIURL string) *Client { - return &Client{ - baseURL: adminAPIURL, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + return NewClientWithExpectedPort(adminAPIURL, defaultCaddyAdminPort) +} + +const ( + defaultCaddyAdminPort = 2019 +) + +// NewClientWithExpectedPort creates a Caddy API client with an explicit expected port. +// +// This enforces a deny-by-default SSRF policy for internal service calls: +// - hostname must be in the internal-service allowlist (exact matches) +// - port must match expectedPort +// - proxy env vars ignored, redirects disabled +func NewClientWithExpectedPort(adminAPIURL string, expectedPort int) *Client { + validatedBase, err := security.ValidateInternalServiceBaseURL(adminAPIURL, expectedPort, security.InternalServiceHostAllowlist()) + client := &Client{ + httpClient: network.NewInternalServiceHTTPClient(30 * time.Second), + initErr: err, } + if err == nil { + client.baseURL = validatedBase + } + return client +} + +func (c *Client) endpoint(path string) (string, error) { + if c.initErr != nil { + return "", fmt.Errorf("caddy client init failed: %w", c.initErr) + } + if c.baseURL == nil { + return "", fmt.Errorf("caddy client base URL is not configured") + } + u := c.baseURL.ResolveReference(&url.URL{Path: path}) + return u.String(), nil } // Load atomically replaces Caddy's entire configuration. // This is the primary method for applying configuration changes. func (c *Client) Load(ctx context.Context, config *Config) error { + urlStr, err := c.endpoint("/load") + if err != nil { + return err + } + body, err := jsonMarshalClient(config) if err != nil { return fmt.Errorf("marshal config: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } @@ -60,7 +98,12 @@ func (c *Client) Load(ctx context.Context, config *Config) error { // GetConfig retrieves the current running configuration from Caddy. func (c *Client) GetConfig(ctx context.Context) (*Config, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", http.NoBody) + urlStr, err := c.endpoint("/config/") + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, http.NoBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -86,7 +129,12 @@ func (c *Client) GetConfig(ctx context.Context) (*Config, error) { // Ping checks if Caddy admin API is reachable. func (c *Client) Ping(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", http.NoBody) + urlStr, err := c.endpoint("/config/") + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, http.NoBody) if err != nil { return fmt.Errorf("create request: %w", err) } diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index c1b2aa03..3b036eab 100644 --- a/backend/internal/caddy/client_test.go +++ b/backend/internal/caddy/client_test.go @@ -22,7 +22,7 @@ func TestClient_Load_Success(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) config, _ := GenerateConfig([]models.ProxyHost{ { UUID: "test", @@ -44,7 +44,7 @@ func TestClient_Load_Failure(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) config := &Config{} err := client.Load(context.Background(), config) @@ -71,7 +71,7 @@ func TestClient_GetConfig_Success(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) config, err := client.GetConfig(context.Background()) require.NoError(t, err) require.NotNil(t, config) @@ -84,13 +84,13 @@ func TestClient_Ping_Success(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) err := client.Ping(context.Background()) require.NoError(t, err) } func TestClient_Ping_Unreachable(t *testing.T) { - client := NewClient("http://localhost:9999") + client := NewClientWithExpectedPort("http://localhost:9999", 9999) err := client.Ping(context.Background()) require.Error(t, err) } @@ -115,7 +115,7 @@ func TestClient_GetConfig_Failure(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) _, err := client.GetConfig(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "500") @@ -128,7 +128,7 @@ func TestClient_GetConfig_InvalidJSON(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) _, err := client.GetConfig(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "decode response") @@ -140,32 +140,24 @@ func TestClient_Ping_Failure(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) err := client.Ping(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "503") } func TestClient_RequestCreationErrors(t *testing.T) { - // Use a control character in URL to force NewRequest error + // Unsafe base URLs are rejected up-front. client := NewClient("http://example.com" + string(byte(0x7f))) err := client.Load(context.Background(), &Config{}) require.Error(t, err) - require.Contains(t, err.Error(), "create request") - - _, err = client.GetConfig(context.Background()) - require.Error(t, err) - require.Contains(t, err.Error(), "create request") - - err = client.Ping(context.Background()) - require.Error(t, err) - require.Contains(t, err.Error(), "create request") + require.Contains(t, err.Error(), "caddy client init failed") } func TestClient_NetworkErrors(t *testing.T) { // Use a closed port to force connection error - client := NewClient("http://127.0.0.1:0") + client := NewClientWithExpectedPort("http://127.0.0.1:1", 1) err := client.Load(context.Background(), &Config{}) require.Error(t, err) @@ -182,7 +174,7 @@ func TestClient_Load_MarshalFailure(t *testing.T) { jsonMarshalClient = func(v any) ([]byte, error) { return nil, fmt.Errorf("marshal error") } defer func() { jsonMarshalClient = orig }() - client := NewClient("http://localhost") + client := NewClientWithExpectedPort("http://localhost:2019", 2019) err := client.Load(context.Background(), &Config{}) require.Error(t, err) require.Contains(t, err.Error(), "marshal config") @@ -195,7 +187,7 @@ func (f *failingTransport) RoundTrip(req *http.Request) (*http.Response, error) } func TestClient_Ping_TransportError(t *testing.T) { - client := NewClient("http://example.com") + client := NewClientWithExpectedPort("http://localhost:2019", 2019) client.httpClient = &http.Client{Transport: &failingTransport{}} err := client.Ping(context.Background()) require.Error(t, err) diff --git a/backend/internal/caddy/config_patch_coverage_test.go b/backend/internal/caddy/config_patch_coverage_test.go new file mode 100644 index 00000000..67811982 --- /dev/null +++ b/backend/internal/caddy/config_patch_coverage_test.go @@ -0,0 +1,220 @@ +package caddy + +import ( + "os" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/require" +) + +func TestGenerateConfig_DNSChallenge_LetsEncrypt_StagingCAAndPropagationTimeout(t *testing.T) { + providerID := uint(1) + host := models.ProxyHost{ + Enabled: true, + DomainNames: "*.example.com,example.com", + DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"}, + DNSProviderID: func() *uint { v := providerID; return &v }(), + } + + conf, err := GenerateConfig( + []models.ProxyHost{host}, + t.TempDir(), + "acme@example.com", + "", + "letsencrypt", + true, + false, false, false, false, + "", + nil, + nil, + nil, + &models.SecurityConfig{}, + []DNSProviderConfig{{ + ID: providerID, + ProviderType: "cloudflare", + PropagationTimeout: 120, + Credentials: map[string]string{"api_token": "tok"}, + }}, + ) + require.NoError(t, err) + require.NotNil(t, conf) + require.NotNil(t, conf.Apps.TLS) + require.NotNil(t, conf.Apps.TLS.Automation) + require.NotEmpty(t, conf.Apps.TLS.Automation.Policies) + + // Find a policy that includes the wildcard subject + var foundIssuer map[string]any + for _, p := range conf.Apps.TLS.Automation.Policies { + if p == nil { + continue + } + for _, s := range p.Subjects { + if s != "*.example.com" { + continue + } + require.NotEmpty(t, p.IssuersRaw) + for _, it := range p.IssuersRaw { + if m, ok := it.(map[string]any); ok { + if m["module"] == "acme" { + foundIssuer = m + break + } + } + } + } + if foundIssuer != nil { + break + } + } + + require.NotNil(t, foundIssuer) + require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", foundIssuer["ca"]) + + challenges, ok := foundIssuer["challenges"].(map[string]any) + require.True(t, ok) + dns, ok := challenges["dns"].(map[string]any) + require.True(t, ok) + require.Equal(t, int64(120)*1_000_000_000, dns["propagation_timeout"]) +} + +func TestGenerateConfig_DNSChallenge_ZeroSSL_IssuerShape(t *testing.T) { + providerID := uint(2) + host := models.ProxyHost{ + Enabled: true, + DomainNames: "*.example.net", + DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"}, + DNSProviderID: func() *uint { v := providerID; return &v }(), + } + + conf, err := GenerateConfig( + []models.ProxyHost{host}, + t.TempDir(), + "acme@example.com", + "", + "zerossl", + false, + false, false, false, false, + "", + nil, + nil, + nil, + &models.SecurityConfig{}, + []DNSProviderConfig{{ + ID: providerID, + ProviderType: "cloudflare", + PropagationTimeout: 5, + Credentials: map[string]string{"api_token": "tok"}, + }}, + ) + require.NoError(t, err) + require.NotNil(t, conf) + require.NotNil(t, conf.Apps.TLS) + require.NotEmpty(t, conf.Apps.TLS.Automation.Policies) + + // Expect at least one issuer with module zerossl + found := false + for _, p := range conf.Apps.TLS.Automation.Policies { + if p == nil { + continue + } + for _, it := range p.IssuersRaw { + if m, ok := it.(map[string]any); ok { + if m["module"] == "zerossl" { + found = true + } + } + } + } + require.True(t, found) +} + +func TestGenerateConfig_DNSChallenge_SkipsPolicyWhenProviderConfigMissing(t *testing.T) { + providerID := uint(3) + host := models.ProxyHost{ + Enabled: true, + DomainNames: "*.example.org", + DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"}, + DNSProviderID: func() *uint { v := providerID; return &v }(), + } + + conf, err := GenerateConfig( + []models.ProxyHost{host}, + t.TempDir(), + "acme@example.com", + "", + "letsencrypt", + false, + false, false, false, false, + "", + nil, + nil, + nil, + &models.SecurityConfig{}, + nil, // no provider configs available + ) + require.NoError(t, err) + require.NotNil(t, conf) + require.NotNil(t, conf.Apps.TLS) + require.NotEmpty(t, conf.Apps.TLS.Automation.Policies) + + // No policy should include the wildcard subject since provider config was missing + for _, p := range conf.Apps.TLS.Automation.Policies { + if p == nil { + continue + } + for _, s := range p.Subjects { + require.NotEqual(t, "*.example.org", s) + } + } +} + +func TestGenerateConfig_HTTPChallenge_ExcludesIPDomains(t *testing.T) { + host := models.ProxyHost{Enabled: true, DomainNames: "example.com,192.168.1.1"} + + conf, err := GenerateConfig( + []models.ProxyHost{host}, + t.TempDir(), + "acme@example.com", + "", + "letsencrypt", + false, + false, false, false, false, + "", + nil, + nil, + nil, + &models.SecurityConfig{}, + nil, + ) + require.NoError(t, err) + require.NotNil(t, conf) + require.NotNil(t, conf.Apps.TLS) + require.NotEmpty(t, conf.Apps.TLS.Automation.Policies) + + for _, p := range conf.Apps.TLS.Automation.Policies { + if p == nil { + continue + } + for _, s := range p.Subjects { + require.NotEqual(t, "192.168.1.1", s) + } + } +} + +func TestGetCrowdSecAPIKey_EnvPriority(t *testing.T) { + os.Unsetenv("CROWDSEC_API_KEY") + os.Unsetenv("CROWDSEC_BOUNCER_API_KEY") + + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "bouncer") + t.Setenv("CROWDSEC_API_KEY", "primary") + require.Equal(t, "primary", getCrowdSecAPIKey()) + + os.Unsetenv("CROWDSEC_API_KEY") + require.Equal(t, "bouncer", getCrowdSecAPIKey()) +} + +func TestHasWildcard_TrueFalse(t *testing.T) { + require.True(t, hasWildcard([]string{"*.example.com"})) + require.False(t, hasWildcard([]string{"example.com"})) +} diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index 83a72246..912b1528 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -73,7 +73,7 @@ func TestManager_Rollback_LoadSnapshotFail(t *testing.T) { })) defer server.Close() - badClient := NewClient(server.URL) + badClient := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) manager := NewManager(badClient, nil, tmp, "", false, config.SecurityConfig{}) err := manager.rollback(context.Background()) assert.Error(t, err) @@ -142,7 +142,7 @@ func TestManager_ApplyConfig_WithSettings(t *testing.T) { // Setup Manager tmpDir := t.TempDir() - client := NewClient(caddyServer.URL) + client := NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) // Create a host @@ -245,7 +245,7 @@ func TestManager_ApplyConfig_RotateSnapshotsWarning(t *testing.T) { os.Chtimes(p, tmo, tmo) } - client := NewClient(caddyServer.URL) + client := NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) @@ -281,7 +281,7 @@ func TestManager_ApplyConfig_LoadFailsAndRollbackFails(t *testing.T) { db.Create(&host) tmp := t.TempDir() - client := NewClient(server.URL) + client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL)) manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{}) err = manager.ApplyConfig(context.Background()) @@ -320,7 +320,7 @@ func TestManager_ApplyConfig_SaveSnapshotFails(t *testing.T) { filePath := filepath.Join(tmp, "file-not-dir") os.WriteFile(filePath, []byte("data"), 0o644) - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) manager := NewManager(client, db, filePath, "", false, config.SecurityConfig{}) err = manager.ApplyConfig(context.Background()) @@ -360,7 +360,7 @@ func TestManager_ApplyConfig_LoadFailsThenRollbackSucceeds(t *testing.T) { db.Create(&host) tmp := t.TempDir() - client := NewClient(server.URL) + client := newTestClient(t, server.URL) manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{}) err = manager.ApplyConfig(context.Background()) @@ -462,7 +462,7 @@ func TestManager_ApplyConfig_WarnsWhenCerberusEnabledWithoutAdminWhitelist(t *te defer caddyServer.Close() // Create manager and call ApplyConfig - should now warn but proceed (no error) - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{}) err = manager.ApplyConfig(context.Background()) // The call should succeed (or fail for other reasons, not the admin whitelist check) @@ -503,7 +503,7 @@ func TestManager_ApplyConfig_ValidateFails(t *testing.T) { })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{}) err = manager.ApplyConfig(context.Background()) @@ -556,7 +556,7 @@ func TestManager_ApplyConfig_RotateSnapshotsWarning_Stderr(t *testing.T) { readDirFunc = func(path string) ([]os.DirEntry, error) { return nil, fmt.Errorf("dir read fail") } defer func() { readDirFunc = origReadDir }() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) manager := NewManager(client, db, t.TempDir(), "", false, config.SecurityConfig{}) err = manager.ApplyConfig(context.Background()) // Should succeed despite rotation warning (non-fatal) @@ -593,7 +593,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T) w.WriteHeader(http.StatusNotFound) })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) // Stub generateConfigFunc to capture adminWhitelist var capturedAdmin string @@ -645,7 +645,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) { })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) var capturedRules []models.SecurityRuleSet orig := generateConfigFunc @@ -698,7 +698,7 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) // Capture wafEnabled and rulesets passed into GenerateConfig var capturedWafEnabled bool @@ -794,7 +794,7 @@ func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) { })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) // Stub writeFileFunc to return an error for coraza ruleset files only to exercise the warn branch origWrite := writeFileFunc @@ -854,7 +854,7 @@ func TestManager_ApplyConfig_RulesetDirMkdirFailure(t *testing.T) { })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) // Use tmp as configDir and we already have a file at tmp/coraza which should make MkdirAll to create rulesets fail manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) // This should not error (failures to create coraza dir are warned only) @@ -893,7 +893,7 @@ func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) { // Ensure DB setting is not present so ACL disabled by default // Manager default SecurityConfig has ACLMode disabled tmpDir := t.TempDir() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "disabled", WAFMode: "disabled", RateLimitMode: "disabled", CrowdSecMode: "disabled"} manager := NewManager(client, db, tmpDir, "", false, secCfg) @@ -1048,7 +1048,7 @@ func TestManager_ApplyConfig_PrependsSecRuleEngineDirectives(t *testing.T) { })) defer caddyServer.Close() - client := NewClient(caddyServer.URL) + client := newTestClient(t, caddyServer.URL) // Capture written file content var writtenContent []byte @@ -1107,7 +1107,7 @@ SecRule REQUEST_BODY "