diff --git a/CAPTCHA_FEATURE.md b/CAPTCHA_FEATURE.md new file mode 100644 index 0000000..00a6228 --- /dev/null +++ b/CAPTCHA_FEATURE.md @@ -0,0 +1,276 @@ +# CAPTCHA Gating Feature for Zoraxy + +This document describes the CAPTCHA gating feature that has been added to Zoraxy. + +## Overview + +The CAPTCHA gating feature allows you to protect your endpoints with CAPTCHA challenges, similar to Cloudflare Turnstile. Users must solve a CAPTCHA before they can access protected endpoints. This feature supports both Cloudflare Turnstile and Google reCAPTCHA (v2 and v3). + +## Features + +- **Per-endpoint configuration**: Just like rate limiting, CAPTCHA can be enabled/disabled per endpoint +- **Multiple provider support**: + - Cloudflare Turnstile + - Google reCAPTCHA v2 (checkbox) + - Google reCAPTCHA v3 (invisible with score) +- **Session management**: Validated users receive a session cookie (configurable duration) +- **Exception rules**: Exclude specific paths or IP ranges from CAPTCHA challenges +- **Modern UI**: Responsive CAPTCHA challenge pages with gradient backgrounds + +## Configuration + +### Per-Endpoint Settings + +When adding or editing a proxy endpoint, you can configure CAPTCHA with the following parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `captcha` | boolean | Enable/disable CAPTCHA for this endpoint | +| `captchaProvider` | integer | Provider type: `0` = Cloudflare Turnstile, `1` = Google reCAPTCHA | +| `captchaSiteKey` | string | Site key (public key) from your CAPTCHA provider | +| `captchaSecretKey` | string | Secret key (private key) from your CAPTCHA provider | +| `captchaSessionDuration` | integer | Session duration in seconds (default: 3600) | +| `captchaRecaptchaVersion` | string | For Google: "v2" or "v3" (default: "v2") | +| `captchaRecaptchaScore` | float | For Google reCAPTCHA v3: minimum score 0.0-1.0 (default: 0.5) | + +### Example API Call + +```bash +curl -X POST http://localhost:8000/api/proxy/edit \ + -d "rootname=example.com" \ + -d "captcha=true" \ + -d "captchaProvider=0" \ + -d "captchaSiteKey=YOUR_SITE_KEY" \ + -d "captchaSecretKey=YOUR_SECRET_KEY" \ + -d "captchaSessionDuration=7200" +``` + +### Configuration Storage + +CAPTCHA configuration is stored in the proxy endpoint configuration files (`.config` files in `conf/http_proxy/`). The configuration is persisted as part of the `ProxyEndpoint` struct in JSON format. + +## How It Works + +### Request Flow + +1. **Request arrives** at protected endpoint +2. **Check exceptions**: If path or IP matches exception rules → allow +3. **Check session cookie**: If valid session exists → allow +4. **Serve CAPTCHA challenge**: Display CAPTCHA page +5. **User solves CAPTCHA** +6. **Verification**: Submit token to provider API +7. **Create session**: On success, set cookie and allow access +8. **Redirect**: User is redirected to original destination + +### Middleware Chain Order + +The CAPTCHA middleware is positioned in the request chain as follows: + +1. Access Control (blacklist/whitelist) +2. Exploit Detection +3. **Rate Limiting** +4. **CAPTCHA Gating** ← Inserted here +5. Authentication (Basic Auth / SSO) +6. Proxy to upstream + +This ensures CAPTCHA verification happens after rate limiting but before authentication. + +## CAPTCHA Exception Rules + +You can exclude certain paths or IP addresses from CAPTCHA challenges: + +### Exception Types + +1. **Path-based exceptions**: Match by path prefix + ```json + { + "RuleType": 0, + "PathPrefix": "/api/v1/" + } + ``` + +2. **IP-based exceptions**: Match by IP or CIDR range + ```json + { + "RuleType": 1, + "CIDR": "192.168.1.0/24" + } + ``` + +### Use Cases + +- Exclude API endpoints from CAPTCHA +- Whitelist internal IP ranges +- Skip CAPTCHA for specific paths (e.g., `/health`, `/metrics`) + +## Session Management + +### Session Store + +The CAPTCHA session store (`CaptchaSessionStore`) is a global component that: +- Stores session IDs with expiration times +- Uses `sync.Map` for thread-safe concurrent access +- Automatically cleans up expired sessions every 5 minutes + +### Session Cookies + +When a user successfully completes a CAPTCHA: +- A random 64-character session ID is generated +- A cookie named `zoraxy_captcha_session` is set +- Cookie attributes: + - `HttpOnly`: Yes (prevents JavaScript access) + - `Secure`: Yes if TLS is enabled + - `SameSite`: Lax + - `MaxAge`: Configurable (default 1 hour) + +## Provider Setup + +### Cloudflare Turnstile + +1. Sign up at https://dash.cloudflare.com/ +2. Navigate to Turnstile section +3. Create a new site +4. Copy the **Site Key** and **Secret Key** +5. Configure in Zoraxy with `captchaProvider=0` + +### Google reCAPTCHA v2 + +1. Visit https://www.google.com/recaptcha/admin +2. Register a new site +3. Select reCAPTCHA v2 (checkbox) +4. Copy the **Site Key** and **Secret Key** +5. Configure in Zoraxy with: + - `captchaProvider=1` + - `captchaRecaptchaVersion=v2` + +### Google reCAPTCHA v3 + +1. Visit https://www.google.com/recaptcha/admin +2. Register a new site +3. Select reCAPTCHA v3 +4. Copy the **Site Key** and **Secret Key** +5. Configure in Zoraxy with: + - `captchaProvider=1` + - `captchaRecaptchaVersion=v3` + - `captchaRecaptchaScore=0.5` (adjust as needed) + +## Code Structure + +### New Files + +- `src/mod/dynamicproxy/captcha.go`: Core CAPTCHA verification and session management logic + +### Modified Files + +- `src/mod/dynamicproxy/typedef.go`: + - Added `CaptchaConfig`, `CaptchaProvider`, `CaptchaExceptionRule` types + - Added `RequireCaptcha` and `CaptchaConfig` fields to `ProxyEndpoint` + - Added `captchaSessionStore` field to `Router` + +- `src/mod/dynamicproxy/dynamicproxy.go`: + - Initialize `CaptchaSessionStore` in `NewDynamicProxy()` + - Added CAPTCHA middleware to port 80 HTTP handler + +- `src/mod/dynamicproxy/Server.go`: + - Added CAPTCHA middleware to main request chain + +- `src/reverseproxy.go`: + - Added CAPTCHA parameter parsing in `ReverseProxyHandleAddEndpoint()` + - Added CAPTCHA parameter parsing in `ReverseProxyHandleEditEndpoint()` + - Added CAPTCHA configuration to endpoint creation + +### Key Functions + +- `handleCaptchaRouting()`: Main middleware function +- `handleCaptchaVerification()`: Process CAPTCHA token verification +- `serveCaptchaChallenge()`: Render CAPTCHA challenge page +- `VerifyCloudflareToken()`: Verify Cloudflare Turnstile token +- `VerifyGoogleRecaptchaToken()`: Verify Google reCAPTCHA token +- `CheckCaptchaException()`: Check if request matches exception rules + +## Security Considerations + +1. **Secret Key Protection**: Store CAPTCHA secret keys securely. They are stored in config files - ensure proper file permissions. + +2. **Session Security**: Session IDs are cryptographically random (32 bytes from `crypto/rand`). + +3. **Cookie Security**: Cookies use HttpOnly and Secure flags when TLS is enabled. + +4. **Rate Limiting**: CAPTCHA works in conjunction with rate limiting, not as a replacement. + +5. **Score Thresholds**: For reCAPTCHA v3, adjust the score threshold based on your traffic patterns (0.5 is a good starting point). + +## Logging + +CAPTCHA-related events are logged with the following identifiers: +- `captcha-required`: User was served a CAPTCHA challenge (403 status) + +## Testing + +### Manual Testing Steps + +1. **Enable CAPTCHA on an endpoint** +2. **Access the endpoint** → Should show CAPTCHA challenge +3. **Complete CAPTCHA** → Should create session and allow access +4. **Access again** → Should bypass CAPTCHA (session valid) +5. **Wait for session expiry** → Should show CAPTCHA again + +### Test with curl + +```bash +# First request - should return CAPTCHA HTML +curl -i http://your-domain.com/ + +# After solving CAPTCHA in browser, copy session cookie +# Second request with session cookie - should proxy normally +curl -i -H "Cookie: zoraxy_captcha_session=YOUR_SESSION_ID" http://your-domain.com/ +``` + +## API Endpoints + +The following API endpoints support CAPTCHA configuration: + +- `POST /api/proxy/add`: Add new endpoint with CAPTCHA +- `POST /api/proxy/edit`: Edit existing endpoint CAPTCHA settings + +## Future Enhancements + +Potential improvements for future versions: + +1. **Web UI Integration**: Add CAPTCHA settings to the web-based admin panel +2. **Additional Providers**: Support for hCaptcha, FriendlyCaptcha +3. **Exception Rule Management API**: Dedicated endpoints for managing exception rules +4. **Analytics**: Track CAPTCHA solve rates and bot detection statistics +5. **Custom Challenge Pages**: Allow custom HTML templates for CAPTCHA pages +6. **Distributed Sessions**: Support for session sharing across multiple Zoraxy instances + +## Troubleshooting + +### CAPTCHA not showing + +- Check that `RequireCaptcha` is `true` in endpoint configuration +- Verify `CaptchaConfig` is not `nil` +- Check logs for any errors + +### Verification failures + +- Verify Site Key and Secret Key are correct +- Check network connectivity to CAPTCHA provider APIs +- Ensure client IP detection is working correctly + +### Session not persisting + +- Check cookie settings in browser +- Verify session duration configuration +- Ensure cookies are not being blocked by browser settings + +## Credits + +Implemented following the existing Zoraxy architecture patterns: +- Rate limiting implementation for reference +- Basic authentication exception rules for exception handling pattern +- Access control for IP filtering patterns + +--- + +For questions or issues, please file a GitHub issue at https://github.com/tobychui/zoraxy/issues diff --git a/src/mod/dynamicproxy/Server.go b/src/mod/dynamicproxy/Server.go index 978eb9a..762b2df 100644 --- a/src/mod/dynamicproxy/Server.go +++ b/src/mod/dynamicproxy/Server.go @@ -98,6 +98,15 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // CAPTCHA Gating + if sep.RequireCaptcha && sep.CaptchaConfig != nil { + err := h.handleCaptchaRouting(w, r, sep, h.Parent.captchaSessionStore) + if err != nil { + h.Parent.logRequest(r, false, 403, "captcha-required", domainOnly, "captcha", sep) + return + } + } + //Validate auth (basic auth or SSO auth) respWritten := handleAuthProviderRouting(sep, w, r, h) if respWritten { diff --git a/src/mod/dynamicproxy/captcha.go b/src/mod/dynamicproxy/captcha.go new file mode 100644 index 0000000..f865a89 --- /dev/null +++ b/src/mod/dynamicproxy/captcha.go @@ -0,0 +1,729 @@ +package dynamicproxy + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +/* + captcha.go + + CAPTCHA verification and session management for gating access to endpoints. + Supports Cloudflare Turnstile and Google reCAPTCHA (v2 and v3). +*/ + +const ( + CaptchaCookieName = "zoraxy_captcha_session" + CaptchaVerifyPath = "/__zoraxy_captcha_verify" + DefaultSessionDuration = 3600 // 1 hour in seconds + CloudflareTurnstileAPI = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + GoogleRecaptchaAPIv2v3 = "https://www.google.com/recaptcha/api/siteverify" +) + +// CaptchaSessionStore manages active CAPTCHA sessions +type CaptchaSessionStore struct { + sessions sync.Map // map[sessionID]expiryTime +} + +// NewCaptchaSessionStore creates a new CAPTCHA session store +func NewCaptchaSessionStore() *CaptchaSessionStore { + store := &CaptchaSessionStore{} + go store.cleanupExpiredSessions() + return store +} + +// generateSessionID creates a random session ID +func generateSessionID() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// AddSession adds a new session with the specified duration +func (s *CaptchaSessionStore) AddSession(sessionID string, durationSeconds int) { + expiryTime := time.Now().Add(time.Duration(durationSeconds) * time.Second) + s.sessions.Store(sessionID, expiryTime) +} + +// IsValidSession checks if a session is valid and not expired +func (s *CaptchaSessionStore) IsValidSession(sessionID string) bool { + value, exists := s.sessions.Load(sessionID) + if !exists { + return false + } + expiryTime, ok := value.(time.Time) + if !ok { + return false + } + return time.Now().Before(expiryTime) +} + +// RemoveSession removes a session from the store +func (s *CaptchaSessionStore) RemoveSession(sessionID string) { + s.sessions.Delete(sessionID) +} + +// cleanupExpiredSessions periodically removes expired sessions +func (s *CaptchaSessionStore) cleanupExpiredSessions() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now() + s.sessions.Range(func(key, value interface{}) bool { + expiryTime, ok := value.(time.Time) + if !ok || now.After(expiryTime) { + s.sessions.Delete(key) + } + return true + }) + } +} + +// CloudflareTurnstileResponse represents the response from Cloudflare Turnstile API +type CloudflareTurnstileResponse struct { + Success bool `json:"success"` + ChallengeTS string `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes"` + Action string `json:"action"` + CData string `json:"cdata"` +} + +// GoogleRecaptchaResponse represents the response from Google reCAPTCHA API +type GoogleRecaptchaResponse struct { + Success bool `json:"success"` + Score float64 `json:"score"` // v3 only + Action string `json:"action"` // v3 only + ChallengeTS string `json:"challenge_ts"` // ISO timestamp + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes"` +} + +// VerifyCloudflareToken verifies a Cloudflare Turnstile token +func VerifyCloudflareToken(token, secretKey, remoteIP string) (bool, error) { + if token == "" || secretKey == "" { + return false, errors.New("token and secret key are required") + } + + formData := url.Values{ + "secret": {secretKey}, + "response": {token}, + } + if remoteIP != "" { + formData.Add("remoteip", remoteIP) + } + + resp, err := http.PostForm(CloudflareTurnstileAPI, formData) + if err != nil { + return false, fmt.Errorf("failed to verify token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("failed to read response: %w", err) + } + + var result CloudflareTurnstileResponse + if err := json.Unmarshal(body, &result); err != nil { + return false, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return false, fmt.Errorf("verification failed: %v", result.ErrorCodes) + } + + return true, nil +} + +// VerifyGoogleRecaptchaToken verifies a Google reCAPTCHA token +func VerifyGoogleRecaptchaToken(token, secretKey, remoteIP string, version string, minScore float64) (bool, error) { + if token == "" || secretKey == "" { + return false, errors.New("token and secret key are required") + } + + formData := url.Values{ + "secret": {secretKey}, + "response": {token}, + } + if remoteIP != "" { + formData.Add("remoteip", remoteIP) + } + + resp, err := http.PostForm(GoogleRecaptchaAPIv2v3, formData) + if err != nil { + return false, fmt.Errorf("failed to verify token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("failed to read response: %w", err) + } + + var result GoogleRecaptchaResponse + if err := json.Unmarshal(body, &result); err != nil { + return false, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return false, fmt.Errorf("verification failed: %v", result.ErrorCodes) + } + + // For v3, check the score + if version == "v3" { + if result.Score < minScore { + return false, fmt.Errorf("score too low: %f < %f", result.Score, minScore) + } + } + + return true, nil +} + +// GetClientIP extracts the real client IP from the request +func GetClientIP(r *http.Request) string { + // Check X-Real-IP header first + if ip := r.Header.Get("X-Real-Ip"); ip != "" { + return ip + } + + // Check CF-Connecting-IP for Cloudflare + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + return ip + } + + // Check Fastly-Client-IP + if ip := r.Header.Get("Fastly-Client-IP"); ip != "" { + return ip + } + + // Check X-Forwarded-For + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // Fall back to RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +// CheckCaptchaException checks if the request matches any exception rules +func CheckCaptchaException(r *http.Request, rules []*CaptchaExceptionRule) bool { + if rules == nil || len(rules) == 0 { + return false + } + + clientIP := GetClientIP(r) + requestPath := r.URL.Path + + for _, rule := range rules { + switch rule.RuleType { + case CaptchaExceptionType_Paths: + if strings.HasPrefix(requestPath, rule.PathPrefix) { + return true + } + case CaptchaExceptionType_CIDR: + if rule.CIDR != "" { + // Check if it's a single IP or CIDR + if !strings.Contains(rule.CIDR, "/") { + // Single IP + if clientIP == rule.CIDR { + return true + } + } else { + // CIDR range + _, ipNet, err := net.ParseCIDR(rule.CIDR) + if err == nil { + ip := net.ParseIP(clientIP) + if ip != nil && ipNet.Contains(ip) { + return true + } + } + } + } + } + } + + return false +} + +// handleCaptchaRouting handles CAPTCHA verification for a proxy endpoint +func (h *ProxyHandler) handleCaptchaRouting(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint, sessionStore *CaptchaSessionStore) error { + // Check if CAPTCHA verification endpoint + if r.URL.Path == CaptchaVerifyPath { + return h.handleCaptchaVerification(w, r, pe, sessionStore) + } + + // Check for exception rules + if pe.CaptchaConfig != nil && CheckCaptchaException(r, pe.CaptchaConfig.ExceptionRules) { + return nil // Allow passthrough + } + + // Check for existing valid session + cookie, err := r.Cookie(CaptchaCookieName) + if err == nil && sessionStore.IsValidSession(cookie.Value) { + return nil // Session is valid, allow passthrough + } + + // No valid session, serve CAPTCHA challenge + h.serveCaptchaChallenge(w, r, pe) + return errors.New("captcha required") +} + +// handleCaptchaVerification processes CAPTCHA verification requests +func (h *ProxyHandler) handleCaptchaVerification(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint, sessionStore *CaptchaSessionStore) error { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return errors.New("invalid method") + } + + // Parse form data + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return err + } + + token := r.FormValue("cf-turnstile-response") + if token == "" { + token = r.FormValue("g-recaptcha-response") + } + if token == "" { + http.Error(w, "CAPTCHA token missing", http.StatusBadRequest) + return errors.New("token missing") + } + + clientIP := GetClientIP(r) + var verified bool + var verifyErr error + + // Verify based on provider + if pe.CaptchaConfig.Provider == CaptchaProviderCloudflare { + verified, verifyErr = VerifyCloudflareToken(token, pe.CaptchaConfig.SecretKey, clientIP) + } else if pe.CaptchaConfig.Provider == CaptchaProviderGoogle { + version := pe.CaptchaConfig.RecaptchaVersion + if version == "" { + version = "v2" + } + minScore := pe.CaptchaConfig.RecaptchaScore + if minScore == 0 { + minScore = 0.5 + } + verified, verifyErr = VerifyGoogleRecaptchaToken(token, pe.CaptchaConfig.SecretKey, clientIP, version, minScore) + } else { + http.Error(w, "Invalid CAPTCHA provider", http.StatusInternalServerError) + return errors.New("invalid provider") + } + + if verifyErr != nil || !verified { + // Verification failed + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "CAPTCHA verification failed", + }) + return errors.New("verification failed") + } + + // Create session + sessionID, err := generateSessionID() + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return err + } + + duration := pe.CaptchaConfig.SessionDuration + if duration == 0 { + duration = DefaultSessionDuration + } + sessionStore.AddSession(sessionID, duration) + + // Set cookie + cookie := &http.Cookie{ + Name: CaptchaCookieName, + Value: sessionID, + Path: "/", + MaxAge: duration, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + } + http.SetCookie(w, cookie) + + // Return success response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }) + + return nil +} + +// serveCaptchaChallenge serves the CAPTCHA challenge page +func (h *ProxyHandler) serveCaptchaChallenge(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + + var captchaHTML bytes.Buffer + if pe.CaptchaConfig.Provider == CaptchaProviderCloudflare { + // Cloudflare Turnstile + captchaHTML.WriteString(fmt.Sprintf(` + +
+ + +Please complete the security check below to access this page.
+ + +Please complete the security check below to access this page.
+ + +Verifying your connection...
+ + +