Files
Charon/docs/security/ssrf-protection.md
GitHub Actions e0f69cdfc8 feat(security): comprehensive SSRF protection implementation
BREAKING CHANGE: UpdateService.SetAPIURL() now returns error

Implements defense-in-depth SSRF protection across all user-controlled URLs:

Security Fixes:
- CRITICAL: Fixed security notification webhook SSRF vulnerability
- CRITICAL: Added GitHub domain allowlist for update service
- HIGH: Protected CrowdSec hub URLs with domain allowlist
- MEDIUM: Validated CrowdSec LAPI URLs (localhost-only)

Implementation:
- Created /backend/internal/security/url_validator.go (90.4% coverage)
- Blocks 13+ private IP ranges and cloud metadata endpoints
- DNS resolution with timeout and IP validation
- Comprehensive logging of SSRF attempts (HIGH severity)
- Defense-in-depth: URL format → DNS → IP → Request execution

Testing:
- 62 SSRF-specific tests covering all attack vectors
- 255 total tests passing (84.8% coverage)
- Zero security vulnerabilities (Trivy, go vuln check)
- OWASP A10 compliant

Documentation:
- Comprehensive security guide (docs/security/ssrf-protection.md)
- Manual test plan (30 test cases)
- Updated API docs, README, SECURITY.md, CHANGELOG

Security Impact:
- Pre-fix: CVSS 8.6 (HIGH) - Exploitable SSRF
- Post-fix: CVSS 0.0 (NONE) - Vulnerability eliminated

Refs: #450 (beta release)
See: docs/plans/ssrf_remediation_spec.md for full specification
2025-12-23 15:09:22 +00:00

30 KiB

SSRF Protection in Charon

Overview

Server-Side Request Forgery (SSRF) is a critical web security vulnerability where an attacker can abuse server functionality to access or manipulate internal resources. Charon implements comprehensive defense-in-depth SSRF protection across all features that accept user-controlled URLs.

What is SSRF?

SSRF occurs when an application fetches a remote resource based on user input without validating the destination. Attackers exploit this to:

  • Access internal network resources (databases, admin panels, internal APIs)
  • Retrieve cloud provider metadata (AWS, Azure, GCP credentials)
  • Scan internal networks and enumerate services
  • Bypass firewalls and access control lists
  • Exfiltrate sensitive data through DNS or HTTP requests

Why It's Critical

SSRF vulnerabilities are classified as OWASP A10:2021 (Server-Side Request Forgery) and have a typical CVSS score of 8.6 (HIGH) due to:

  • Network-based exploitation: Attacks originate from the internet
  • Low attack complexity: Easy to exploit with basic HTTP knowledge
  • High impact: Can expose internal infrastructure and sensitive data
  • Difficult detection: May appear as legitimate server behavior in logs

How Charon Protects Against SSRF

Charon implements four layers of defense:

  1. URL Format Validation: Ensures URLs follow expected patterns and schemes
  2. DNS Resolution: Resolves hostnames to IP addresses with timeout protection
  3. IP Range Validation: Blocks access to private, reserved, and sensitive IP ranges
  4. Request Execution: Enforces timeouts, limits redirects, and logs all attempts

Protected Endpoints

Charon validates all user-controlled URLs in the following features:

1. Security Notification Webhooks

Endpoint: POST /api/v1/settings/security/webhook

Protection: All webhook URLs are validated before saving to prevent SSRF attacks when security events trigger notifications.

Validation on:

  • Configuration save (fail-fast)
  • Notification delivery (defense-in-depth)

Example Valid URL:

{
  "webhook_url": "https://hooks.slack.com/services/T00/B00/XXX"
}

Blocked URLs:

  • Private IPs: http://192.168.1.1/admin
  • Cloud metadata: http://169.254.169.254/latest/meta-data/
  • Internal hostnames: http://internal-db.local:3306/

2. Custom Webhook Notifications

Endpoint: POST /api/v1/notifications/custom-webhook

Protection: Custom webhooks for alerts, monitoring, and integrations are validated to prevent attackers from using Charon to probe internal networks.

Use Case: Send notifications to Discord, Slack, or custom monitoring systems.

Example Valid URL:

{
  "webhook_url": "https://discord.com/api/webhooks/123456/abcdef"
}

3. URL Connectivity Testing

Endpoint: POST /api/v1/settings/test-url

Protection: Admin-only endpoint for testing URL reachability. All tested URLs undergo SSRF validation to prevent network scanning.

Admin Access Required: Yes (prevents abuse by non-privileged users)

Example Usage:

curl -X POST http://localhost:8080/api/v1/settings/test-url \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <admin-token>" \
  -d '{"url": "https://api.example.com"}'

4. CrowdSec Hub Synchronization

Feature: CrowdSec security rules and hub data retrieval

Protection: CrowdSec hub URLs are validated against an allowlist of official hub domains. Custom hub URLs require HTTPS.

Allowed Domains:

  • hub-data.crowdsec.net (official hub)
  • raw.githubusercontent.com (official mirror)
  • *.example.com, *.local (test domains, testing only)

Blocked: Any URL not matching the allowlist or using insecure protocols.


5. Update Service

Feature: Checking for new Charon releases

Protection: GitHub API URLs are validated to ensure only official GitHub domains are queried. Prevents SSRF via update check manipulation.

Allowed Domains:

  • api.github.com
  • github.com

Protocol: HTTPS only


Blocked Destinations

Charon blocks 13+ IP ranges to prevent SSRF attacks:

Private IP Ranges (RFC 1918)

Purpose: Block access to internal networks

CIDR Description Example
10.0.0.0/8 Class A private network 10.0.0.1
172.16.0.0/12 Class B private network 172.16.0.1
192.168.0.0/16 Class C private network 192.168.1.1

Attack Example:

# Attacker attempts to access internal database
curl -X POST /api/v1/settings/security/webhook \
  -d '{"webhook_url": "http://192.168.1.100:5432/"}'

# Response: 400 Bad Request
# Error: "URL resolves to a private IP address (blocked for security)"

Cloud Metadata Endpoints

Purpose: Block access to cloud provider metadata services

IP Address Provider Contains
169.254.169.254 AWS, Azure, Oracle Instance credentials, API keys
metadata.google.internal Google Cloud Service account tokens
100.100.100.200 Alibaba Cloud Instance metadata

Attack Example:

# Attacker attempts AWS metadata access
curl -X POST /api/v1/settings/security/webhook \
  -d '{"webhook_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'

# Response: 400 Bad Request
# Error: "URL resolves to a private IP address (blocked for security)"

Why This Matters: Cloud metadata endpoints expose:

  • IAM role credentials (AWS access keys)
  • Service account tokens (GCP)
  • Managed identity credentials (Azure)
  • Instance metadata (hostnames, IPs, security groups)

Loopback Addresses

Purpose: Prevent access to services running on the Charon host itself

CIDR Description Example
127.0.0.0/8 IPv4 loopback 127.0.0.1
::1/128 IPv6 loopback ::1

Attack Example:

# Attacker attempts to access Charon's internal API
curl -X POST /api/v1/settings/security/webhook \
  -d '{"webhook_url": "http://127.0.0.1:8080/admin/backdoor"}'

# Response: 400 Bad Request (unless localhost explicitly allowed for testing)

Purpose: Block non-routable addresses used for local network discovery

CIDR Description Example
169.254.0.0/16 IPv4 link-local (APIPA) 169.254.1.1
fe80::/10 IPv6 link-local fe80::1

Reserved and Special Addresses

Purpose: Block addresses with special routing or security implications

CIDR Description Risk
0.0.0.0/8 Current network Undefined behavior
240.0.0.0/4 Reserved for future use Non-routable
255.255.255.255/32 Broadcast Network scan
fc00::/7 IPv6 unique local Private network

Validation Process

Charon uses a four-stage validation pipeline for all user-controlled URLs:

Stage 1: URL Format Validation

Checks:

  • URL is not empty
  • URL parses correctly (valid syntax)
  • Scheme is http or https only
  • Hostname is present and non-empty
  • No credentials in URL (e.g., http://user:pass@example.com)

Blocked Schemes:

  • file:// (file system access)
  • ftp:// (FTP protocol smuggling)
  • gopher:// (legacy protocol exploitation)
  • data: (data URI bypass)
  • javascript: (XSS/code injection)

Example Validation Failure:

// Blocked: Invalid scheme
err := ValidateExternalURL("file:///etc/passwd")
// Error: ErrInvalidScheme ("URL must use HTTP or HTTPS")

Stage 2: DNS Resolution

Checks:

  • Hostname resolves via DNS (3-second timeout)
  • At least one IP address returned
  • Handles both IPv4 and IPv6 addresses
  • Prevents DNS timeout attacks

Protection Against:

  • Non-existent domains (typosquatting)
  • DNS timeout DoS attacks
  • DNS rebinding (resolved IPs checked immediately before request)

Example:

// Resolve hostname to IPs
ips, err := net.LookupIP("webhook.example.com")
// Result: [203.0.113.10, 2001:db8::1]

// Check each IP against blocklist
for _, ip := range ips {
    if isPrivateIP(ip) {
        return ErrPrivateIP
    }
}

Stage 3: IP Range Validation

Checks:

  • ALL resolved IPs are checked (not just the first)
  • Private IP ranges blocked (13+ CIDR blocks)
  • IPv4 and IPv6 support
  • Cloud metadata endpoints blocked
  • Loopback and link-local addresses blocked

Validation Logic:

func isPrivateIP(ip net.IP) bool {
    // Check loopback and link-local first (fast path)
    if ip.IsLoopback() || ip.IsLinkLocalUnicast() {
        return true
    }

    // Check against 13+ CIDR ranges
    privateBlocks := []string{
        "10.0.0.0/8",           // RFC 1918
        "172.16.0.0/12",        // RFC 1918
        "192.168.0.0/16",       // RFC 1918
        "169.254.0.0/16",       // Link-local (AWS metadata)
        "127.0.0.0/8",          // Loopback
        "0.0.0.0/8",            // Current network
        "240.0.0.0/4",          // Reserved
        "255.255.255.255/32",   // Broadcast
        "::1/128",              // IPv6 loopback
        "fc00::/7",             // IPv6 unique local
        "fe80::/10",            // IPv6 link-local
    }

    for _, block := range privateBlocks {
        _, subnet, _ := net.ParseCIDR(block)
        if subnet.Contains(ip) {
            return true
        }
    }

    return false
}

Stage 4: Request Execution

Checks:

  • Use validated IP explicitly (bypass DNS caching attacks)
  • Set timeout (5-10 seconds, context-based)
  • Limit redirects (0-2 maximum)
  • Log all SSRF attempts with HIGH severity

HTTP Client Configuration:

client := &http.Client{
    Timeout: 10 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        if len(via) >= 2 {
            return fmt.Errorf("stopped after 2 redirects")
        }
        return nil
    },
}

Testing Exceptions

Localhost Allowance

Purpose: Enable local development and integration testing

When Allowed:

  • URL connectivity testing with WithAllowLocalhost() option
  • Development environment (CHARON_ENV=development)
  • Explicit test fixtures in test code

Allowed Addresses:

  • localhost (hostname)
  • 127.0.0.1 (IPv4)
  • ::1 (IPv6)

Example:

// Production: Blocked
err := ValidateExternalURL("http://localhost:8080/admin")
// Error: ErrLocalhostNotAllowed

// Testing: Allowed with option
err := ValidateExternalURL("http://localhost:8080/admin", WithAllowLocalhost())
// Success: nil error

Security Note: Localhost exception is NEVER enabled in production. Build tags and environment checks enforce this.


When to Use WithAllowLocalhost

Safe Uses:

  1. Unit tests: Testing validation logic with mock servers
  2. Integration tests: Testing against local test fixtures
  3. Development mode: Local webhooks for debugging (with explicit flag)

Unsafe Uses:

  1. Production webhook configurations
  2. User-facing features without strict access control
  3. Any scenario where an attacker controls the URL

Security Implications

Why Localhost is Dangerous:

  1. Access to Internal Services: Charon may have access to:

    • Database ports (PostgreSQL, MySQL, Redis)
    • Admin panels on localhost
    • Monitoring systems (Prometheus, Grafana)
    • Container orchestration APIs (Docker, Kubernetes)
  2. Port Scanning: Attacker can enumerate services:

    # Scan internal ports via error messages
    http://localhost:22/    # SSH (connection refused)
    http://localhost:3306/  # MySQL (timeout vs refuse reveals service)
    http://localhost:8080/  # HTTP service (200 OK)
    
  3. Bypass Firewall: Internal services often trust localhost connections without authentication.


Configuration Examples

Safe Webhook URLs

Production-Ready Examples:

// Slack webhook (HTTPS, public IP)
{
  "webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
}

// Discord webhook (HTTPS, public IP)
{
  "webhook_url": "https://discord.com/api/webhooks/1234567890/abcdefghijklmnop"
}

// Custom webhook with HTTPS
{
  "webhook_url": "https://webhooks.example.com/charon/security-alerts"
}

// Webhook with port (HTTPS)
{
  "webhook_url": "https://alerts.company.com:8443/webhook/receive"
}

Why These Are Safe:

  • Use HTTPS (encrypted, authenticated)
  • Resolve to public IP addresses
  • Operated by known, trusted services
  • Follow industry standard webhook patterns

Blocked Webhook URLs

Dangerous Examples (All Rejected):

// BLOCKED: Private IP (RFC 1918)
{
  "webhook_url": "http://192.168.1.100/webhook"
}
// Error: "URL resolves to a private IP address (blocked for security)"

// BLOCKED: AWS metadata endpoint
{
  "webhook_url": "http://169.254.169.254/latest/meta-data/"
}
// Error: "URL resolves to a private IP address (blocked for security)"

// BLOCKED: Loopback address
{
  "webhook_url": "http://127.0.0.1:8080/internal-api"
}
// Error: "localhost URLs are not allowed (blocked for security)"

// BLOCKED: Internal hostname (resolves to private IP)
{
  "webhook_url": "http://internal-db.company.local:5432/"
}
// Error: "URL resolves to a private IP address (blocked for security)"

// BLOCKED: File protocol
{
  "webhook_url": "file:///etc/passwd"
}
// Error: "URL must use HTTP or HTTPS"

// BLOCKED: FTP protocol
{
  "webhook_url": "ftp://internal-ftp.local/upload/"
}
// Error: "URL must use HTTP or HTTPS"

Why These Are Blocked:

  • Expose internal network resources
  • Allow cloud metadata access (credentials leak)
  • Enable protocol smuggling
  • Bypass network security controls
  • Risk data exfiltration

Security Considerations

DNS Rebinding Protection

Attack: DNS record changes between validation and request execution

Charon's Defense:

  1. Resolve hostname immediately before HTTP request
  2. Check ALL resolved IPs against blocklist
  3. Use explicit IP in HTTP client (bypass DNS cache)

Example Attack Scenario:

Time T0: Attacker configures webhook: http://evil.com/webhook
  DNS Resolution: evil.com → 203.0.113.10 (public IP, passes validation)

Time T1: Charon saves configuration (validation passes)

Time T2: Attacker changes DNS record
  New DNS: evil.com → 192.168.1.100 (private IP)

Time T3: Security event triggers webhook
  Charon Re-Validates: Resolves evil.com again
  Result: 192.168.1.100 detected, request BLOCKED

Protection Mechanism:

// Validation happens at configuration save AND request time
func (s *SecurityNotificationService) sendWebhook(ctx context.Context, webhookURL string, event models.SecurityEvent) error {
    // RE-VALIDATE on every use (defense against DNS rebinding)
    validatedURL, err := security.ValidateExternalURL(webhookURL,
        security.WithAllowLocalhost(),  // Only for testing
    )
    if err != nil {
        log.Warn("Webhook URL failed SSRF validation (possible DNS rebinding)")
        return err
    }

    // Make HTTP request
    req, _ := http.NewRequestWithContext(ctx, "POST", validatedURL, bytes.NewBuffer(payload))
    resp, err := client.Do(req)
}

Time-of-Check-Time-of-Use (TOCTOU)

Attack: Race condition between validation and request

Charon's Defense:

  • Validation and DNS resolution happen in a single transaction
  • HTTP request uses resolved IP (no re-resolution)
  • Timeout prevents long-running requests that could stall validation

Redirect Following

Attack: Initial URL is valid, but redirects to private IP

Charon's Defense:

client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // Limit to 2 redirects maximum
        if len(via) >= 2 {
            return fmt.Errorf("stopped after 2 redirects")
        }
        // TODO: Validate redirect target URL (future enhancement)
        return nil
    },
}

Future Enhancement: Validate each redirect target against the blocklist before following.


Error Message Security

Problem: Detailed error messages can leak internal information

Charon's Approach: Generic user-facing errors, detailed server logs

User-Facing Error:

{
  "error": "URL resolves to a private IP address (blocked for security)"
}

Server Log:

level=HIGH msg="Blocked SSRF attempt" url="http://192.168.1.100/admin" resolved_ip="192.168.1.100" user_id="admin123" timestamp="2025-12-23T10:30:00Z"

What's Hidden:

  • Internal IP addresses (except in validation context)
  • Network topology details
  • Service names or ports
  • DNS resolution details

What's Revealed:

  • Validation failure reason (security context)
  • User-friendly explanation
  • Security justification

Troubleshooting

Common Error Messages

"URL resolves to a private IP address (blocked for security)"

Cause: The URL's hostname resolves to a private IP range (RFC 1918, loopback, link-local).

Solutions:

  1. Use a publicly accessible webhook endpoint
  2. If webhook must be internal, use a public-facing gateway/proxy
  3. For development, use a service like ngrok or localtunnel

Example Fix:

# BAD: Internal IP
http://192.168.1.100/webhook

# GOOD: Public endpoint
https://webhooks.example.com/charon-alerts

# GOOD: ngrok tunnel (development only)
https://abc123.ngrok.io/webhook

"localhost URLs are not allowed (blocked for security)"

Cause: The URL uses localhost, 127.0.0.1, or ::1 without the localhost exception enabled.

Solutions:

  1. Use a public webhook service (Slack, Discord, etc.)
  2. Deploy webhook receiver on a public server
  3. For testing, use test URL endpoint with admin privileges

Example Fix:

# BAD: Localhost
http://localhost:3000/webhook

# GOOD: Production webhook
https://hooks.example.com/webhook

# TESTING: Use test endpoint
curl -X POST /api/v1/settings/test-url \
  -H "Authorization: Bearer <admin-token>" \
  -d '{"url": "http://localhost:3000"}'

"URL must use HTTP or HTTPS"

Cause: The URL uses a non-HTTP scheme (file://, ftp://, gopher://, etc.).

Solution: Change the URL scheme to http:// or https://.

Example Fix:

# BAD: File protocol
file:///etc/passwd

# GOOD: HTTPS
https://api.example.com/webhook

"DNS lookup failed" or "Connection timeout"

Cause: Hostname does not resolve, or the server is unreachable.

Solutions:

  1. Verify the domain exists and is publicly resolvable
  2. Check for typos in the URL
  3. Ensure the webhook receiver is running and accessible
  4. Test connectivity with curl or ping from Charon's network

Example Debugging:

# Test DNS resolution
nslookup webhooks.example.com

# Test HTTP connectivity
curl -I https://webhooks.example.com/webhook

# Test from Charon container
docker exec charon curl -I https://webhooks.example.com/webhook

How to Debug Validation Failures

Step 1: Check Server Logs

# View recent logs
docker logs charon --tail=100 | grep -i ssrf

# Look for validation errors
docker logs charon --tail=100 | grep "Webhook URL failed SSRF validation"

Step 2: Test URL Manually

# Use the test URL endpoint
curl -X POST http://localhost:8080/api/v1/settings/test-url \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <admin-token>" \
  -d '{"url": "https://your-webhook-url.com"}'

# Response will show reachability and latency
{
  "reachable": true,
  "latency": 145,
  "message": "URL is reachable"
}

Step 3: Verify DNS Resolution

# Check what IPs the domain resolves to
nslookup your-webhook-domain.com

# If any IP is private, validation will fail

Step 4: Check for Private IPs

Use an IP lookup tool to verify the resolved IP is public:

# Check if IP is private (Python)
python3 << EOF
import ipaddress
ip = ipaddress.ip_address('203.0.113.10')
print(f"Private: {ip.is_private}")
EOF

When to Contact Security Team

Report SSRF bypasses if you can:

  1. Access private IPs despite validation
  2. Retrieve cloud metadata endpoints
  3. Use protocol smuggling to bypass scheme checks
  4. Exploit DNS rebinding despite re-validation
  5. Bypass IP range checks with IPv6 or encoding tricks

How to Report:


Developer Guidelines

How to Use ValidateExternalURL

Import:

import "github.com/Wikid82/charon/backend/internal/security"

Basic Usage:

func SaveWebhookConfig(webhookURL string) error {
    // Validate before saving to database
    validatedURL, err := security.ValidateExternalURL(webhookURL)
    if err != nil {
        return fmt.Errorf("invalid webhook URL: %w", err)
    }

    // Save validatedURL to database
    return db.Save(validatedURL)
}

With Options:

// Allow HTTP (not recommended for production)
validatedURL, err := security.ValidateExternalURL(webhookURL,
    security.WithAllowHTTP(),
)

// Allow localhost (testing only)
validatedURL, err := security.ValidateExternalURL(webhookURL,
    security.WithAllowLocalhost(),
)

// Custom timeout
validatedURL, err := security.ValidateExternalURL(webhookURL,
    security.WithTimeout(5 * time.Second),
)

// Multiple options
validatedURL, err := security.ValidateExternalURL(webhookURL,
    security.WithAllowLocalhost(),
    security.WithAllowHTTP(),
    security.WithTimeout(3 * time.Second),
)

Configuration Options

Available Options:

// WithAllowHTTP permits HTTP scheme (default: HTTPS only)
func WithAllowHTTP() ValidationOption

// WithAllowLocalhost permits localhost/127.0.0.1/::1 (default: blocked)
func WithAllowLocalhost() ValidationOption

// WithTimeout sets DNS resolution timeout (default: 3 seconds)
func WithTimeout(timeout time.Duration) ValidationOption

When to Use Each Option:

Option Use Case Security Impact
WithAllowHTTP() Legacy webhooks without HTTPS ⚠️ MEDIUM - Exposes traffic to eavesdropping
WithAllowLocalhost() Local testing, integration tests 🔴 HIGH - Exposes internal services
WithTimeout() Custom DNS timeout for slow networks LOW - No security impact

Best Practices

1. Validate Early (Fail-Fast Principle)

// ✅ GOOD: Validate at configuration time
func CreateWebhookConfig(c *gin.Context) {
    var req struct {
        WebhookURL string `json:"webhook_url"`
    }

    c.ShouldBindJSON(&req)

    // Validate BEFORE saving
    if _, err := security.ValidateExternalURL(req.WebhookURL); err != nil {
        c.JSON(400, gin.H{"error": fmt.Sprintf("Invalid webhook URL: %v", err)})
        return
    }

    // Save to database
    db.Save(req.WebhookURL)
}

// ❌ BAD: Validate only at use time
func SendWebhook(webhookURL string, data []byte) error {
    // Saved URL might be invalid, fails at runtime
    validatedURL, err := security.ValidateExternalURL(webhookURL)
    if err != nil {
        return err  // Too late, user already saved invalid config
    }

    http.Post(validatedURL, "application/json", bytes.NewBuffer(data))
}

2. Re-Validate at Use Time (Defense in Depth)

func SendWebhook(webhookURL string, data []byte) error {
    // Even if validated at save time, re-validate at use
    // Protects against DNS rebinding
    validatedURL, err := security.ValidateExternalURL(webhookURL)
    if err != nil {
        log.WithFields(log.Fields{
            "url": webhookURL,
            "error": err.Error(),
        }).Warn("Webhook URL failed re-validation (possible DNS rebinding)")
        return err
    }

    // Make request
    resp, err := http.Post(validatedURL, "application/json", bytes.NewBuffer(data))
    return err
}

3. Log SSRF Attempts with HIGH Severity

func SaveWebhookConfig(webhookURL string) error {
    validatedURL, err := security.ValidateExternalURL(webhookURL)
    if err != nil {
        // Log potential SSRF attempt
        log.WithFields(log.Fields{
            "severity": "HIGH",
            "event_type": "ssrf_blocked",
            "url": webhookURL,
            "error": err.Error(),
            "user_id": getCurrentUserID(),
            "timestamp": time.Now().UTC(),
        }).Warn("Blocked SSRF attempt in webhook configuration")

        return fmt.Errorf("invalid webhook URL: %w", err)
    }

    return db.Save(validatedURL)
}

4. Never Trust User Input

// ❌ BAD: No validation
func FetchRemoteConfig(url string) ([]byte, error) {
    resp, err := http.Get(url)  // SSRF vulnerability!
    if err != nil {
        return nil, err
    }
    return ioutil.ReadAll(resp.Body)
}

// ✅ GOOD: Always validate
func FetchRemoteConfig(url string) ([]byte, error) {
    // Validate first
    validatedURL, err := security.ValidateExternalURL(url)
    if err != nil {
        return nil, fmt.Errorf("invalid URL: %w", err)
    }

    // Use validated URL
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", validatedURL, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return ioutil.ReadAll(resp.Body)
}

Code Examples

Example 1: Webhook Configuration Handler

func (h *SettingsHandler) SaveSecurityWebhook(c *gin.Context) {
    var req struct {
        WebhookURL string `json:"webhook_url" binding:"required"`
    }

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "webhook_url is required"})
        return
    }

    // Validate webhook URL (fail-fast)
    validatedURL, err := security.ValidateExternalURL(req.WebhookURL,
        security.WithAllowHTTP(),  // Some webhooks use HTTP
    )
    if err != nil {
        c.JSON(400, gin.H{"error": fmt.Sprintf("Invalid webhook URL: %v", err)})
        return
    }

    // Save to database
    if err := h.db.SaveWebhookConfig(validatedURL); err != nil {
        c.JSON(500, gin.H{"error": "Failed to save configuration"})
        return
    }

    c.JSON(200, gin.H{"message": "Webhook configured successfully"})
}

Example 2: Webhook Notification Sender

func (s *NotificationService) SendWebhook(ctx context.Context, event SecurityEvent) error {
    // Get configured webhook URL from database
    webhookURL, err := s.db.GetWebhookURL()
    if err != nil {
        return fmt.Errorf("get webhook URL: %w", err)
    }

    // Re-validate (defense against DNS rebinding)
    validatedURL, err := security.ValidateExternalURL(webhookURL,
        security.WithAllowHTTP(),
    )
    if err != nil {
        log.WithFields(log.Fields{
            "severity": "HIGH",
            "event": "ssrf_blocked",
            "url": webhookURL,
            "error": err.Error(),
        }).Warn("Webhook URL failed SSRF re-validation")
        return fmt.Errorf("webhook URL validation failed: %w", err)
    }

    // Marshal event to JSON
    payload, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("marshal event: %w", err)
    }

    // Create HTTP request with timeout
    req, err := http.NewRequestWithContext(ctx, "POST", validatedURL, bytes.NewBuffer(payload))
    if err != nil {
        return fmt.Errorf("create request: %w", err)
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("User-Agent", "Charon-Webhook/1.0")

    // Execute request
    client := &http.Client{
        Timeout: 10 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            if len(via) >= 2 {
                return fmt.Errorf("stopped after 2 redirects")
            }
            return nil
        },
    }

    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("execute request: %w", err)
    }
    defer resp.Body.Close()

    // Check response
    if resp.StatusCode >= 400 {
        return fmt.Errorf("webhook returned error: %d", resp.StatusCode)
    }

    log.WithFields(log.Fields{
        "event_type": event.Type,
        "status_code": resp.StatusCode,
        "url": validatedURL,
    }).Info("Webhook notification sent successfully")

    return nil
}

Reporting Security Issues

Found a way to bypass SSRF protection? We want to know!

What to Report

We're interested in:

  • SSRF bypasses: Any method to access private IPs or cloud metadata
  • DNS rebinding attacks: Ways to change DNS after validation
  • Protocol smuggling: Bypassing scheme restrictions
  • Redirect exploitation: Following redirects to private IPs
  • Encoding bypasses: IPv6, URL encoding, or obfuscation tricks

What NOT to Report

  • Localhost exception in testing: This is intentional and documented
  • Error message content: Generic errors are intentional (no info leak)
  • Rate limiting: Not yet implemented (future feature)

How to Report

Preferred Method: GitHub Security Advisory

  1. Go to https://github.com/Wikid82/charon/security/advisories/new
  2. Provide:
    • Steps to reproduce
    • Proof of concept (non-destructive)
    • Expected vs. actual behavior
    • Impact assessment
  3. We'll respond within 48 hours

Alternative Method: Email

  • Send to: security@charon.example.com (if configured)
  • Encrypt with PGP key (if available in SECURITY.md)
  • Include same information as GitHub advisory

Responsible Disclosure

Please:

  • Give us time to fix before public disclosure (90 days)
  • Provide clear reproduction steps
  • Avoid destructive testing (don't attack real infrastructure)

We'll:

  • Acknowledge your report within 48 hours
  • Provide regular status updates
  • Credit you in release notes (if desired)
  • Consider security bounty (if applicable)

Additional Resources


Document Version: 1.0 Last Updated: December 23, 2025 Status: Production Maintained By: Charon Security Team