Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[minor] Add protectHttpMethods config #28

Merged
merged 1 commit into from
Feb 26, 2025
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand All @@ -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. |
Expand Down
49 changes: 42 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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",
},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -641,3 +667,12 @@ func getDefaultTmpl() string {
</body>
</html>`
}

func strInSlice(s string, sl []string) bool {
for _, a := range sl {
if a == s {
return true
}
}
return false
}