Files
Charon/docs/plans/archive/ssrf_handler_fix_spec.md
2026-02-19 16:34:10 +00:00

947 lines
30 KiB
Markdown

# SSRF Remediation Plan: settings_handler.go TestPublicURL
**Status:** Planning - Revised
**Priority:** Critical
**Affected File:** `backend/internal/api/handlers/settings_handler.go`
**CodeQL Alert:** SSRF vulnerability via user-controlled URL input
**Date:** 2025-12-23
**Last Updated:** 2025-12-23 (Supervisor Review)
---
## Executive Summary
### Key Security Insights
1. **Triple-Layer Defense-in-Depth**:
- Layer 1: Format validation (scheme, paths)
- Layer 2: Pre-validation with DNS resolution (blocks private IPs)
- Layer 3: Runtime re-validation at connection time (eliminates TOCTOU)
2. **DNS Rebinding/TOCTOU Protection**:
- `ssrfSafeDialer()` performs **second DNS resolution** at connection time
- Even if attacker changes DNS between validations, connection is blocked
- Closes the Time-of-Check Time-of-Use (TOCTOU) window
3. **URL Parser Differential Protection**:
- `ValidateExternalURL()` rejects URLs with embedded credentials
- Prevents `http://evil.com@127.0.0.1/` bypass attacks
4. **CodeQL Taint Breaking**:
- Works because `ValidateExternalURL()` returns **new value** (not passthrough)
- Static analysis sees taint chain broken by value transformation
- NOT based on function name recognition
5. **HTTP Status Code Consistency**:
- SSRF blocks return `200 OK` with `reachable: false` (existing behavior)
- Only format errors return `400 Bad Request`
- Maintains consistent API contract for clients
---
## 1. Vulnerability Analysis
### 1.1 Data Flow
CodeQL has identified the following taint flow:
```
Source (Line 285): req.URL (user input from TestURLRequest)
Step 2-7: utils.ValidateURL() - Format validation only
Step 8: normalized URL (still tainted)
Step 9-10: utils.TestURLConnectivity() → http.NewRequestWithContext() [SINK]
```
### 1.2 Root Cause
**Format Validation Only**: `utils.ValidateURL()` performs only superficial format checks:
- Validates scheme (http/https)
- Rejects paths beyond "/"
- Returns warning for HTTP
- **Does NOT validate for SSRF risks** (private IPs, localhost, cloud metadata)
**Runtime Protection Not Recognized**: While `TestURLConnectivity()` has SSRF protection via `ssrfSafeDialer()`:
- The dialer blocks private IPs at connection time
- CodeQL's static analysis cannot detect this runtime protection
- The taint chain remains unbroken from the analysis perspective
**The Problem**: Static analysis sees unvalidated user input flowing directly to a network request sink without explicit SSRF pre-validation.
### 1.3 Existing Security Infrastructure
We have a comprehensive SSRF validator already implemented:
**File:** `backend/internal/security/url_validator.go`
- `ValidateExternalURL()`: Full SSRF protection with DNS resolution
- Blocks private IPs, loopback, link-local, cloud metadata endpoints
- **Rejects URLs with embedded credentials** (prevents parser differentials like `http://evil.com@127.0.0.1/`)
- Configurable options (WithAllowLocalhost, WithAllowHTTP, WithTimeout)
- Well-tested and production-ready
**File:** `backend/internal/utils/url_testing.go`
- `TestURLConnectivity()`: Has runtime SSRF protection via `ssrfSafeDialer()`
- **DNS Rebinding/TOCTOU Protection**: `ssrfSafeDialer()` performs second DNS resolution and IP validation at connection time
- `isPrivateIP()`: Comprehensive IP blocking logic
- Already protects against SSRF at connection time
**Defense-in-Depth Architecture**:
1. **Handler Validation**`ValidateExternalURL()` - Pre-validates URL and resolves DNS
2. **TestURLConnectivity Re-Validation**`ssrfSafeDialer()` - Validates IP again at connection time
3. This eliminates DNS rebinding/TOCTOU vulnerabilities (attacker can't change DNS between validations)
---
## 2. Fix Options
### Option A: Add Explicit Pre-Validation in Handler (RECOMMENDED)
**Description**: Insert explicit SSRF validation in `TestPublicURL` handler before calling `TestURLConnectivity()`.
**Implementation**:
```go
func (h *SettingsHandler) TestPublicURL(c *gin.Context) {
// ... existing auth check ...
var req TestURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Step 1: Format validation
normalized, _, err := utils.ValidateURL(req.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"reachable": false,
"error": "Invalid URL format",
})
return
}
// Step 2: SSRF validation (BREAKS TAINT CHAIN)
validatedURL, err := security.ValidateExternalURL(normalized,
security.WithAllowHTTP())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"reachable": false,
"error": err.Error(),
})
return
}
// Step 3: Connectivity test (now using validated URL)
reachable, latency, err := utils.TestURLConnectivity(validatedURL)
// ... rest of handler ...
}
```
**Pros**:
- ✅ Minimal code changes (localized to one handler)
- ✅ Explicitly breaks taint chain for CodeQL
- ✅ Uses existing, well-tested `security.ValidateExternalURL()`
- ✅ Clear separation of concerns (format → security → connectivity)
- ✅ Easy to audit and understand
**Cons**:
- ❌ Requires adding security package import to handler
- ❌ Two-step validation might seem redundant (but is defense-in-depth)
---
### Option B: Enhance ValidateURL() with SSRF Checks
**Description**: Modify `utils.ValidateURL()` to include SSRF validation.
**Implementation**:
```go
// In utils/url.go
func ValidateURL(rawURL string, options ...security.ValidationOption) (normalized string, warning string, err error) {
// Parse URL
parsed, parseErr := url.Parse(rawURL)
if parseErr != nil {
return "", "", parseErr
}
// Validate scheme
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", "", &url.Error{Op: "parse", URL: rawURL, Err: nil}
}
// Warn if HTTP
if parsed.Scheme == "http" {
warning = "Using HTTP is not recommended. Consider using HTTPS for security."
}
// Reject URLs with path components
if parsed.Path != "" && parsed.Path != "/" {
return "", "", &url.Error{Op: "validate", URL: rawURL, Err: nil}
}
// SSRF validation (NEW)
normalized = strings.TrimSuffix(rawURL, "/")
validatedURL, err := security.ValidateExternalURL(normalized,
security.WithAllowHTTP())
if err != nil {
return "", "", fmt.Errorf("SSRF validation failed: %w", err)
}
return validatedURL, warning, nil
}
```
**Pros**:
- ✅ Single validation point
- ✅ All callers of `ValidateURL()` automatically get SSRF protection
- ✅ No changes needed in handlers
**Cons**:
- ❌ Changes signature/behavior of widely-used function
- ❌ Mixes concerns (format validation + security validation)
- ❌ May break existing tests that expect `ValidateURL()` to accept localhost
- ❌ Adds cross-package dependency (utils → security)
- ❌ Could impact other callers unexpectedly
---
### Option C: Create Dedicated ValidateURLForTesting() Wrapper
**Description**: Create a new function specifically for the test endpoint that combines all validations.
**Implementation**:
```go
// In utils/url.go or security/url_validator.go
func ValidateURLForTesting(rawURL string) (normalized string, warning string, err error) {
// Step 1: Format validation
normalized, warning, err = ValidateURL(rawURL)
if err != nil {
return "", "", err
}
// Step 2: SSRF validation
validatedURL, err := security.ValidateExternalURL(normalized,
security.WithAllowHTTP())
if err != nil {
return "", warning, fmt.Errorf("security validation failed: %w", err)
}
return validatedURL, warning, nil
}
```
**Handler Usage**:
```go
func (h *SettingsHandler) TestPublicURL(c *gin.Context) {
// ... auth check ...
var req TestURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Combined validation
normalized, _, err := utils.ValidateURLForTesting(req.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"reachable": false,
"error": err.Error(),
})
return
}
// Connectivity test
reachable, latency, err := utils.TestURLConnectivity(normalized)
// ... rest of handler ...
}
```
**Pros**:
- ✅ Clean API for the specific use case
- ✅ Doesn't modify existing `ValidateURL()` behavior
- ✅ Self-documenting function name
- ✅ Encapsulates the two-step validation
**Cons**:
- ❌ Adds another function to maintain
- ❌ Possible confusion about when to use `ValidateURL()` vs `ValidateURLForTesting()`
---
## 3. Recommended Approach: Option A (Explicit Pre-Validation)
**Rationale**:
1. **Minimal Risk**: Localized change to a single handler
2. **Clear Intent**: The two-step validation makes security concerns explicit
3. **Defense in Depth**: Format validation + SSRF validation + runtime protection
4. **Static Analysis Friendly**: CodeQL can clearly see the taint break
5. **Reuses Battle-Tested Code**: Leverages existing `security.ValidateExternalURL()`
6. **No Breaking Changes**: Doesn't modify shared utility functions
---
## 4. Implementation Plan
### 4.0 HTTP Status Code Strategy
**Existing Behavior** (from `settings_handler_test.go:605`):
- SSRF blocks return `200 OK` with `reachable: false` and error message
- This maintains consistent API contract: endpoint always returns structured JSON
- Only format validation errors return `400 Bad Request`
**Rationale**:
- SSRF validation is a *connectivity constraint*, not a request format error
- Returning 200 allows clients to distinguish between "URL malformed" vs "URL blocked by security policy"
- Consistent with existing test: `TestSettingsHandler_TestPublicURL_PrivateIPBlocked` expects `StatusOK`
**Implementation Rule**:
- `400 Bad Request` → Format errors (invalid scheme, paths, malformed JSON)
- `200 OK` → All SSRF/connectivity failures (return `reachable: false` with error details)
### 4.1 Code Changes
**File:** `backend/internal/api/handlers/settings_handler.go`
1. Add import:
```go
import (
// ... existing imports ...
"github.com/Wikid82/charon/backend/internal/security"
)
```
2. Modify `TestPublicURL` handler (lines 269-316):
```go
func (h *SettingsHandler) TestPublicURL(c *gin.Context) {
// Admin-only access check
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
// Parse request body
type TestURLRequest struct {
URL string `json:"url" binding:"required"`
}
var req TestURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Step 1: Validate URL format (scheme, no paths)
normalized, _, err := utils.ValidateURL(req.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"reachable": false,
"error": "Invalid URL format",
})
return
}
// Step 2: SSRF validation (DNS resolution + IP blocking)
// This explicitly validates against private IPs, loopback, link-local,
// and cloud metadata endpoints, breaking the taint chain for static analysis.
validatedURL, err := security.ValidateExternalURL(normalized,
security.WithAllowHTTP())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"reachable": false,
"error": err.Error(),
})
return
}
// Step 3: Perform connectivity test with runtime SSRF protection
reachable, latency, err := utils.TestURLConnectivity(validatedURL)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"reachable": false,
"error": err.Error(),
})
return
}
// Return success response
c.JSON(http.StatusOK, gin.H{
"reachable": reachable,
"latency": latency,
"message": fmt.Sprintf("URL reachable (%.0fms)", latency),
})
}
```
### 4.2 Test Cases
**File:** `backend/internal/api/handlers/settings_handler_test.go`
Add comprehensive test cases:
```go
func TestTestPublicURL_SSRFProtection(t *testing.T) {
tests := []struct {
name string
url string
expectedStatus int
expectReachable bool
expectError string
}{
{
name: "Valid public URL",
url: "https://example.com",
expectedStatus: http.StatusOK,
expectReachable: true,
},
{
name: "Private IP blocked - 10.0.0.1",
url: "http://10.0.0.1",
expectedStatus: http.StatusOK,
expectError: "private ip",
},
{
name: "Localhost blocked - 127.0.0.1",
url: "http://127.0.0.1",
expectedStatus: http.StatusOK,
expectError: "private ip",
},
{
name: "Localhost blocked - localhost",
url: "http://localhost:8080",
expectedStatus: http.StatusOK,
expectError: "private ip",
},
{
name: "Cloud metadata blocked - 169.254.169.254",
url: "http://169.254.169.254",
expectedStatus: http.StatusOK,
expectError: "cloud metadata",
},
{
name: "Link-local blocked - 169.254.1.1",
url: "http://169.254.1.1",
expectedStatus: http.StatusOK,
expectError: "private ip",
},
{
name: "Private IPv4 blocked - 192.168.1.1",
url: "http://192.168.1.1",
expectedStatus: http.StatusOK,
expectError: "private ip",
},
{
name: "Private IPv4 blocked - 172.16.0.1",
url: "http://172.16.0.1",
expectedStatus: http.StatusOK,
expectError: "private ip",
},
{
name: "Invalid scheme rejected",
url: "ftp://example.com",
expectedStatus: http.StatusBadRequest,
expectError: "Invalid URL format",
},
{
name: "Path component rejected",
url: "https://example.com/path",
expectedStatus: http.StatusBadRequest,
expectError: "Invalid URL format",
},
{
name: "Empty URL field",
url: "",
expectedStatus: http.StatusBadRequest,
expectError: "required",
},
{
name: "URL with embedded credentials blocked",
url: "http://user:pass@example.com",
expectedStatus: http.StatusOK,
expectError: "credentials",
},
{
name: "HTTP URL allowed with WithAllowHTTP option",
url: "http://example.com",
expectedStatus: http.StatusOK,
expectReachable: true, // Should succeed if example.com is reachable
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup test environment
db := setupTestDB(t)
handler := NewSettingsHandler(db)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
})
router.POST("/api/settings/test-url", handler.TestPublicURL)
// Create request
body := fmt.Sprintf(`{"url": "%s"}`, tt.url)
req, _ := http.NewRequest("POST", "/api/settings/test-url",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Execute request
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Verify response
assert.Equal(t, tt.expectedStatus, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
if tt.expectError != "" {
assert.Contains(t,
strings.ToLower(resp["error"].(string)),
strings.ToLower(tt.expectError))
}
if tt.expectReachable {
assert.True(t, resp["reachable"].(bool))
}
})
}
}
func TestTestPublicURL_RequiresAdmin(t *testing.T) {
db := setupTestDB(t)
handler := NewSettingsHandler(db)
router := gin.New()
// Non-admin user
router.Use(func(c *gin.Context) {
c.Set("role", "user")
})
router.POST("/api/settings/test-url", handler.TestPublicURL)
body := `{"url": "https://example.com"}`
req, _ := http.NewRequest("POST", "/api/settings/test-url",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
```
### 4.3 Documentation
Add inline comment in the handler explaining the multi-layer protection:
```go
// TestPublicURL performs a server-side connectivity test with comprehensive SSRF protection.
// This endpoint implements defense-in-depth security:
// 1. Format validation: Ensures valid HTTP/HTTPS URLs without path components
// 2. SSRF validation: Pre-validates DNS resolution and blocks private/reserved IPs
// 3. Runtime protection: ssrfSafeDialer validates IPs again at connection time
// This multi-layer approach satisfies both static analysis (CodeQL) and runtime security.
```
---
## 5. Similar Issues in Codebase
### 5.1 Other Handlers Using User URLs
**Search Results**: No other handlers currently accept user-provided URLs for outbound requests.
**Checked Files**:
- `remote_server_handler.go`: Uses `net.DialTimeout()` for TCP, not HTTP requests
- `proxy_host_handler.go`: Manages proxy configs but doesn't make outbound requests
- Other handlers: No URL input parameters
**Conclusion**: This is an isolated instance. The `TestPublicURL` handler is the only place where user-supplied URLs are used for outbound HTTP requests.
### 5.2 Preventive Measures
To prevent similar issues in the future:
1. **Code Review Checklist**: Add SSRF check for any handler accepting URLs
2. **Linting Rule**: Consider adding a custom linter rule to detect `http.NewRequest*()` calls with untainted strings
3. **Documentation**: Update security guidelines to require SSRF validation for URL inputs
---
## 6. CodeQL Satisfaction Strategy
### 6.1 Why CodeQL Flags This
CodeQL's taint analysis tracks data flow from sources (user input) to sinks (network operations). It flags this because:
1. **Source**: `req.URL` is user-controlled
2. **No Explicit Barrier**: `ValidateURL()` doesn't signal to CodeQL that it performs security checks
3. **Sink**: The tainted string reaches `http.NewRequestWithContext()`
### 6.2 How Our Fix Satisfies CodeQL
By inserting `security.ValidateExternalURL()`:
1. **Returns New Value (Breaks Taint Chain)**: The function performs DNS resolution and IP validation, then returns a **new string value**. From static analysis perspective, this new return value is not the same tainted input—it's a validated, sanitized result. CodeQL's taint tracking stops here because the data flow breaks.
2. **Technical Explanation**: Static analysis tools like CodeQL track data flow through variables. When a function takes tainted input and returns a different value (not just a passthrough), the analysis must assume the return value could be untainted. Since `ValidateExternalURL()` performs validation logic (DNS resolution, IP checks) before returning, CodeQL treats the output as clean.
3. **Defense-in-Depth Signal**: Using a dedicated security validation function makes the security boundary explicit, making code review and auditing easier.
**Important**: CodeQL does NOT recognize `ValidateExternalURL()` by name alone. It works because the function returns a new value that breaks the taint chain. Any validation function that transforms input into a new output would have the same effect.
### 6.3 Expected Result
After implementation:
- ✅ CodeQL should recognize the taint chain is broken
- ✅ Alert should resolve or downgrade to "False Positive"
- ✅ Future scans should not flag this pattern
---
## 6.5 DNS Rebinding/TOCTOU Protection Architecture
### The TOCTOU Problem
A classic SSRF bypass technique is DNS rebinding, also known as Time-of-Check Time-of-Use (TOCTOU):
1. **Check Time**: Handler calls `ValidateExternalURL()` which resolves `attacker.com` to public IP `1.2.3.4` ✅ Allowed
2. **Use Time**: Milliseconds later, `TestURLConnectivity()` resolves `attacker.com` again but attacker has changed DNS to `127.0.0.1` ❌ SSRF!
### Our Defense-in-Depth Solution
**Triple-Layer Protection**:
1. **Layer 1: Format Validation** (`utils.ValidateURL()`)
- Validates URL scheme (http/https only)
- Blocks URLs with paths (prevents bypasses like `http://example.com/../internal`)
- Rejects invalid URL formats
2. **Layer 2: Pre-Validation** (`security.ValidateExternalURL()`)
- Performs DNS resolution
- Validates resolved IPs against block list
- Blocks embedded credentials (prevents `http://evil.com@127.0.0.1/` parser differentials)
- **Purpose**: Breaks CodeQL taint chain and provides early fail-fast behavior
3. **Layer 3: Runtime Validation** (`TestURLConnectivity()` → `ssrfSafeDialer()`)
- **Second DNS resolution** at connection time
- Validates ALL resolved IPs before connecting
- Uses first valid IP for connection (prevents IP hopping)
- **Purpose**: Eliminates DNS rebinding/TOCTOU window
**From `backend/internal/utils/url_testing.go`**:
```go
// ssrfSafeDialer creates a custom dialer that validates IP addresses at connection time.
// This prevents DNS rebinding attacks by validating the IP just before connecting.
func ssrfSafeDialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
// Resolve DNS with context timeout
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("DNS resolution failed: %w", err)
}
// Validate ALL resolved IPs - if any are private, reject immediately
for _, ip := range ips {
if isPrivateIP(ip.IP) {
return nil, fmt.Errorf("access to private IP addresses is blocked (resolved to %s)", ip.IP)
}
}
// Connect to the first valid IP (prevents DNS rebinding)
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}
}
```
### Why This Eliminates TOCTOU
Even if an attacker changes DNS between Layer 2 and Layer 3:
- Layer 2 validates at time T1: `attacker.com` → `1.2.3.4` ✅
- Attacker changes DNS
- Layer 3 resolves again at time T2: `attacker.com` → `127.0.0.1` ❌ **BLOCKED by ssrfSafeDialer**
The second validation happens **inside the HTTP client's dialer**, at the moment of connection. This closes the TOCTOU window.
---
## 6.6 URL Parser Differential Protection
### The Attack Vector
Some SSRF bypasses exploit differences in how various URL parsers interpret URLs:
```
http://evil.com@127.0.0.1/
```
**Vulnerable Parser** interprets as:
- User: `evil.com`
- Host: `127.0.0.1` ← SSRF target
**Strict Parser** interprets as:
- User: `evil.com`
- Host: `127.0.0.1`
- But rejects due to embedded credentials
### Our Protection
`security.ValidateExternalURL()` (in `url_validator.go`) rejects URLs with embedded credentials:
```go
if parsed.User != nil {
return "", fmt.Errorf("URL must not contain embedded credentials")
}
```
This prevents parser differential attacks where credentials are used to disguise the real target.
---
## 6.7 Pattern Comparison: Why This Handler Uses Simple Validation
### Our Pattern (TestPublicURL)
```go
// Step 1: Format validation
normalized, _, err := utils.ValidateURL(req.URL)
// Step 2: SSRF pre-validation
validatedURL, err := security.ValidateExternalURL(normalized, security.WithAllowHTTP())
// Step 3: Test connectivity
reachable, latency, err := utils.TestURLConnectivity(validatedURL)
```
**Characteristics**:
- Two-step validation (format → security)
- Uses `ValidateExternalURL()` for simplicity
- Relies on `ssrfSafeDialer()` for runtime protection
- **Use Case**: Simple connectivity test, no custom IP selection needed
### Security Notifications Pattern
**Reference**: `backend/internal/api/handlers/security_notifications.go` (lines 50-65)
```go
if config.WebhookURL != "" {
if _, err := security.ValidateExternalURL(config.WebhookURL,
security.WithAllowLocalhost(),
security.WithAllowHTTP(),
); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Invalid webhook URL: %v", err),
})
return
}
}
```
**Characteristics**:
- Single validation step
- Uses `WithAllowLocalhost()` option (allows testing webhooks locally)
- No connectivity test required (URL is stored, not immediately used)
- **Use Case**: Configuration storage, not immediate outbound request
### Why The Difference?
| Aspect | TestPublicURL | Security Notifications |
|--------|---------------|------------------------|
| **Immediate Use** | Yes, tests connectivity now | No, stores for later use |
| **Localhost** | Blocked (security risk) | Allowed (dev convenience) |
| **HTTP Allowed** | Yes (some APIs are HTTP-only) | Yes (same reason) |
| **Runtime Protection** | Yes (ssrfSafeDialer) | N/A (no immediate request) |
Both handlers use the same `ValidateExternalURL()` function but with different options based on their use case.
---
## 7. Testing Strategy
### 7.1 Unit Tests
- ✅ Test all SSRF scenarios (private IPs, localhost, cloud metadata)
- ✅ Test valid public URLs (ensure functionality still works)
- ✅ Test authorization (admin-only requirement)
- ✅ Test format validation errors
- ✅ Test connectivity failures (unreachable URLs)
### 7.2 Integration Tests
1. Deploy fix to staging environment
2. Test with real DNS resolution (ensure no false positives)
3. Verify error messages are user-friendly
4. Confirm public URLs are still testable
**Future Work**: Add DNS rebinding integration test:
- Set up test DNS server that changes responses dynamically
- Verify that `ssrfSafeDialer()` blocks the rebind attempt
- Test sequence: Initial DNS → Public IP (allowed) → DNS change → Private IP (blocked)
- This requires test infrastructure not currently available but should be prioritized for comprehensive security validation
### 7.3 Security Validation
1. Run CodeQL scan after implementation
2. Verify alert is resolved
3. Test with OWASP ZAP or Burp Suite to confirm SSRF protection
4. Penetration test with DNS rebinding attacks
---
## 8. Rollout Plan
### Phase 1: Implementation (Day 1)
- [ ] Implement code changes in `settings_handler.go`
- [ ] Add comprehensive unit tests
- [ ] Verify existing tests still pass
### Phase 2: Validation (Day 1-2)
- [ ] Run CodeQL scan locally
- [ ] Manual security testing
- [ ] Code review with security focus
### Phase 3: Deployment (Day 2-3)
- [ ] Merge to main branch
- [ ] Deploy to staging
- [ ] Run automated security scan
- [ ] Deploy to production
### Phase 4: Verification (Day 3+)
- [ ] Monitor logs for validation errors
- [ ] Verify CodeQL alert closure
- [ ] Update security documentation
---
## 9. Risk Assessment
### Security Risks (PRE-FIX)
- **Critical**: Admins could test internal endpoints (localhost, metadata services)
- **High**: Potential information disclosure about internal network topology
- **Medium**: DNS rebinding attack surface
### Security Risks (POST-FIX)
- **None**: Comprehensive SSRF protection at multiple layers
### Functional Risks
- **Low**: Error messages might be too restrictive if users try to test localhost
- **Mitigation**: Clear error message explaining security rationale
- **Low**: Public URL testing might fail due to DNS issues
- **Mitigation**: Existing timeout and error handling remains in place
---
## 10. Success Criteria
✅ **Security**:
- CodeQL SSRF alert resolved
- All SSRF test vectors blocked (localhost, private IPs, cloud metadata)
- No regression in other security scans
✅ **Functionality**:
- Public URLs can still be tested successfully
- Appropriate error messages for invalid/blocked URLs
- Latency measurements remain accurate
✅ **Code Quality**:
- 100% test coverage for new code paths
- No breaking changes to existing functionality
- Clear documentation and inline comments
---
## 11. References
- **OWASP SSRF**: <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
- **CWE-918 (SSRF)**: <https://cwe.mitre.org/data/definitions/918.html>
- **DNS Rebinding Attacks**: <https://en.wikipedia.org/wiki/DNS_rebinding>
- **TOCTOU Vulnerabilities**: <https://cwe.mitre.org/data/definitions/367.html>
- **URL Parser Confusion**: <https://claroty.com/team82/research/exploiting-url-parsing-confusion>
- **Existing Implementation**: `backend/internal/security/url_validator.go`
- **Runtime Protection**: `backend/internal/utils/url_testing.go::ssrfSafeDialer()`
- **Comparison Pattern**: `backend/internal/api/handlers/security_notifications.go`
---
## Appendix: Alternative Mitigations Considered
### A. Whitelist-Only Approach
**Description**: Only allow testing URLs from a pre-configured whitelist.
**Rejected**: Too restrictive for legitimate admin use cases.
### B. Remove Test Endpoint
**Description**: Remove the `TestPublicURL` endpoint entirely.
**Rejected**: Legitimate functionality for validating public URL configuration.
### C. Client-Side Testing Only
**Description**: Move connectivity testing to the frontend.
**Rejected**: Cannot validate server-side reachability from client.
---
**Plan Status**: ✅ Revised and Ready for Implementation
**Revision Date**: 2025-12-23 (Post-Supervisor Review)
**Next Steps**:
1. Backend_Dev to implement Option A following this revised specification
2. Ensure HTTP status codes match existing test behavior (200 OK for SSRF blocks)
3. Use `err.Error()` directly without wrapping
4. Verify triple-layer protection works end-to-end
**Key Changes from Original Plan**:
- Fixed CodeQL taint analysis explanation (value transformation, not name recognition)
- Documented DNS rebinding/TOCTOU protection via ssrfSafeDialer
- Changed HTTP status codes to 200 OK for SSRF blocks (matches existing tests)
- Fixed error message handling (no wrapper prefix)
- Added URL parser differential protection documentation
- Added pattern comparison with security_notifications.go
- Added comprehensive architecture documentation for TestURLConnectivity
- Added missing test cases (empty URL, embedded credentials, WithAllowHTTP)