mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-12-19 13:57:00 +01:00
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:
276
CAPTCHA_FEATURE.md
Normal file
276
CAPTCHA_FEATURE.md
Normal 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
|
||||||
@@ -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)
|
//Validate auth (basic auth or SSO auth)
|
||||||
respWritten := handleAuthProviderRouting(sep, w, r, h)
|
respWritten := handleAuthProviderRouting(sep, w, r, h)
|
||||||
if respWritten {
|
if respWritten {
|
||||||
|
|||||||
729
src/mod/dynamicproxy/captcha.go
Normal file
729
src/mod/dynamicproxy/captcha.go
Normal 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())
|
||||||
|
}
|
||||||
@@ -23,13 +23,14 @@ import (
|
|||||||
func NewDynamicProxy(option RouterOption) (*Router, error) {
|
func NewDynamicProxy(option RouterOption) (*Router, error) {
|
||||||
proxyMap := sync.Map{}
|
proxyMap := sync.Map{}
|
||||||
thisRouter := Router{
|
thisRouter := Router{
|
||||||
Option: &option,
|
Option: &option,
|
||||||
ProxyEndpoints: &proxyMap,
|
ProxyEndpoints: &proxyMap,
|
||||||
Running: false,
|
Running: false,
|
||||||
server: nil,
|
server: nil,
|
||||||
routingRules: []*RoutingRule{},
|
routingRules: []*RoutingRule{},
|
||||||
loadBalancer: option.LoadBalancer,
|
loadBalancer: option.LoadBalancer,
|
||||||
rateLimitCounter: RequestCountPerIpTable{},
|
rateLimitCounter: RequestCountPerIpTable{},
|
||||||
|
captchaSessionStore: NewCaptchaSessionStore(),
|
||||||
}
|
}
|
||||||
|
|
||||||
thisRouter.mux = &ProxyHandler{
|
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
|
//Validate basic auth
|
||||||
if sep.AuthenticationProvider.AuthMethod == AuthMethodBasic {
|
if sep.AuthenticationProvider.AuthMethod == AuthMethodBasic {
|
||||||
err := handleBasicAuth(w, r, sep)
|
err := handleBasicAuth(w, r, sep)
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ type Router struct {
|
|||||||
|
|
||||||
rateLimterStop chan bool //Stop channel for rate limiter
|
rateLimterStop chan bool //Stop channel for rate limiter
|
||||||
rateLimitCounter RequestCountPerIpTable //Request counter for rate limter
|
rateLimitCounter RequestCountPerIpTable //Request counter for rate limter
|
||||||
|
|
||||||
|
captchaSessionStore *CaptchaSessionStore //CAPTCHA session store for tracking verified sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Basic Auth Related Data structure*/
|
/* 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.
|
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
|
// A proxy endpoint record, a general interface for handling inbound routing
|
||||||
type ProxyEndpoint struct {
|
type ProxyEndpoint struct {
|
||||||
ProxyType ProxyType //The type of this proxy, see const def
|
ProxyType ProxyType //The type of this proxy, see const def
|
||||||
@@ -208,6 +242,10 @@ type ProxyEndpoint struct {
|
|||||||
RequireRateLimit bool
|
RequireRateLimit bool
|
||||||
RateLimit int64 // Rate limit in requests per second
|
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
|
//Uptime Monitor
|
||||||
DisableUptimeMonitor bool //Disable uptime monitor for this endpoint
|
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)
|
DisableAutoFallback bool //Disable automatic fallback when uptime monitor detects an upstream is down (continue monitoring but don't auto-disable upstream)
|
||||||
|
|||||||
@@ -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
|
// Bypass WebSocket Origin Check
|
||||||
strbpwsorg, _ := utils.PostPara(r, "bpwsorg")
|
strbpwsorg, _ := utils.PostPara(r, "bpwsorg")
|
||||||
if strbpwsorg == "" {
|
if strbpwsorg == "" {
|
||||||
@@ -434,6 +474,9 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Rate Limit
|
// Rate Limit
|
||||||
RequireRateLimit: requireRateLimit,
|
RequireRateLimit: requireRateLimit,
|
||||||
RateLimit: int64(proxyRateLimit),
|
RateLimit: int64(proxyRateLimit),
|
||||||
|
// CAPTCHA Gating
|
||||||
|
RequireCaptcha: requireCaptcha,
|
||||||
|
CaptchaConfig: captchaConfig,
|
||||||
|
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
DisableUptimeMonitor: !enableUtm,
|
DisableUptimeMonitor: !enableUtm,
|
||||||
@@ -493,6 +536,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
BypassGlobalTLS: false,
|
BypassGlobalTLS: false,
|
||||||
DefaultSiteOption: defaultSiteOption,
|
DefaultSiteOption: defaultSiteOption,
|
||||||
DefaultSiteValue: dsVal,
|
DefaultSiteValue: dsVal,
|
||||||
|
RequireCaptcha: requireCaptcha,
|
||||||
|
CaptchaConfig: captchaConfig,
|
||||||
}
|
}
|
||||||
preparedRootProxyRoute, err := dynamicProxyRouter.PrepareProxyRoute(&rootRoutingEndpoint)
|
preparedRootProxyRoute, err := dynamicProxyRouter.PrepareProxyRoute(&rootRoutingEndpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,6 +638,46 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxyRateLimit = 1000
|
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
|
// Disable chunked Encoding
|
||||||
disableChunkedEncoding, _ := utils.PostBool(r, "dChunkedEnc")
|
disableChunkedEncoding, _ := utils.PostBool(r, "dChunkedEnc")
|
||||||
|
|
||||||
@@ -652,6 +737,8 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
newProxyEndpoint.RequireRateLimit = requireRateLimit
|
newProxyEndpoint.RequireRateLimit = requireRateLimit
|
||||||
newProxyEndpoint.RateLimit = proxyRateLimit
|
newProxyEndpoint.RateLimit = proxyRateLimit
|
||||||
|
newProxyEndpoint.RequireCaptcha = requireCaptcha
|
||||||
|
newProxyEndpoint.CaptchaConfig = captchaConfig
|
||||||
newProxyEndpoint.UseStickySession = useStickySession
|
newProxyEndpoint.UseStickySession = useStickySession
|
||||||
newProxyEndpoint.DisableUptimeMonitor = disbleUtm
|
newProxyEndpoint.DisableUptimeMonitor = disbleUtm
|
||||||
newProxyEndpoint.DisableAutoFallback = disableAutoFallback
|
newProxyEndpoint.DisableAutoFallback = disableAutoFallback
|
||||||
|
|||||||
Reference in New Issue
Block a user