Files
Charon/docs/security/ssrf-protection.md
2026-01-13 22:11:35 +00:00

39 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.

Status: CodeQL CWE-918 Resolved (PR #450)

  • Taint chain break verified via static analysis
  • Test coverage: 90.2% for URL validation utilities
  • Zero security vulnerabilities (Trivy, govulncheck clean)
  • See PR #450 Implementation Summary for details

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 a three-layer defense-in-depth strategy using two complementary packages:

Defense Layer Architecture

Layer Component Function Prevents
Layer 1 security.ValidateExternalURL() Pre-validates URL format, scheme, and DNS resolution Malformed URLs, blocked protocols, obvious private IPs
Layer 2 network.NewSafeHTTPClient() Connection-time IP validation in custom dialer DNS rebinding, TOCTOU attacks, cached DNS exploits
Layer 3 Redirect validation Each redirect target validated before following Redirect-based SSRF bypasses

Validation Flow

User URL Input
      │
      ▼
┌─────────────────────────────────────┐
│  Layer 1: security.ValidateExternalURL()   │
│  - Parse URL (scheme, host, path)   │
│  - Reject non-HTTP(S) schemes       │
│  - Resolve DNS with timeout         │
│  - Check ALL IPs against blocklist  │
└───────────────┬─────────────────────┘
                │ ✓ Passes validation
                ▼
┌─────────────────────────────────────┐
│  Layer 2: network.NewSafeHTTPClient()      │
│  - Custom safeDialer() at TCP level │
│  - Re-resolve DNS immediately       │
│  - Re-validate ALL resolved IPs     │
│  - Connect to validated IP directly │
└───────────────┬─────────────────────┘
                │ ✓ Connection allowed
                ▼
┌─────────────────────────────────────┐
│  Layer 3: Redirect Validation       │
│  - Check redirect count (max 2)     │
│  - Validate redirect target URL     │
│  - Re-run IP validation on target   │
└───────────────┬─────────────────────┘
                │ ✓ Response received
                ▼
         Safe Response

This layered approach ensures that even if an attacker bypasses URL validation through DNS rebinding or cache manipulation, the connection-time validation will catch the attack.


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

Using network.NewSafeHTTPClient()

For runtime HTTP requests where SSRF protection must be enforced at connection time (defense-in-depth), use the network.NewSafeHTTPClient() function. This provides a three-layer defense strategy:

  1. Layer 1: URL Pre-Validation - security.ValidateExternalURL() checks URL format and DNS resolution
  2. Layer 2: Connection-Time Validation - network.NewSafeHTTPClient() re-validates IPs at dial time
  3. Layer 3: Redirect Validation - Each redirect target is validated before following

Import:

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

Basic Usage:

// Create a safe HTTP client with default options
client := network.NewSafeHTTPClient()

// Make request (connection-time SSRF protection is automatic)
resp, err := client.Get("https://api.example.com/data")
if err != nil {
    // Connection blocked due to private IP, or normal network error
    log.Error("Request failed:", err)
    return err
}
defer resp.Body.Close()

With Options:

// Custom timeout (default: 10 seconds)
client := network.NewSafeHTTPClient(
    network.WithTimeout(5 * time.Second),
)

// Allow localhost for local services (e.g., CrowdSec LAPI)
client := network.NewSafeHTTPClient(
    network.WithAllowLocalhost(),
)

// Allow limited redirects (default: 0)
client := network.NewSafeHTTPClient(
    network.WithMaxRedirects(2),
)

// Restrict to specific domains
client := network.NewSafeHTTPClient(
    network.WithAllowedDomains("api.github.com", "hub-data.crowdsec.net"),
)

// Custom dial timeout (default: 5 seconds)
client := network.NewSafeHTTPClient(
    network.WithDialTimeout(3 * time.Second),
)

// Combine multiple options
client := network.NewSafeHTTPClient(
    network.WithTimeout(10 * time.Second),
    network.WithAllowLocalhost(),        // For CrowdSec LAPI
    network.WithMaxRedirects(2),
    network.WithDialTimeout(5 * time.Second),
)

Available Options for NewSafeHTTPClient

Option Default Description Security Impact
WithTimeout(d) 10s Total request timeout LOW
WithAllowLocalhost() false Permit 127.0.0.1/localhost/::1 🔴 HIGH
WithAllowedDomains(...) nil Restrict to specific domains Improves security
WithMaxRedirects(n) 0 Max redirects to follow (each validated) ⚠️ MEDIUM if > 0
WithDialTimeout(d) 5s Connection timeout per dial LOW

How It Prevents DNS Rebinding

The safeDialer() function prevents DNS rebinding attacks by:

  1. Resolving the hostname to IP addresses immediately before connection
  2. Validating ALL resolved IPs (not just the first) against IsPrivateIP()
  3. Connecting directly to the validated IP (bypassing DNS cache)
// Internal implementation (simplified)
func safeDialer(opts *ClientOptions) func(ctx, network, addr string) (net.Conn, error) {
    return func(ctx, network, addr string) (net.Conn, error) {
        host, port := net.SplitHostPort(addr)

        // Resolve DNS immediately before connection
        ips, _ := net.DefaultResolver.LookupIPAddr(ctx, host)

        // Validate ALL resolved IPs
        for _, ip := range ips {
            if IsPrivateIP(ip.IP) {
                return nil, fmt.Errorf("connection to private IP blocked: %s", ip.IP)
            }
        }

        // Connect to validated IP (not hostname)
        return dialer.DialContext(ctx, network, net.JoinHostPort(selectedIP, port))
    }
}

Blocked CIDR Ranges

The network.IsPrivateIP() function blocks these IP ranges:

CIDR Block Description
10.0.0.0/8 RFC 1918 Class A private network
172.16.0.0/12 RFC 1918 Class B private network
192.168.0.0/16 RFC 1918 Class C private network
169.254.0.0/16 Link-local (AWS/Azure/GCP metadata: 169.254.169.254)
127.0.0.0/8 IPv4 loopback
0.0.0.0/8 Current network
240.0.0.0/4 Reserved for future use
255.255.255.255/32 Broadcast
::1/128 IPv6 loopback
fc00::/7 IPv6 unique local addresses
fe80::/10 IPv6 link-local

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
}


Test Coverage (PR #450)

Comprehensive SSRF Protection Tests

Charon maintains extensive test coverage for all SSRF protection mechanisms:

URL Validation Tests (90.2% coverage):

  • Private IP detection (IPv4/IPv6)
  • Cloud metadata endpoint blocking (169.254.169.254)
  • DNS resolution with timeout handling
  • Localhost allowance in test mode only
  • Custom timeout configuration
  • Multiple IP address validation (all must pass)

Security Notification Tests:

  • Webhook URL validation on save
  • Webhook URL re-validation on send
  • HTTPS enforcement in production
  • SSRF blocking for private IPs
  • DNS rebinding protection

Integration Tests:

  • End-to-end webhook delivery with SSRF checks
  • CrowdSec hub URL validation
  • URL connectivity testing with admin-only access
  • Performance benchmarks (< 10ms validation overhead)

Test Pattern Example:

func TestValidateExternalURL_CloudMetadataDetection(t *testing.T) {
    // Test blocking AWS metadata endpoint
    _, err := security.ValidateExternalURL("http://169.254.169.254/latest/meta-data/")
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "private IP address")
}

func TestValidateExternalURL_IPv6Comprehensive(t *testing.T) {
    // Test IPv6 private addresses
    testCases := []string{
        "http://[fc00::1]/",      // Unique local
        "http://[fe80::1]/",      // Link-local
        "http://[::1]/",          // Loopback
    }
    for _, url := range testCases {
        _, err := security.ValidateExternalURL(url)
        assert.Error(t, err, "Should block: %s", url)
    }
}

See PR #450 Implementation Summary for complete test metrics.


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.1 Last Updated: December 24, 2025 Status: Production Maintained By: Charon Security Team