Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,29 +89,29 @@ services:
```
### Config options

| **Parameter** | **Type (Required)** | **Default** | **Description** |
|-------------------------|-------------------------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `protectRoutes` | `[]string` (required) | `""` | Comma-separated list of route prefixes to protect. e.g., `"/"` protects the entire site (including file/js/css downloads, which you likely don't want). `"/browse"` protects its subtree. |
| `excludeRoutes` | `[]string` | `""` | Comma-separated list of route prefixes to **never** protect. e.g., `protectRoutes: "/"` protects the entire site. `excludeRoutes: "/ajax"` would never challenge any route starting with `/ajax` |
| `captchaProvider` | `string` (required) | `""` | The captcha type to use. Supported values: `turnstile`, `hcaptcha`, and `recaptcha`. |
| `siteKey` | `string` (required) | `""` | The captcha site key. |
| `secretKey` | `string` (required) | `""` | The captcha secret key. |
| `rateLimit` | `uint` | `20` | Maximum requests allowed from a subnet before a challenge is triggered. |
| `window` | `int` | `86400` | Duration (in seconds) for monitoring requests per subnet. |
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
| `ipv6subnetMask` | `int` | `64` | CIDR subnet mask to group IPv6 addresses for rate limiting. |
| `ipForwardedHeader` | `string` | `""` | Header to check for the original client IP if Traefik is behind a load balancer. |
| `ipDepth` | `int` | `0` | How deep past the last non-exempt IP to fetch the real IP from `ipForwardedHeader`. Default 0 returns the last IP in the forward header |
| `goodBots` | `[]string` (encouraged) | *see below* | List of second-level domains for bots that are never challenged or rate-limited. |
| `protectParameters` | `string` | `"false"` | Forces rate limiting even for good bots if URL parameters are present. Useful for protecting faceted search pages. |
| `protectFileExtensions` | `[]string` | `""` | Comma-separated file extensions to protect. By default, your protected routes only protect html files. This is to prevent files like CSS/JS/img from tripping the rate limit. |
| `protectHttpMethods` | `[]string` | `"GET,HEAD"` | Comma-separated list of HTTP methods to protect against |
| `exemptIps` | `[]string` | `privateIPs` | CIDR-formatted IPs that should never be challenged. Private IP ranges are always exempt. |
| `challengeURL` | `string` | `"/challenge"` | URL where challenges are served. This will override existing routes if there is a conflict. |
| `challengeTmpl` | `string` | `"./challenge.tmpl.html"`| Path to the Go HTML template for the captcha challenge page. |
| `enableStatsPage` | `string` | `"false"` | Allows `exemptIps` to access `/captcha-protect/stats` to monitor the rate limiter. |
| `logLevel` | `string` | `"INFO"` | Log level for the middleware. Options: `ERROR`, `WARNING`, `INFO`, or `DEBUG`. |
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |
| **Parameter** | **Type (Required)** | **Default** | **Description** |
|-------------------------|-------------------------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `protectRoutes` | `[]string` (required) | `""` | Comma-separated list of route prefixes to protect. e.g., `"/"` protects the entire site (including file/js/css downloads, which you likely don't want). `"/browse"` protects its subtree. |
| `excludeRoutes` | `[]string` | `""` | Comma-separated list of route prefixes to **never** protect. e.g., `protectRoutes: "/"` protects the entire site. `excludeRoutes: "/ajax"` would never challenge any route starting with `/ajax` |
| `captchaProvider` | `string` (required) | `""` | The captcha type to use. Supported values: `turnstile`, `hcaptcha`, and `recaptcha`. |
| `siteKey` | `string` (required) | `""` | The captcha site key. |
| `secretKey` | `string` (required) | `""` | The captcha secret key. |
| `rateLimit` | `uint` | `20` | Maximum requests allowed from a subnet before a challenge is triggered. |
| `window` | `int` | `86400` | Duration (in seconds) for monitoring requests per subnet. |
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
| `ipv6subnetMask` | `int` | `64` | CIDR subnet mask to group IPv6 addresses for rate limiting. |
| `ipForwardedHeader` | `string` | `""` | Header to check for the original client IP if Traefik is behind a load balancer. |
| `ipDepth` | `int` | `0` | How deep past the last non-exempt IP to fetch the real IP from `ipForwardedHeader`. Default 0 returns the last IP in the forward header |
| `goodBots` | `[]string` (encouraged) | *see below* | List of second-level domains for bots that are never challenged or rate-limited. |
| `protectParameters` | `string` | `"false"` | Forces rate limiting even for good bots if URL parameters are present. Useful for protecting faceted search pages. |
| `protectFileExtensions` | `[]string` | `""` | Comma-separated file extensions to protect. By default, your protected routes only protect html files. This is to prevent files like CSS/JS/img from tripping the rate limit. |
| `protectHttpMethods` | `[]string` | `"GET,HEAD"` | Comma-separated list of HTTP methods to protect against |
| `exemptIps` | `[]string` | `privateIPs` | CIDR-formatted IPs that should never be challenged. Private IP ranges are always exempt. |
| `challengeURL` | `string` | `"/challenge"` | URL where challenges are served. This will override existing routes if there is a conflict. Setting to blank will have the challenge presented on the same page that tripped the rate limit. |
| `challengeTmpl` | `string` | `"./challenge.tmpl.html"`| Path to the Go HTML template for the captcha challenge page. |
| `enableStatsPage` | `string` | `"false"` | Allows `exemptIps` to access `/captcha-protect/stats` to monitor the rate limiter. |
| `logLevel` | `string` | `"INFO"` | Log level for the middleware. Options: `ERROR`, `WARNING`, `INFO`, or `DEBUG`. |
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |


### Good Bots
Expand Down
60 changes: 49 additions & 11 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ func CreateConfig() *Config {
}

func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
return NewCaptchaProtect(ctx, next, config, name)
}

func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, name string) (*CaptchaProtect, error) {
var logLevel slog.LevelVar
logLevel.Set(slog.LevelInfo)
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Expand All @@ -123,6 +127,10 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
return nil, fmt.Errorf("you must protect at least one route with the protectRoutes config value. / will cover your entire site")
}

if config.ChallengeURL == "/" {
return nil, fmt.Errorf("your challenge URL can not be the entire site. Default is `/challenge`. A blank value will have challenges presented on the visit that trips the rate limit")
}

if len(config.ProtectHttpMethods) == 0 {
config.ProtectHttpMethods = []string{
"GET",
Expand Down Expand Up @@ -220,10 +228,26 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h

func (bc *CaptchaProtect) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
clientIP, ipRange := bc.getClientIP(req)
challengeOnPage := bc.ChallengeOnPage()
if challengeOnPage && req.Method == http.MethodPost {
response := req.FormValue(bc.captchaConfig.key + "-response")
if response == "" {
if !strInSlice(req.Method, bc.config.ProtectHttpMethods) {
bc.next.ServeHTTP(rw, req)
return
}
} else {
statusCode := bc.verifyChallengePage(rw, req, clientIP)
log.Info("Captcha challenge", "clientIP", clientIP, "method", req.Method, "path", req.URL.Path, "status", statusCode, "useragent", req.UserAgent())
return
}
}

if req.URL.Path == bc.config.ChallengeURL {
if req.Method == http.MethodGet {
log.Info("Captcha challenge", "clientIP", clientIP, "method", req.Method, "path", req.URL.Path, "useragent", req.UserAgent())
bc.serveChallengePage(rw, req)
destination := req.URL.Query().Get("destination")
log.Info("Captcha challenge", "clientIP", clientIP, "method", req.Method, "path", req.URL.Path, "destination", destination, "useragent", req.UserAgent())
bc.serveChallengePage(rw, destination)
} else if req.Method == http.MethodPost {
statusCode := bc.verifyChallengePage(rw, req, clientIP)
log.Info("Captcha challenge", "clientIP", clientIP, "method", req.Method, "path", req.URL.Path, "status", statusCode, "useragent", req.UserAgent())
Expand All @@ -243,30 +267,40 @@ func (bc *CaptchaProtect) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
bc.registerRequest(ipRange)

if bc.trippedRateLimit(ipRange) {
encodedURI := url.QueryEscape(req.RequestURI)
url := fmt.Sprintf("%s?destination=%s", bc.config.ChallengeURL, encodedURI)
http.Redirect(rw, req, url, http.StatusFound)
} else {
if !bc.trippedRateLimit(ipRange) {
bc.next.ServeHTTP(rw, req)
return
}

if bc.ChallengeOnPage() {
log.Info("Captcha challenge", "clientIP", clientIP, "method", req.Method, "path", req.URL.Path, "useragent", req.UserAgent())
bc.serveChallengePage(rw, req.URL.Path)
return
}
encodedURI := url.QueryEscape(req.RequestURI)
url := fmt.Sprintf("%s?destination=%s", bc.config.ChallengeURL, encodedURI)
http.Redirect(rw, req, url, http.StatusFound)
}

func (bc *CaptchaProtect) serveChallengePage(rw http.ResponseWriter, req *http.Request) {
func (bc *CaptchaProtect) serveChallengePage(rw http.ResponseWriter, destination string) {
d := map[string]string{
"SiteKey": bc.config.SiteKey,
"FrontendJS": bc.captchaConfig.js,
"FrontendKey": bc.captchaConfig.key,
"ChallengeURL": bc.config.ChallengeURL,
"Destination": req.URL.Query().Get("destination"),
"Destination": destination,
}
status := http.StatusOK
if bc.ChallengeOnPage() {
status = http.StatusTooManyRequests
}
rw.WriteHeader(status)

err := bc.tmpl.Execute(rw, d)
if err != nil {
log.Error("Unable to execute go template", "tmpl", bc.config.ChallengeTmpl, "err", err)
http.Error(rw, "Internal error", http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
}

func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.Request, ip string) int {
Expand Down Expand Up @@ -700,3 +734,7 @@ func strInSlice(s string, sl []string) bool {
}
return false
}

func (bc *CaptchaProtect) ChallengeOnPage() bool {
return bc.config.ChallengeURL == ""
}
61 changes: 61 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package captcha_protect

import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)

Expand Down Expand Up @@ -529,3 +532,61 @@ func parseCIDR(cidr string, t *testing.T) *net.IPNet {
}
return block
}

func TestServeHTTP(t *testing.T) {
next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
_, err := rw.Write([]byte("next"))
if err != nil {
slog.Error("problems", "err", err)
os.Exit(1)
}
})
config := CreateConfig()
tests := []struct {
name string
rateLimit uint
expectedStatus uint
challengePage string
expectedBody string
}{
{
name: "Redirect to 302",
rateLimit: 0,
challengePage: "/challenge",
expectedStatus: http.StatusFound,
expectedBody: "/challenge?destination=%2Fsomepath",
},
{
name: "429 on same page",
rateLimit: 0,
challengePage: "",
expectedStatus: http.StatusTooManyRequests,
expectedBody: "One moment while we verify your network connection",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
config.RateLimit = tc.rateLimit
config.CaptchaProvider = "turnstile"
config.ProtectRoutes = []string{"/"}
config.ChallengeURL = tc.challengePage
config.ExemptIPs = []string{}
cp, err := NewCaptchaProtect(context.Background(), next, config, "captcha-protect")
if err != nil {
t.Errorf("unexpected error %v", err)
}
req := httptest.NewRequest(http.MethodGet, "http://example.com/somepath", nil)
req.RequestURI = "/somepath"
rr := httptest.NewRecorder()
cp.ServeHTTP(rr, req)
if rr.Code != int(tc.expectedStatus) {
t.Errorf("expected %d got %d", tc.expectedStatus, rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, tc.expectedBody) {
t.Errorf("expected %s got %s", tc.expectedBody, body)
}
})
}
}
Loading