diff --git a/README.md b/README.md index a464094..a79a14f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ services: --providers.docker=true --providers.docker.network=default --experimental.plugins.captcha-protect.modulename=github.com/libops/captcha-protect - --experimental.plugins.captcha-protect.version=v1.3.2 + --experimental.plugins.captcha-protect.version=v1.4.0 volumes: - /var/run/docker.sock:/var/run/docker.sock:z - /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw @@ -95,7 +95,7 @@ services: | `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. | +| `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. | @@ -104,6 +104,7 @@ services: | `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. | diff --git a/main.go b/main.go index a89257e..1744553 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ type Config struct { ProtectParameters string `json:"protectParameters"` ProtectRoutes []string `json:"protectRoutes"` ProtectFileExtensions []string `json:"protectFileExtensions"` + ProtectHttpMethods []string `json:"protectHttpMethods"` GoodBots []string `json:"goodBots"` ExemptIPs []string `json:"exemptIps"` ChallengeURL string `json:"challengeURL"` @@ -70,13 +71,14 @@ type captchaResponse struct { func CreateConfig() *Config { return &Config{ - RateLimit: 20, - Window: 86400, - IPv4SubnetMask: 16, - IPv6SubnetMask: 64, - IPForwardedHeader: "", - ProtectParameters: "false", - ProtectRoutes: []string{}, + RateLimit: 20, + Window: 86400, + IPv4SubnetMask: 16, + IPv6SubnetMask: 64, + IPForwardedHeader: "", + ProtectParameters: "false", + ProtectRoutes: []string{}, + ProtectHttpMethods: []string{}, ProtectFileExtensions: []string{ "html", }, @@ -117,6 +119,14 @@ 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 len(config.ProtectHttpMethods) == 0 { + config.ProtectHttpMethods = []string{ + "GET", + "HEAD", + } + } + config.ParseHttpMethods() + var tmpl *template.Template if _, err := os.Stat(config.ChallengeTmpl); os.IsNotExist(err) { log.Warn("Unable to find template file. Using default template.", "challengeTmpl", config.ChallengeTmpl) @@ -317,6 +327,10 @@ func (bc *CaptchaProtect) serveStatsPage(rw http.ResponseWriter, ip string) { } func (bc *CaptchaProtect) shouldApply(req *http.Request, clientIP string) bool { + if !strInSlice(req.Method, bc.config.ProtectHttpMethods) { + return false + } + _, verified := bc.verifiedCache.Get(clientIP) if verified { return false @@ -545,6 +559,18 @@ func ParseLogLevel(level string) (slog.Level, error) { } } +// log a warning if protected methods contains an invalid method +func (c *Config) ParseHttpMethods() { + for _, method := range c.ProtectHttpMethods { + switch method { + case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE": + continue + default: + log.Warn("unknown http method", "method", method) + } + } +} + func (bc *CaptchaProtect) saveState(ctx context.Context) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() @@ -641,3 +667,12 @@ func getDefaultTmpl() string { ` } + +func strInSlice(s string, sl []string) bool { + for _, a := range sl { + if a == s { + return true + } + } + return false +}