Files
Charon/docs/plans/url_testing_codeql_fix.md
2026-01-13 22:11:35 +00:00

1031 lines
38 KiB
Markdown

# CodeQL SSRF Fix for url_testing.go
## Executive Summary
**Problem**: CodeQL static analysis detects SSRF vulnerability in `backend/internal/utils/url_testing.go` line 113:
```
rawURL parameter (tainted source) → http.NewRequestWithContext(rawURL) [SINK]
```
**Root Cause**: The taint chain is NOT broken because:
1. The `rawURL` parameter flows directly to `http.NewRequestWithContext()` at line 113
2. While `ssrfSafeDialer()` validates IPs at CONNECTION TIME, CodeQL's static analysis cannot detect this runtime protection
3. Static analysis sees: `tainted input → network request` without an intermediate sanitization step
**Solution**: Insert explicit URL validation at function start to create a new, clean value that breaks the taint chain for CodeQL while maintaining existing runtime protections. **CRITICAL**: Validation must be skipped when custom transport is provided to preserve test isolation.
**Recommendation**: **Option A** - Use `security.ValidateExternalURL()` with conditional execution based on transport parameter
**Key Design Decisions**:
1. **Test Path Preservation**: Skip validation when custom `http.RoundTripper` is provided (test path)
2. **Unconditional Options**: Always use `WithAllowHTTP()` and `WithAllowLocalhost()` (function design requirement)
3. **Defense in Depth**: Accept double validation (cheap DNS cache hit) for security layering
4. **Zero Test Modifications**: Existing test suite must pass without any changes
---
## Critical Design Decisions
### 1. Conditional Validation Based on Transport Parameter
**Problem**: Tests inject custom `http.RoundTripper` to mock network calls. If we validate URLs even with test transport, we perform REAL DNS lookups that:
- Break test isolation
- Fail in environments without network access
- Cause tests to fail even though the mock transport would work
**Solution**: Only validate when `transport` parameter is nil/empty
```go
if len(transport) == 0 || transport[0] == nil {
// Production path: Validate
validatedURL, err := security.ValidateExternalURL(rawURL, ...)
rawURL = validatedURL
}
// Test path: Skip validation, use original rawURL with mock transport
```
**Why This Is Secure**:
- Production code never provides custom transport (always nil)
- Test code provides mock transport that bypasses network entirely
- `ssrfSafeDialer()` provides connection-time protection as fallback
### 2. Unconditional HTTP and Localhost Options
**Problem**: `TestURLConnectivity()` is DESIGNED to test HTTP and localhost connectivity. These are not optional features.
**Solution**: Always use `WithAllowHTTP()` and `WithAllowLocalhost()`
```go
validatedURL, err := security.ValidateExternalURL(rawURL,
security.WithAllowHTTP(), // REQUIRED: Function tests HTTP URLs
security.WithAllowLocalhost()) // REQUIRED: Function tests localhost
```
**Why These Are Not Security Bypasses**:
- The function's PURPOSE is to test connectivity to any reachable URL
- Security policy is enforced by CALLERS (e.g., handlers validate before calling)
- This validation is defense-in-depth, not the primary security layer
### 3. Accept Double Validation
**Problem**: `settings_handler.go` already validates URLs before calling `TestURLConnectivity()`
**Solution**: Accept the redundancy as defense-in-depth
```go
// Handler layer
validatedURL, err := security.ValidateExternalURL(userInput)
// Utils layer (this fix)
reachable, latency, err := TestURLConnectivity(validatedURL) // Validates again
```
**Why This Is Acceptable**:
- DNS results are cached (1ms overhead, not 50ms)
- Multiple layers reduce risk of bypass
- CodeQL only needs validation in ONE layer of the chain
- Performance is not critical for this function
---
## Analysis: Why CodeQL Still Fails
### Current Control Flow
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// ❌ rawURL (tainted) used immediately
parsed, err := url.Parse(rawURL)
// ...
req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
// ^^^^^^
// TAINTED VALUE AT SINK
}
```
### What CodeQL Sees
```
┌─────────────────────────────────────────────────────────────┐
│ Taint Flow Analysis │
├─────────────────────────────────────────────────────────────┤
│ 1. rawURL parameter → [TAINTED SOURCE] │
│ 2. url.Parse(rawURL) → parse but still tainted │
│ 3. scheme validation → checks but doesn't sanitize │
│ 4. http.NewRequestWithContext(rawURL) → [SINK - VIOLATION] │
└─────────────────────────────────────────────────────────────┘
```
### What CodeQL CANNOT See
- Runtime IP validation in `ssrfSafeDialer()`
- Connection-time DNS resolution checks
- Dynamic private IP blocking
**Static Analysis Limitation**: CodeQL needs proof that the value passed to the network call is derived from a validation function that returns a NEW value.
---
## Solution Options
### Option A: Use `security.ValidateExternalURL()` ⭐ RECOMMENDED
**Rationale**:
- Consistent with the fix in `settings_handler.go`
- Clearly breaks taint chain by returning a new string
- Provides defense-in-depth with pre-validation
- Satisfies CodeQL's static analysis requirements
- Already tested and proven to work
**Implementation**:
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// CRITICAL: Validate URL and break taint chain for CodeQL
// This validation occurs BEFORE the request is created
validatedURL, err := security.ValidateExternalURL(rawURL,
security.WithAllowHTTP(), // Allow HTTP for testing
security.WithAllowLocalhost(), // Allow localhost for tests
)
if err != nil {
return false, 0, fmt.Errorf("url validation failed: %w", err)
}
// Parse validated URL (validatedURL is a NEW value, breaks taint chain)
parsed, err := url.Parse(validatedURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
// ... existing code ...
// Use validated URL for request (clean value)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, validatedURL, nil)
// ^^^^^^^^^^^^^
// CLEAN VALUE
}
```
**Why This Works for CodeQL**:
```
┌──────────────────────────────────────────────────────────────┐
│ NEW Taint Flow (BROKEN) │
├──────────────────────────────────────────────────────────────┤
│ 1. rawURL parameter → [TAINTED SOURCE] │
│ 2. security.ValidateExternalURL() → [SANITIZER] │
│ 3. validatedURL (return value) → [NEW CLEAN VALUE] │
│ 4. http.NewRequestWithContext(validatedURL) → [CLEAN - OK] │
└──────────────────────────────────────────────────────────────┘
```
**Pros**:
- ✅ Satisfies CodeQL static analysis
- ✅ Consistent with other handlers
- ✅ Provides DNS resolution validation BEFORE request
- ✅ Blocks private IPs at validation time (defense-in-depth)
- ✅ Returns normalized URL string (breaks taint)
- ✅ Minimal code changes
- ✅ Existing `ssrfSafeDialer()` provides additional runtime protection
**Cons**:
- ⚠️ Requires import of `security` package
- ⚠️ Small performance overhead (extra DNS resolution)
- ⚠️ May need to handle test scenarios with `WithAllowLocalhost()`
---
### Option B: Parse, Validate, and Reconstruct URL
**Implementation**:
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// Phase 1: Parse and validate
parsed, err := url.Parse(rawURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
// Validate URL scheme
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false, 0, fmt.Errorf("invalid URL scheme: only http and https are allowed")
}
// Phase 2: Reconstruct URL as NEW value (breaks taint)
sanitizedURL := parsed.Scheme + "://" + parsed.Host + parsed.Path
if parsed.RawQuery != "" {
sanitizedURL += "?" + parsed.RawQuery
}
// ... rest of function uses sanitizedURL ...
req, err := http.NewRequestWithContext(ctx, http.MethodHead, sanitizedURL, nil)
}
```
**Pros**:
- ✅ No external dependencies
- ✅ Creates new string value (breaks taint)
- ✅ Keeps existing `ssrfSafeDialer()` protection
**Cons**:
- ❌ Does NOT validate IPs at this point (only at connection time)
- ❌ Inconsistent with handler pattern
- ❌ More complex and less maintainable
- ❌ URL reconstruction may miss edge cases
---
### Option C: Accept Only Pre-Validated URLs
**Description**: Document that callers MUST validate before calling.
**Pros**:
- ✅ Moves validation responsibility to callers
**Cons**:
- ❌ Breaks API contract
- ❌ Requires changes to ALL callers
- ❌ Doesn't satisfy CodeQL (taint still flows through)
- ❌ Less secure (relies on caller discipline)
---
## Recommended Solution: Option A
### Implementation Plan
#### Step 1: Import Security Package
**File**: `backend/internal/utils/url_testing.go`
**Location**: Line 3 (imports section)
```go
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/YourOrg/charon/backend/internal/security" // ADD THIS
)
```
#### Step 2: Add URL Validation at Function Start
**File**: `backend/internal/utils/url_testing.go`
**Location**: Line 55 (start of `TestURLConnectivity()`)
**REPLACE**:
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// Parse URL
parsed, err := url.Parse(rawURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
// Validate URL scheme (only allow http/https)
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false, 0, fmt.Errorf("invalid URL scheme: only http and https are allowed")
}
```
**WITH**:
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// Parse URL first to validate structure
parsed, err := url.Parse(rawURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
// Validate scheme
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false, 0, fmt.Errorf("only http and https schemes are allowed")
}
// CRITICAL: Two distinct code paths for production vs testing
//
// PRODUCTION PATH: Validate URL to break CodeQL taint chain
// - Performs DNS resolution and IP validation
// - Returns a NEW string value (breaks taint for static analysis)
// - This is the path CodeQL analyzes for security
//
// TEST PATH: Skip validation when custom transport provided
// - Tests inject http.RoundTripper to bypass network/DNS completely
// - Validation would perform real DNS even with test transport
// - This would break test isolation and cause failures
//
// Why this is secure:
// - Production code never provides custom transport (len == 0)
// - Test code provides mock transport (bypasses network entirely)
// - ssrfSafeDialer() provides defense-in-depth at connection time
if len(transport) == 0 || transport[0] == nil {
validatedURL, err := security.ValidateExternalURL(rawURL,
security.WithAllowHTTP(), // REQUIRED: TestURLConnectivity is designed to test HTTP
security.WithAllowLocalhost()) // REQUIRED: TestURLConnectivity is designed to test localhost
if err != nil {
return false, 0, fmt.Errorf("security validation failed: %w", err)
}
rawURL = validatedURL // Use validated URL for production requests (breaks taint chain)
}
// For test path: rawURL remains unchanged (test transport handles everything)
```
#### Step 3: Request Creation (No Changes Needed)
**File**: `backend/internal/utils/url_testing.go`
**Location**: Line 113
**Note**: No changes needed at the request creation site. The `rawURL` variable now contains the validated URL (reassigned in Step 2) for production paths, and the original URL for test paths. Both are correct for their respective contexts.
```go
// rawURL is now either:
// - Production: validated clean string (breaks taint chain)
// - Test: original string (bypassed by custom transport)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
```
---
## Impact Analysis
### Impact on Existing Tests
**Test Files to Review**:
- `backend/internal/utils/url_testing_test.go`
- `backend/internal/handlers/settings_handler_test.go`
- `backend/internal/services/notification_service_test.go`
**CRITICAL: Test Suite Must Pass Without Modification**
The revised implementation preserves test behavior by:
1. **Detecting Test Context**: Custom `http.RoundTripper` indicates test mode
2. **Skipping Validation in Tests**: Avoids real DNS lookups that would break test isolation
3. **Preserving Mock Transport**: Test transport bypasses network completely
**Expected Test Behavior**:
#### ✅ Tests That Will PASS (No Changes Needed)
1. **Valid HTTP URLs**: `http://example.com` - validation allows HTTP with `WithAllowHTTP()`
2. **Valid HTTPS URLs**: `https://example.com` - standard behavior
3. **Localhost URLs**: `http://localhost:8080` - validation allows with `WithAllowLocalhost()`
4. **Status Code Tests**: All existing status code tests - unchanged
5. **Timeout Tests**: Network timeout behavior - unchanged
6. **All Tests With Custom Transport**: Skip validation entirely - **CRITICAL FOR TEST SUITE**
#### 🎯 Why Tests Continue to Work
**Test Pattern (with custom transport)**:
```go
// Test creates mock transport
mockTransport := &mockRoundTripper{response: &http.Response{StatusCode: 200}}
// Calls TestURLConnectivity with transport
reachable, _, err := utils.TestURLConnectivity("http://example.com", mockTransport)
// ✅ Validation is SKIPPED (len(transport) > 0)
// ✅ Mock transport is used (no real network call)
// ✅ No DNS resolution occurs
// ✅ Test runs in complete isolation
```
**Production Pattern (no custom transport)**:
```go
// Production code calls without transport
reachable, _, err := utils.TestURLConnectivity("http://example.com")
// ✅ Validation runs (len(transport) == 0)
// ✅ DNS resolution occurs
// ✅ IP validation runs
// ✅ Taint chain breaks for CodeQL
// ✅ ssrfSafeDialer provides connection-time protection
```
#### ⚠️ Edge Cases (Unlikely to Exist)
If tests exist that:
1. **Test validation behavior directly** without providing custom transport
- These would now fail earlier (at validation stage vs connection stage)
- **Expected**: None exist (validation is tested in security package)
2. **Test with nil transport explicitly** (`TestURLConnectivity(url, nil)`)
- These would trigger validation (nil is treated as no transport)
- **Fix**: Remove nil parameter or provide actual transport
3. **Test invalid schemes without custom transport**
- May fail at validation stage instead of later
- **Expected**: Tests use custom transport, so unaffected
### Impact on Callers
**Direct Callers of `TestURLConnectivity()`**:
#### 1. `settings_handler.go` - TestPublicURL Handler
**Current Code**:
```go
validatedURL, err := security.ValidateExternalURL(url)
// ...
reachable, latency, err := utils.TestURLConnectivity(validatedURL)
```
**Impact**: ✅ **NO BREAKING CHANGE**
- Already passes validated URL
- Double validation occurs but is acceptable (defense-in-depth)
- **Why Double Validation is OK**:
- First validation: User input → handler (breaks taint for handler)
- Second validation: Defense-in-depth (validates again even if handler is bypassed)
- Performance: DNS results are cached by resolver (~1ms overhead)
- Security: Multiple layers reduce risk of bypass
- **CodeQL Perspective**: Only needs ONE validation in the chain; having two is fine
#### 2. `notification_service.go` - SendWebhookNotification
**Current Code** (approximate):
```go
reachable, latency, err := utils.TestURLConnectivity(webhookURL)
```
**Impact**: ✅ **NO BREAKING CHANGE**
- Validation now happens inside `TestURLConnectivity()`
- Behavior unchanged (private IPs still blocked)
- May see different error messages for invalid URLs
---
## Security Analysis
### Defense-in-Depth Layers (After Fix)
```
┌───────────────────────────────────────────────────────────────┐
│ Layer 1: Handler Validation (settings_handler.go) │
│ - security.ValidateExternalURL(userInput) │
│ - Validates scheme, DNS, IPs │
│ - Returns clean string │
└───────────────────┬───────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ Layer 2: TestURLConnectivity Validation [NEW] │
│ - security.ValidateExternalURL(rawURL) │
│ - Breaks taint chain for CodeQL │
│ - Validates DNS and IPs again (defense-in-depth) │
│ - Returns clean string │
└───────────────────┬───────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ Layer 3: Connection-Time Validation (ssrfSafeDialer) │
│ - DNS resolution at connection time │
│ - Validates ALL resolved IPs │
│ - Prevents DNS rebinding attacks │
│ - Blocks connections to private IPs │
└───────────────────────────────────────────────────────────────┘
```
### DNS Rebinding Protection
**Time-of-Check/Time-of-Use (TOCTOU) Attack**:
```
T0: ValidateExternalURL("http://attacker.com") → resolves to 203.0.113.5 (public) ✅
T1: ssrfSafeDialer() connects to "attacker.com" → resolves to 127.0.0.1 (private) ❌
```
**Mitigation**: The `ssrfSafeDialer()` validates IPs at T1 (connection time), so even if DNS changes between T0 and T1, the connection is blocked. This is why we keep BOTH validations:
- **ValidateExternalURL()** at T0: Satisfies CodeQL, provides early feedback
- **ssrfSafeDialer()** at T1: Prevents TOCTOU attacks, ultimate enforcement
---
## Testing Strategy
### Unit Tests to Add/Update
#### Test 1: Verify Private IP Blocking
```go
func TestTestURLConnectivity_BlocksPrivateIP(t *testing.T) {
// Should fail at validation stage now
_, _, err := utils.TestURLConnectivity("http://192.168.1.1:8080/test")
assert.Error(t, err)
assert.Contains(t, err.Error(), "private ip addresses")
}
```
#### Test 2: Verify Invalid Scheme Rejection
```go
func TestTestURLConnectivity_RejectsInvalidScheme(t *testing.T) {
// Should fail at validation stage now
_, _, err := utils.TestURLConnectivity("ftp://example.com/file")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported scheme")
}
```
#### Test 3: Verify Localhost Allowed
```go
func TestTestURLConnectivity_AllowsLocalhost(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Should succeed with localhost
reachable, _, err := utils.TestURLConnectivity(server.URL)
assert.NoError(t, err)
assert.True(t, reachable)
}
```
#### Test 4: Verify Existing Tests Still Pass
```bash
cd backend && go test -v -run TestTestURLConnectivity ./internal/utils/
```
**Expected**: All tests should pass with possible error message changes.
### Integration Tests
Run existing integration tests to ensure no regressions:
```bash
# Settings handler tests (already uses ValidateExternalURL)
cd backend && go test -v -run TestSettingsHandler ./internal/handlers/
# Notification service tests
cd backend && go test -v -run TestNotificationService ./internal/services/
```
---
## CodeQL Verification
### How CodeQL Analysis Works
**What CodeQL Needs to See**:
1. **Tainted Source**: User-controlled input (parameter)
2. **Sanitizer**: Function that returns a NEW value after validation
3. **Clean Sink**: Network operation uses the NEW value, not the original
**Why the Production Path Matters**:
- CodeQL performs static analysis on ALL possible code paths
- The test path (with custom transport) is a **separate code path** that doesn't reach the network sink
- The production path (without custom transport) is what CodeQL analyzes for SSRF
- As long as the production path has proper validation, CodeQL is satisfied
### How the Fix Satisfies CodeQL
#### Production Code Path (What CodeQL Analyzes)
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) {
// rawURL = TAINTED
if len(transport) == 0 || transport[0] == nil { // ✅ Production path
validatedURL, err := security.ValidateExternalURL(rawURL, ...) // ✅ SANITIZER
// validatedURL = CLEAN (new string returned by validator)
rawURL = validatedURL // ✅ Reassign: rawURL is now CLEAN
}
req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
// ✅ CLEAN value used at sink (for production path)
}
```
**CodeQL Taint Flow Analysis**:
```
Production Path:
┌──────────────────────────────────────────────────────────────┐
│ 1. rawURL parameter → [TAINTED SOURCE] │
│ 2. security.ValidateExternalURL() → [RECOGNIZED SANITIZER] │
│ 3. validatedURL return value → [NEW CLEAN VALUE] │
│ 4. rawURL = validatedURL → [TAINT BROKEN] │
│ 5. http.NewRequestWithContext(rawURL) → [CLEAN SINK ✅] │
└──────────────────────────────────────────────────────────────┘
Test Path (with custom transport):
┌──────────────────────────────────────────────────────────────┐
│ 1. rawURL parameter → [TAINTED] │
│ 2. if len(transport) > 0 → [BRANCH NOT TAKEN] │
│ 3. Custom transport provided → [BYPASSES NETWORK] │
│ 4. SINK NOT REACHED (transport mocks network call) │
│ 5. CodeQL doesn't flag this path → [NO VULNERABILITY] │
└──────────────────────────────────────────────────────────────┘
```
### Why Function Options Are Unconditional
**Background**: `TestURLConnectivity()` is designed to test connectivity to any reachable URL, including:
- HTTP URLs (not just HTTPS)
- Localhost URLs (for local services)
**The Options Are Not "Allowing Exceptions"** - they define the function's purpose:
```go
security.WithAllowHTTP() // REQUIRED: Function tests HTTP connectivity
security.WithAllowLocalhost() // REQUIRED: Function tests localhost services
```
**Why These Are Always Set**:
1. **Functional Requirement**: The function name is `TestURLConnectivity`, not `TestSecureURLConnectivity`
2. **Use Case**: Testing webhook endpoints (may be HTTP in dev), local services (localhost:8080)
3. **Security**: The function is NOT exposed to untrusted input directly
- Handlers call `ValidateExternalURL()` BEFORE calling this function
- This validation is defense-in-depth, not the primary security layer
4. **Caller's Responsibility**: Callers decide what security policy to apply BEFORE calling this function
**Not a Security Bypass**:
- If a handler needs to enforce HTTPS-only, it validates BEFORE calling `TestURLConnectivity()`
- If a handler allows HTTP, it's an intentional policy decision, not a bypass
- This function's job is to test connectivity, not enforce security policy
### How to Verify Fix
#### Step 1: Run CodeQL Analysis
```bash
codeql database create codeql-db --language=go --source-root=backend
codeql database analyze codeql-db codeql/go-queries --format=sarif-latest --output=codeql-results.sarif
```
#### Step 2: Check for SSRF Findings
```bash
# Should NOT find SSRF in url_testing.go line 113
grep -A 5 "url_testing.go" codeql-results.sarif | grep -i "ssrf"
```
**Expected Result**: No SSRF findings in `url_testing.go`
#### Step 3: Verify Taint Flow is Broken
Check SARIF output for taint flow:
```json
{
"results": [
// Should NOT contain:
// "path": "backend/internal/utils/url_testing.go",
// "line": 113,
// "kind": "path-problem",
// "message": "This request depends on a user-provided value"
]
}
```
---
## Migration Steps
### Phase 1: Implementation (15 minutes)
1. ✅ Add `security` package import to `url_testing.go`
2. ✅ Insert `security.ValidateExternalURL()` call at function start
3. ✅ Update all references from `rawURL` to `validatedURL`
4. ✅ Add explanatory comments for CodeQL
### Phase 2: Testing (20 minutes)
1. ✅ Run unit tests: `go test ./internal/utils/`
- **Expected**: All tests PASS without modification
- **Reason**: Tests use custom transport, validation is skipped
2. ✅ Run handler tests: `go test ./internal/handlers/`
- **Expected**: All tests PASS
- **Reason**: Handlers already validate before calling TestURLConnectivity
3. ✅ Run service tests: `go test ./internal/services/`
- **Expected**: All tests PASS
- **Reason**: Services either validate or use test transports
4. ✅ Verify test coverage remains ≥85%
- Run: `go test -cover ./internal/utils/`
- **Expected**: Coverage unchanged (new code is covered by production path)
### Phase 3: Verification (10 minutes)
1. ✅ Run CodeQL analysis
2. ✅ Verify no SSRF findings in `url_testing.go`
3. ✅ Review SARIF output for clean taint flow
### Phase 4: Documentation (5 minutes)
1. ✅ Update function documentation to mention validation
2. ✅ Update SSRF_COMPLETE.md with new architecture
3. ✅ Mark this plan as complete
---
## Test Impact Analysis
### Critical Requirement: Zero Test Modifications
**Design Principle**: The fix MUST preserve existing test behavior without requiring any test changes.
### How Test Preservation Works
#### 1. Test Detection Mechanism
```go
if len(transport) == 0 || transport[0] == nil {
// Production path: Validate
} else {
// Test path: Skip validation
}
```
**Test Pattern Recognition**:
- `TestURLConnectivity(url)` → Production (validate)
- `TestURLConnectivity(url, mockTransport)` → Test (skip validation)
- `TestURLConnectivity(url, nil)` → Production (validate)
#### 2. Why Tests Don't Break
**Problem if we validated in test path**:
```go
// Test provides mock transport to avoid real network
mockTransport := &mockRoundTripper{...}
reachable, _, err := TestURLConnectivity("http://example.com", mockTransport)
// ❌ If validation runs with test transport:
// 1. ValidateExternalURL() performs REAL DNS lookup for "example.com"
// 2. DNS may fail in test environment (no network, mock DNS, etc.)
// 3. Test fails even though mockTransport would have worked
// 4. Test isolation is broken (test depends on external DNS)
```
**Solution - skip validation with custom transport**:
```go
// Test provides mock transport
mockTransport := &mockRoundTripper{...}
reachable, _, err := TestURLConnectivity("http://example.com", mockTransport)
// ✅ Validation is skipped (len(transport) > 0)
// ✅ No DNS lookup occurs
// ✅ mockTransport handles the "request" (no network)
// ✅ Test runs in complete isolation
// ✅ Test behavior is identical to before the fix
```
#### 3. Test Suite Verification
**Tests that MUST pass without changes**:
```go
// backend/internal/utils/url_testing_test.go
func TestTestURLConnectivity_Success(t *testing.T) {
server := httptest.NewServer(...) // Creates mock server
defer server.Close()
// ✅ Uses httptest transport internally (validation skipped)
reachable, latency, err := TestURLConnectivity(server.URL)
// ✅ PASSES: No real DNS, uses test transport
}
func TestTestURLConnectivity_WithCustomTransport(t *testing.T) {
transport := &mockRoundTripper{...}
// ✅ Custom transport provided (validation skipped)
reachable, _, err := TestURLConnectivity("http://example.com", transport)
// ✅ PASSES: No real DNS, uses mock transport
}
func TestTestURLConnectivity_Timeout(t *testing.T) {
transport := &timeoutTransport{...}
// ✅ Custom transport provided (validation skipped)
_, _, err := TestURLConnectivity("http://slow.example.com", transport)
// ✅ PASSES: Timeout handled by mock transport
}
```
**Production code that gets validation**:
```go
// backend/internal/handlers/settings_handler.go
func (h *SettingsHandler) TestPublicURL(w http.ResponseWriter, r *http.Request) {
url := r.FormValue("url")
// First validation (handler layer)
validatedURL, err := security.ValidateExternalURL(url)
// Second validation (utils layer) - defense in depth
// ✅ No transport provided, validation runs
reachable, latency, err := utils.TestURLConnectivity(validatedURL)
}
```
#### 4. Edge Cases Handled
| Scenario | Transport Parameter | Behavior |
|----------|-------------------|----------|
| Production code | None | Validation runs |
| Production code | `nil` explicitly | Validation runs |
| Unit test | `mockTransport` | Validation skipped |
| Integration test | `httptest` default | Validation skipped |
| Handler test | None (uses real client) | Validation runs (acceptable) |
### Verification Checklist
Before merging, verify:
- [ ] `go test ./internal/utils/` - All tests pass
- [ ] `go test ./internal/handlers/` - All tests pass
- [ ] `go test ./internal/services/` - All tests pass
- [ ] `go test ./...` - Full suite passes
- [ ] Test coverage ≥85% maintained
- [ ] No test files modified
- [ ] No test assertions changed
---
## Risk Assessment
### Security Risks
- **Risk**: None. This ADDS validation, doesn't remove it.
- **Mitigation**: Keep existing `ssrfSafeDialer()` for defense-in-depth.
### Performance Risks
- **Risk**: Extra DNS resolution (one at validation, one at connection).
- **Impact**: ~10-50ms added latency (DNS lookup time).
- **Mitigation**: DNS resolver caching will reduce impact for repeated requests.
- **Acceptable**: Testing is not a hot path; security takes priority.
### Compatibility Risks
- **Risk**: Tests may need error message updates.
- **Impact**: LOW - Only test assertions may need adjustment.
- **Mitigation**: Run full test suite before merging.
---
## Success Criteria
### ✅ Definition of Done
1. CodeQL analysis shows NO SSRF findings in `url_testing.go` line 113
2. All existing unit tests pass **WITHOUT ANY MODIFICATIONS** ⚠️ CRITICAL
3. All integration tests pass
4. Test coverage remains ≥85%
5. No test files were changed
6. Documentation updated
7. Code review approved
### 🎯 Expected Outcome
```
┌─────────────────────────────────────────────────────────────┐
│ CodeQL Results: url_testing.go │
├─────────────────────────────────────────────────────────────┤
│ ✅ No SSRF vulnerabilities found │
│ ✅ Taint chain broken at validation layer (production) │
│ ✅ All security controls recognized │
│ ✅ Test isolation preserved (validation skipped in tests) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Test Results: Full Suite │
├─────────────────────────────────────────────────────────────┤
│ ✅ internal/utils tests: PASS (0 changes) │
│ ✅ internal/handlers tests: PASS (0 changes) │
│ ✅ internal/services tests: PASS (0 changes) │
│ ✅ Coverage: ≥85% (maintained) │
└─────────────────────────────────────────────────────────────┘
```
---
## Appendix: Code Comparison
### Before (Current - FAILS CodeQL)
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// ❌ rawURL is TAINTED
parsed, err := url.Parse(rawURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false, 0, fmt.Errorf("invalid URL scheme: only http and https are allowed")
}
// ... transport setup ...
// ❌ TAINTED rawURL used at SINK
req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
// ^^^^^^
// CODEQL VIOLATION
}
```
### After (With Fix - PASSES CodeQL)
```go
func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, float64, error) {
// Parse URL first to validate structure
parsed, err := url.Parse(rawURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
// Validate scheme
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false, 0, fmt.Errorf("only http and https schemes are allowed")
}
// ✅ PRODUCTION PATH: Validate and create NEW value (breaks taint)
// ✅ TEST PATH: Skip validation (custom transport bypasses network)
if len(transport) == 0 || transport[0] == nil {
validatedURL, err := security.ValidateExternalURL(rawURL,
security.WithAllowHTTP(), // REQUIRED: Function tests HTTP
security.WithAllowLocalhost()) // REQUIRED: Function tests localhost
if err != nil {
return false, 0, fmt.Errorf("security validation failed: %w", err)
}
rawURL = validatedURL // ✅ BREAKS TAINT: rawURL is now CLEAN
}
// Test path: rawURL unchanged (transport mocks network, sink not reached)
// ... transport setup ...
// ✅ Production path: rawURL is CLEAN (reassigned above)
// ✅ Test path: custom transport bypasses network (sink not reached)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
// ^^^^^^
// PRODUCTION: CLEAN ✅
// TEST: BYPASSED ✅
}
```
### Key Differences Explained
| Aspect | Before | After |
|--------|--------|-------|
| **Taint Chain** | rawURL → req (TAINTED) | rawURL → validatedURL → rawURL → req (CLEAN) |
| **CodeQL Result** | ❌ SSRF Detected | ✅ No Issues |
| **Test Behavior** | N/A | Preserved (validation skipped) |
| **Validation** | Only at connection time | Both at validation and connection time |
| **Function Options** | N/A | Always HTTP + localhost (by design) |
---
## Next Steps
1. **Immediate**: Implement Option A changes in `url_testing.go`
2. **Test**: Run full test suite and fix any assertion mismatches
3. **Verify**: Run CodeQL analysis to confirm fix
4. **Document**: Update SSRF_COMPLETE.md with new architecture
5. **Review**: Submit PR for review
---
**Status**: ⏳ PLAN COMPLETE - AWAITING IMPLEMENTATION
**Priority**: 🔴 CRITICAL - Blocks CodeQL pass
**Estimated Time**: 50 minutes total
**Complexity**: 🟢 LOW - Straightforward validation insertion