# URL Test Button Navigation Bug - Implementation Plan
**Status**: Ready for Implementation
**Priority**: High
**Affected Component**: System Settings - Application URL Test
**Last Updated**: December 22, 2025 (Security Review Completed)
---
## Security Review Summary
**Critical vulnerabilities fixed in this revision:**
1. ✅ **DNS Rebinding Protection**: HTTP requests now use validated IP addresses instead of hostnames, preventing TOCTOU attacks
2. ✅ **Redirect Validation**: All redirect targets validated for private IPs before following
3. ✅ **Complete IP Blocklist**: 15 IPv4 + 6 IPv6 reserved ranges blocked (RFC-compliant)
4. ✅ **HTTPS Enforcement**: Only HTTPS URLs accepted for secure testing
5. ✅ **Port Restrictions**: Limited to 443/8443 only
6. ✅ **Hostname Blocklist**: Cloud metadata endpoints explicitly blocked
7. ✅ **Rate Limiting**: Middleware implementation with 5 tests/minute per user
---
## Executive Summary
The URL test button in System Settings incorrectly uses `window.open()` instead of performing a server-side connectivity test. This causes the browser to open the URL in a new tab (blank screen if unreachable) rather than executing a proper health check.
**User Report**: Clicking test button for `http://100.98.12.109:8080/settings/https//charon.hatfieldhosted.com` opened blank blue screen.
---
## Current Implementation Analysis
### Frontend: SystemSettings.tsx
**File**: [frontend/src/pages/SystemSettings.tsx](frontend/src/pages/SystemSettings.tsx#L103-L118)
```typescript
const testPublicURL = async () => {
if (!publicURL) {
toast.error(t('systemSettings.applicationUrl.invalidUrl'))
return
}
setPublicURLSaving(true)
try {
window.open(publicURL, '_blank') // ❌ Opens URL in browser instead of API test
toast.success('URL opened in new tab')
} catch {
toast.error('Failed to open URL')
} finally {
setPublicURLSaving(false)
}
}
```
**Button** (line 417):
```typescript
```
### Backend: Existing Validation Only
**File**: [backend/internal/api/routes/routes.go](backend/internal/api/routes/routes.go#L195)
```go
protected.POST("/settings/validate-url", settingsHandler.ValidatePublicURL)
```
**Handler**: [backend/internal/api/handlers/settings_handler.go](backend/internal/api/handlers/settings_handler.go#L229-L267)
This endpoint **only validates format** (scheme, no paths), does NOT test connectivity.
---
## Root Cause
1. **Misnamed Function**: `testPublicURL()` implies connectivity test but performs navigation
2. **No Backend Endpoint**: Missing API for server-side reachability tests
3. **User Expectation**: "Test" button should verify connectivity, not open URL
4. **Malformed URL Issue**: User input `https//charon.hatfieldhosted.com` (missing colon) causes navigation failure
---
## Security: SSRF Protection Requirements
**CRITICAL**: Backend URL testing must prevent Server-Side Request Forgery attacks.
### Required Protections
1. **Complete IP Blocklist**: Reject all private/reserved IPs
- IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- Loopback: `127.0.0.0/8`, IPv6 `::1/128`
- Link-local: `169.254.0.0/16`, IPv6 `fe80::/10`
- Cloud metadata: `169.254.169.254` (AWS/GCP/Azure)
- IPv6 ULA: `fc00::/7`
- Test/doc ranges: `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`
- Reserved: `0.0.0.0/8`, `240.0.0.0/4`, `255.255.255.255/32`
- CGNAT: `100.64.0.0/10`
- Multicast: `224.0.0.0/4`, IPv6 `ff00::/8`
2. **DNS Rebinding Protection** (CRITICAL):
- Make HTTP request directly to validated IP address
- Use `req.Host` header for SNI/vhost routing
- Prevents TOCTOU attacks where DNS changes between check and use
3. **Redirect Validation** (CRITICAL):
- Validate each redirect target's IP before following
- Max 2 redirects
- Block redirects to private IPs
4. **Hostname Blocklist**:
- `metadata.google.internal`, `metadata.goog`, `metadata`
- `169.254.169.254`, `localhost`
5. **HTTPS Enforcement**:
- Require HTTPS scheme (reject HTTP for security)
- Warn users about insecure connections
6. **Port Restrictions**:
- Allow only: 443 (HTTPS), 8443 (alternate HTTPS)
- Block all other ports including privileged ports
7. **Rate Limiting**: 5 tests per minute per user
- Implement using `golang.org/x/time/rate`
- Per-user token bucket with burst allowance
8. **Request Restrictions**:
- 5 second HTTP timeout
- 3 second DNS timeout
- HEAD method only (no full GET)
9. **Admin-Only**: Require admin role (already enforced on `/settings/*`)
---
## Implementation Plan
### Backend: New API Endpoint
#### 1. Register Route with Rate Limiting
**File**: [backend/internal/api/routes/routes.go](backend/internal/api/routes/routes.go#L195)
After line 195:
```go
// Create rate limiter for URL testing (5 requests per minute)
urlTestLimiter := middleware.NewRateLimiter(5.0/60.0, 5)
protected.POST("/settings/test-url",
urlTestLimiter.Limit(),
settingsHandler.TestPublicURL)
```
#### 2. Handler
**File**: [backend/internal/api/handlers/settings_handler.go](backend/internal/api/handlers/settings_handler.go#L267)
```go
// TestPublicURL performs server-side connectivity test with SSRF protection
func (h *SettingsHandler) TestPublicURL(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
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
}
// Validate format first
normalized, _, err := utils.ValidateURL(req.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"reachable": false,
"error": "Invalid URL format",
})
return
}
// Test connectivity (SSRF-safe)
reachable, latency, err := utils.TestURLConnectivity(normalized)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"reachable": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"reachable": reachable,
"latency": latency,
"message": fmt.Sprintf("URL reachable (%.0fms)", latency),
})
}
```
#### 3. Utility Function with DNS Rebinding Protection
**File**: Create `backend/internal/utils/url_test.go`
```go
package utils
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// TestURLConnectivity checks if URL is reachable with comprehensive SSRF protection
// including DNS rebinding prevention, redirect validation, and complete IP blocklist
func TestURLConnectivity(rawURL string) (bool, float64, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return false, 0, fmt.Errorf("invalid URL: %w", err)
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
port = map[string]string{"https": "443", "http": "80"}[parsed.Scheme]
}
// Enforce HTTPS for security
if parsed.Scheme != "https" {
return false, 0, fmt.Errorf("HTTPS required")
}
// Validate port
allowedPorts := map[string]bool{"443": true, "8443": true}
if !allowedPorts[port] {
return false, 0, fmt.Errorf("port %s not allowed", port)
}
// Block metadata hostnames explicitly
forbiddenHosts := []string{
"metadata.google.internal", "metadata.goog", "metadata",
"169.254.169.254", "localhost",
}
for _, forbidden := range forbiddenHosts {
if strings.EqualFold(host, forbidden) {
return false, 0, fmt.Errorf("blocked hostname")
}
}
// DNS resolution with timeout
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return false, 0, fmt.Errorf("DNS failed: %w", err)
}
if len(ips) == 0 {
return false, 0, fmt.Errorf("no IPs found")
}
// SSRF protection: block private IPs
for _, ip := range ips {
if isPrivateIP(ip.IP) {
return false, 0, fmt.Errorf("private IP blocked: %s", ip.IP)
}
}
// DNS REBINDING PROTECTION: Use first validated IP for request
validatedIP := ips[0].IP.String()
// Construct URL using validated IP to prevent TOCTOU attacks
var targetURL string
if port != "" {
targetURL = fmt.Sprintf("%s://%s:%s%s", parsed.Scheme, validatedIP, port, parsed.Path)
} else {
targetURL = fmt.Sprintf("%s://%s%s", parsed.Scheme, validatedIP, parsed.Path)
}
// HTTP request with redirect validation
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 2 {
return fmt.Errorf("too many redirects")
}
// CRITICAL: Validate redirect target IPs
redirectHost := req.URL.Hostname()
redirectIPs, err := net.DefaultResolver.LookupIPAddr(ctx, redirectHost)
if err != nil {
return fmt.Errorf("redirect DNS failed: %w", err)
}
if len(redirectIPs) == 0 {
return fmt.Errorf("redirect DNS returned no IPs")
}
// Check redirect target IPs
for _, ip := range redirectIPs {
if isPrivateIP(ip.IP) {
return fmt.Errorf("redirect to private IP blocked: %s", ip.IP)
}
}
return nil
},
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, targetURL, nil)
if err != nil {
return false, 0, fmt.Errorf("request creation failed: %w", err)
}
// Set Host header to original hostname for SNI/vhost routing
req.Host = parsed.Host
req.Header.Set("User-Agent", "Charon-Health-Check/1.0")
resp, err := client.Do(req)
latency := time.Since(start).Seconds() * 1000
if err != nil {
return false, 0, fmt.Errorf("connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
return true, latency, nil
}
return false, latency, fmt.Errorf("status %d", resp.StatusCode)
}
// isPrivateIP checks if an IP is in any private/reserved range
func isPrivateIP(ip net.IP) bool {
// Check special addresses
if ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() {
return true
}
// Check if it's IPv4 or IPv6
if ip.To4() != nil {
// IPv4 private ranges (comprehensive RFC compliance)
privateBlocks := []string{
"0.0.0.0/8", // Current network
"10.0.0.0/8", // Private
"100.64.0.0/10", // Shared address space (CGNAT)
"127.0.0.0/8", // Loopback
"169.254.0.0/16", // Link-local / Cloud metadata
"172.16.0.0/12", // Private
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"192.168.0.0/16", // Private
"198.18.0.0/15", // Benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"224.0.0.0/4", // Multicast
"240.0.0.0/4", // Reserved
"255.255.255.255/32", // Broadcast
}
for _, block := range privateBlocks {
_, subnet, _ := net.ParseCIDR(block)
if subnet.Contains(ip) {
return true
}
}
} else {
// IPv6 private ranges
privateBlocks := []string{
"::1/128", // Loopback
"::/128", // Unspecified
"::ffff:0:0/96", // IPv4-mapped
"fe80::/10", // Link-local
"fc00::/7", // Unique local
"ff00::/8", // Multicast
}
for _, block := range privateBlocks {
_, subnet, _ := net.ParseCIDR(block)
if subnet.Contains(ip) {
return true
}
}
}
return false
}
```
#### 4. Rate Limiting Middleware
**File**: Create `backend/internal/middleware/rate_limit.go`
```go
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
type RateLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit
burst int
}
func NewRateLimiter(rps float64, burst int) *RateLimiter {
return &RateLimiter{
limiters: make(map[string]*rate.Limiter),
rate: rate.Limit(rps),
burst: burst,
}
}
func (rl *RateLimiter) getLimiter(key string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
limiter, exists := rl.limiters[key]
if !exists {
limiter = rate.NewLimiter(rl.rate, rl.burst)
rl.limiters[key] = limiter
}
return limiter
}
func (rl *RateLimiter) Limit() gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
limiter := rl.getLimiter(userID.(string))
if !limiter.Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "Rate limit exceeded. Maximum 5 tests per minute.",
})
return
}
c.Next()
}
}
```
### Frontend: Use API Instead of window.open
#### 1. API Client
**File**: [frontend/src/api/settings.ts](frontend/src/api/settings.ts#L40)
```typescript
export const testPublicURL = async (url: string): Promise<{
reachable: boolean
latency?: number
message?: string
error?: string
}> => {
const response = await client.post('/settings/test-url', { url })
return response.data
}
```
#### 2. Component Update
**File**: [frontend/src/pages/SystemSettings.tsx](frontend/src/pages/SystemSettings.tsx#L103-L118)
Replace function:
```typescript
const testPublicURLHandler = async () => {
if (!publicURL) {
toast.error(t('systemSettings.applicationUrl.invalidUrl'))
return
}
setPublicURLSaving(true)
try {
const result = await testPublicURL(publicURL)
if (result.reachable) {
toast.success(
result.message || `URL reachable (${result.latency?.toFixed(0)}ms)`
)
} else {
toast.error(result.error || 'URL not reachable')
}
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Test failed')
} finally {
setPublicURLSaving(false)
}
}
```
#### 3. Update Button (line 417)
```typescript