Add CAPTCHA gating feature with Cloudflare Turnstile and Google reCAPTCHA support

Implemented a comprehensive CAPTCHA gating system similar to Cloudflare Turnstile
that allows per-endpoint protection of resources with CAPTCHA challenges.

Features:
- Per-endpoint CAPTCHA configuration (just like rate limiting)
- Support for Cloudflare Turnstile
- Support for Google reCAPTCHA v2 (checkbox) and v3 (invisible with score)
- Session management with configurable duration (default 1 hour)
- Exception rules for paths and IP ranges/CIDR blocks
- Modern, responsive CAPTCHA challenge pages
- Secure session cookies with HttpOnly and Secure flags
- Automatic cleanup of expired sessions

Implementation details:
- Added CaptchaConfig, CaptchaProvider, and CaptchaExceptionRule types
- Created captcha.go module with verification logic for both providers
- Integrated CAPTCHA middleware into proxy request chain (after rate limiting, before auth)
- Added CAPTCHA session store to Router for tracking validated users
- Updated API endpoints (add/edit) to support CAPTCHA configuration
- Added comprehensive documentation in CAPTCHA_FEATURE.md

The feature follows existing Zoraxy patterns:
- Per-endpoint control similar to rate limiting
- Exception rules similar to basic auth exceptions
- IP detection similar to access control

Configuration is stored in proxy endpoint .config files and can be managed
via the existing /api/proxy/add and /api/proxy/edit endpoints.
This commit is contained in:
Claude
2025-12-13 02:42:37 +00:00
parent 6adce0dcb5
commit 023b0aa73b
6 changed files with 1155 additions and 7 deletions

276
CAPTCHA_FEATURE.md Normal file
View File

@@ -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

View File

@@ -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 {

View File

@@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check Required</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 24px;
}
p {
color: #666;
margin-bottom: 30px;
}
.captcha-container {
display: flex;
justify-content: center;
margin: 20px 0;
}
#status {
margin-top: 20px;
padding: 10px;
border-radius: 5px;
display: none;
}
.error {
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
}
.success {
background-color: #efe;
color: #3c3;
border: 1px solid #cfc;
}
</style>
</head>
<body>
<div class="container">
<h1>🛡️ Security Check Required</h1>
<p>Please complete the security check below to access this page.</p>
<form id="captchaForm">
<div class="captcha-container">
<div class="cf-turnstile" data-sitekey="%s" data-callback="onCaptchaSuccess"></div>
</div>
<div id="status"></div>
</form>
</div>
<script>
function onCaptchaSuccess(token) {
const formData = new FormData();
formData.append('cf-turnstile-response', token);
fetch('%s', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
const status = document.getElementById('status');
status.textContent = 'Verification successful! Redirecting...';
status.className = 'success';
status.style.display = 'block';
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
const status = document.getElementById('status');
status.textContent = 'Verification failed. Please try again.';
status.className = 'error';
status.style.display = 'block';
}
})
.catch(error => {
const status = document.getElementById('status');
status.textContent = 'An error occurred. Please try again.';
status.className = 'error';
status.style.display = 'block';
});
}
</script>
</body>
</html>`, pe.CaptchaConfig.SiteKey, CaptchaVerifyPath))
} else {
// Google reCAPTCHA
version := pe.CaptchaConfig.RecaptchaVersion
if version == "" {
version = "v2"
}
if version == "v2" {
captchaHTML.WriteString(fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check Required</title>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 24px;
}
p {
color: #666;
margin-bottom: 30px;
}
.captcha-container {
display: flex;
justify-content: center;
margin: 20px 0;
}
#status {
margin-top: 20px;
padding: 10px;
border-radius: 5px;
display: none;
}
.error {
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
}
.success {
background-color: #efe;
color: #3c3;
border: 1px solid #cfc;
}
</style>
</head>
<body>
<div class="container">
<h1>🛡️ Security Check Required</h1>
<p>Please complete the security check below to access this page.</p>
<form id="captchaForm" onsubmit="return handleSubmit(event)">
<div class="captcha-container">
<div class="g-recaptcha" data-sitekey="%s" data-callback="onCaptchaSuccess"></div>
</div>
<div id="status"></div>
</form>
</div>
<script>
function onCaptchaSuccess(token) {
const formData = new FormData();
formData.append('g-recaptcha-response', token);
fetch('%s', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
const status = document.getElementById('status');
status.textContent = 'Verification successful! Redirecting...';
status.className = 'success';
status.style.display = 'block';
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
const status = document.getElementById('status');
status.textContent = 'Verification failed. Please try again.';
status.className = 'error';
status.style.display = 'block';
grecaptcha.reset();
}
})
.catch(error => {
const status = document.getElementById('status');
status.textContent = 'An error occurred. Please try again.';
status.className = 'error';
status.style.display = 'block';
grecaptcha.reset();
});
}
function handleSubmit(e) {
e.preventDefault();
return false;
}
</script>
</body>
</html>`, pe.CaptchaConfig.SiteKey, CaptchaVerifyPath))
} else {
// reCAPTCHA v3
captchaHTML.WriteString(fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check Required</title>
<script src="https://www.google.com/recaptcha/api.js?render=%s"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 24px;
}
p {
color: #666;
margin-bottom: 30px;
}
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0%% { transform: rotate(0deg); }
100%% { transform: rotate(360deg); }
}
#status {
margin-top: 20px;
padding: 10px;
border-radius: 5px;
}
.error {
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
}
.success {
background-color: #efe;
color: #3c3;
border: 1px solid #cfc;
}
</style>
</head>
<body>
<div class="container">
<h1>🛡️ Security Check</h1>
<p>Verifying your connection...</p>
<div class="loader"></div>
<div id="status"></div>
</div>
<script>
grecaptcha.ready(function() {
grecaptcha.execute('%s', {action: 'access'}).then(function(token) {
const formData = new FormData();
formData.append('g-recaptcha-response', token);
fetch('%s', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
const status = document.getElementById('status');
if (data.success) {
status.textContent = 'Verification successful! Redirecting...';
status.className = 'success';
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
status.textContent = 'Verification failed. Access denied.';
status.className = 'error';
}
})
.catch(error => {
const status = document.getElementById('status');
status.textContent = 'An error occurred. Please refresh the page.';
status.className = 'error';
});
});
});
</script>
</body>
</html>`, pe.CaptchaConfig.SiteKey, pe.CaptchaConfig.SiteKey, CaptchaVerifyPath))
}
}
w.Write(captchaHTML.Bytes())
}

View File

@@ -23,13 +23,14 @@ import (
func NewDynamicProxy(option RouterOption) (*Router, error) {
proxyMap := sync.Map{}
thisRouter := Router{
Option: &option,
ProxyEndpoints: &proxyMap,
Running: false,
server: nil,
routingRules: []*RoutingRule{},
loadBalancer: option.LoadBalancer,
rateLimitCounter: RequestCountPerIpTable{},
Option: &option,
ProxyEndpoints: &proxyMap,
Running: false,
server: nil,
routingRules: []*RoutingRule{},
loadBalancer: option.LoadBalancer,
rateLimitCounter: RequestCountPerIpTable{},
captchaSessionStore: NewCaptchaSessionStore(),
}
thisRouter.mux = &ProxyHandler{
@@ -144,6 +145,14 @@ func (router *Router) StartProxyService() error {
}
}
// CAPTCHA Gating
if sep.RequireCaptcha && sep.CaptchaConfig != nil {
ph := &ProxyHandler{Parent: router}
if err := ph.handleCaptchaRouting(w, r, sep, router.captchaSessionStore); err != nil {
return
}
}
//Validate basic auth
if sep.AuthenticationProvider.AuthMethod == AuthMethodBasic {
err := handleBasicAuth(w, r, sep)

View File

@@ -93,6 +93,8 @@ type Router struct {
rateLimterStop chan bool //Stop channel for rate limiter
rateLimitCounter RequestCountPerIpTable //Request counter for rate limter
captchaSessionStore *CaptchaSessionStore //CAPTCHA session store for tracking verified sessions
}
/* Basic Auth Related Data structure*/
@@ -179,6 +181,38 @@ type AuthenticationProvider struct {
ForwardAuthRequestExcludedCookies []string // List of cookies to exclude from the request after sending it to the forward auth server.
}
/* CAPTCHA Provider Configuration */
type CaptchaProvider int
const (
CaptchaProviderCloudflare CaptchaProvider = iota // Cloudflare Turnstile
CaptchaProviderGoogle // Google reCAPTCHA v2/v3
)
type CaptchaConfig struct {
Provider CaptchaProvider // CAPTCHA provider type
SiteKey string // Site key / public key
SecretKey string // Secret key / private key
ExceptionRules []*CaptchaExceptionRule // Paths or IPs to exclude from CAPTCHA
SessionDuration int // Duration in seconds for which a successful CAPTCHA is valid (default: 3600)
RecaptchaVersion string // For Google reCAPTCHA: "v2" or "v3" (default: "v2")
RecaptchaScore float64 // For Google reCAPTCHA v3: minimum score threshold (0.0-1.0, default: 0.5)
}
type CaptchaExceptionType int
const (
CaptchaExceptionType_Paths CaptchaExceptionType = iota // Path exception, match by path prefix
CaptchaExceptionType_CIDR // CIDR exception, match by CIDR
)
type CaptchaExceptionRule struct {
RuleType CaptchaExceptionType // The type of the exception rule
PathPrefix string // Path prefix to match, e.g. /api/v1/
CIDR string // CIDR to match, e.g. 192.168.1.0/24 or IP address
}
// A proxy endpoint record, a general interface for handling inbound routing
type ProxyEndpoint struct {
ProxyType ProxyType //The type of this proxy, see const def
@@ -208,6 +242,10 @@ type ProxyEndpoint struct {
RequireRateLimit bool
RateLimit int64 // Rate limit in requests per second
// CAPTCHA Gating
RequireCaptcha bool // Enable CAPTCHA gating for this endpoint
CaptchaConfig *CaptchaConfig // CAPTCHA provider configuration
//Uptime Monitor
DisableUptimeMonitor bool //Disable uptime monitor for this endpoint
DisableAutoFallback bool //Disable automatic fallback when uptime monitor detects an upstream is down (continue monitoring but don't auto-disable upstream)

View File

@@ -300,6 +300,46 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
}
}
// CAPTCHA Gating
requireCaptcha, _ := utils.PostBool(r, "captcha")
captchaProviderStr, _ := utils.PostPara(r, "captchaProvider")
captchaSiteKey, _ := utils.PostPara(r, "captchaSiteKey")
captchaSecretKey, _ := utils.PostPara(r, "captchaSecretKey")
captchaSessionDurationStr, _ := utils.PostPara(r, "captchaSessionDuration")
captchaRecaptchaVersion, _ := utils.PostPara(r, "captchaRecaptchaVersion")
captchaRecaptchaScoreStr, _ := utils.PostPara(r, "captchaRecaptchaScore")
var captchaConfig *dynamicproxy.CaptchaConfig
if requireCaptcha {
captchaProvider := 0
if captchaProviderStr != "" {
captchaProvider, _ = strconv.Atoi(captchaProviderStr)
}
captchaSessionDuration := 3600
if captchaSessionDurationStr != "" {
captchaSessionDuration, _ = strconv.Atoi(captchaSessionDurationStr)
}
captchaRecaptchaScore := 0.5
if captchaRecaptchaScoreStr != "" {
captchaRecaptchaScore, _ = strconv.ParseFloat(captchaRecaptchaScoreStr, 64)
}
if captchaRecaptchaVersion == "" {
captchaRecaptchaVersion = "v2"
}
captchaConfig = &dynamicproxy.CaptchaConfig{
Provider: dynamicproxy.CaptchaProvider(captchaProvider),
SiteKey: captchaSiteKey,
SecretKey: captchaSecretKey,
SessionDuration: captchaSessionDuration,
RecaptchaVersion: captchaRecaptchaVersion,
RecaptchaScore: captchaRecaptchaScore,
}
}
// Bypass WebSocket Origin Check
strbpwsorg, _ := utils.PostPara(r, "bpwsorg")
if strbpwsorg == "" {
@@ -434,6 +474,9 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
// Rate Limit
RequireRateLimit: requireRateLimit,
RateLimit: int64(proxyRateLimit),
// CAPTCHA Gating
RequireCaptcha: requireCaptcha,
CaptchaConfig: captchaConfig,
Tags: tags,
DisableUptimeMonitor: !enableUtm,
@@ -493,6 +536,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
BypassGlobalTLS: false,
DefaultSiteOption: defaultSiteOption,
DefaultSiteValue: dsVal,
RequireCaptcha: requireCaptcha,
CaptchaConfig: captchaConfig,
}
preparedRootProxyRoute, err := dynamicProxyRouter.PrepareProxyRoute(&rootRoutingEndpoint)
if err != nil {
@@ -593,6 +638,46 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
proxyRateLimit = 1000
}
// CAPTCHA Gating
requireCaptcha, _ := utils.PostBool(r, "captcha")
captchaProviderStr, _ := utils.PostPara(r, "captchaProvider")
captchaSiteKey, _ := utils.PostPara(r, "captchaSiteKey")
captchaSecretKey, _ := utils.PostPara(r, "captchaSecretKey")
captchaSessionDurationStr, _ := utils.PostPara(r, "captchaSessionDuration")
captchaRecaptchaVersion, _ := utils.PostPara(r, "captchaRecaptchaVersion")
captchaRecaptchaScoreStr, _ := utils.PostPara(r, "captchaRecaptchaScore")
var captchaConfig *dynamicproxy.CaptchaConfig
if requireCaptcha {
captchaProvider := 0
if captchaProviderStr != "" {
captchaProvider, _ = strconv.Atoi(captchaProviderStr)
}
captchaSessionDuration := 3600
if captchaSessionDurationStr != "" {
captchaSessionDuration, _ = strconv.Atoi(captchaSessionDurationStr)
}
captchaRecaptchaScore := 0.5
if captchaRecaptchaScoreStr != "" {
captchaRecaptchaScore, _ = strconv.ParseFloat(captchaRecaptchaScoreStr, 64)
}
if captchaRecaptchaVersion == "" {
captchaRecaptchaVersion = "v2"
}
captchaConfig = &dynamicproxy.CaptchaConfig{
Provider: dynamicproxy.CaptchaProvider(captchaProvider),
SiteKey: captchaSiteKey,
SecretKey: captchaSecretKey,
SessionDuration: captchaSessionDuration,
RecaptchaVersion: captchaRecaptchaVersion,
RecaptchaScore: captchaRecaptchaScore,
}
}
// Disable chunked Encoding
disableChunkedEncoding, _ := utils.PostBool(r, "dChunkedEnc")
@@ -652,6 +737,8 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
newProxyEndpoint.RequireRateLimit = requireRateLimit
newProxyEndpoint.RateLimit = proxyRateLimit
newProxyEndpoint.RequireCaptcha = requireCaptcha
newProxyEndpoint.CaptchaConfig = captchaConfig
newProxyEndpoint.UseStickySession = useStickySession
newProxyEndpoint.DisableUptimeMonitor = disbleUtm
newProxyEndpoint.DisableAutoFallback = disableAutoFallback