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
47 changes: 24 additions & 23 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.4.1
--experimental.plugins.captcha-protect.version=v1.5.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 @@ -89,28 +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. |
| `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. |
| `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
36 changes: 22 additions & 14 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Config struct {
IPDepth int `json:"ipDepth"`
ProtectParameters string `json:"protectParameters"`
ProtectRoutes []string `json:"protectRoutes"`
ExcludeRoutes []string `json:"excludeRoutes"`
ProtectFileExtensions []string `json:"protectFileExtensions"`
ProtectHttpMethods []string `json:"protectHttpMethods"`
GoodBots []string `json:"goodBots"`
Expand Down Expand Up @@ -80,6 +81,7 @@ func CreateConfig() *Config {
IPForwardedHeader: "",
ProtectParameters: "false",
ProtectRoutes: []string{},
ExcludeRoutes: []string{},
ProtectHttpMethods: []string{},
ProtectFileExtensions: []string{
"html",
Expand Down Expand Up @@ -360,26 +362,32 @@ func (bc *CaptchaProtect) shouldApply(req *http.Request, clientIP string) bool {
}

func (bc *CaptchaProtect) RouteIsProtected(path string) bool {
protected:
for _, route := range bc.config.ProtectRoutes {
if strings.HasPrefix(path, route) {
ext := filepath.Ext(path)
ext = strings.TrimPrefix(ext, ".")
if ext == "" {
return true
}
if !strings.HasPrefix(path, route) {
continue
}

skip := true
for _, protectedExtensions := range bc.config.ProtectFileExtensions {
if strings.EqualFold(ext, protectedExtensions) {
skip = false
}
}
if skip {
continue
// we're on a protected route - make sure this route doesn't have an exclusion
for _, eRoute := range bc.config.ExcludeRoutes {
if strings.HasPrefix(path, eRoute) {
continue protected
}
}

// if this path isn't a file, go ahead and mark this path as protected
ext := filepath.Ext(path)
ext = strings.TrimPrefix(ext, ".")
if ext == "" {
return true
}

// if we have a file extension, see if we should protect this file extension type
for _, protectedExtensions := range bc.config.ProtectFileExtensions {
if strings.EqualFold(ext, protectedExtensions) {
return true
}
}
}

return false
Expand Down
64 changes: 46 additions & 18 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,72 +297,100 @@ func TestRouteIsProtected(t *testing.T) {
{
name: "Protected route - exact match",
config: Config{
ProtectRoutes: []string{"/"},
ProtectRoutes: []string{"/foo", "/bar", "/baz"},
ProtectFileExtensions: []string{},
ExcludeRoutes: []string{},
},
path: "/foo",
path: "/baz",
expected: true,
},
{
name: "Unprotected route",
config: Config{
ProtectRoutes: []string{"/foo"},
ProtectRoutes: []string{"/foo", "/bar", "/baz"},
ProtectFileExtensions: []string{},
ExcludeRoutes: []string{},
},
path: "/bar",
path: "/ddos-me",
expected: false,
},
{
name: "Protected route with included file extension",
config: Config{
ProtectRoutes: []string{"/foo"},
ProtectFileExtensions: []string{"css", "js"},
ProtectRoutes: []string{"/foo", "/bar", "/baz"},
ProtectFileExtensions: []string{"jp2", "json"},
ExcludeRoutes: []string{},
},
path: "/foo/bar/style.css",
path: "/foo/bar/style.json",
expected: true,
},
{
name: "html always protected",
config: Config{
ProtectRoutes: []string{"/"},
ProtectFileExtensions: []string{"css", "js"},
ProtectRoutes: []string{"/foo", "/bar", "/baz"},
ProtectFileExtensions: []string{"jp2", "json"},
ExcludeRoutes: []string{},
},
path: "/foo/bar/data.html",
expected: true,
},
{
name: "subpath route protection",
config: Config{
ProtectRoutes: []string{"/foo"},
ProtectRoutes: []string{"/foo", "/bar", "/baz"},
ProtectFileExtensions: []string{},
ExcludeRoutes: []string{},
},
path: "/foo/any/route",
path: "/bar/any/route",
expected: true,
},
{
name: "No routes protected",
name: "File extension in unprotected route",
config: Config{
ProtectRoutes: []string{"/foo", "/bar", "/baz"},
ProtectFileExtensions: []string{"jp2", "json"},
ExcludeRoutes: []string{},
},
path: "/unprotected/script.json",
expected: false,
},
{
name: "Excluded route not protected (exact match)",
config: Config{
ProtectRoutes: []string{},
ProtectRoutes: []string{"/"},
ProtectFileExtensions: []string{},
ExcludeRoutes: []string{"/ajax"},
},
path: "/any/route",
path: "/ajax",
expected: false,
},
{
name: "File extension in unprotected route",
name: "Excluded route not protected (prefix match)",
config: Config{
ProtectRoutes: []string{"/protected"},
ProtectFileExtensions: []string{"css", "js"},
ProtectRoutes: []string{"/foo"},
ProtectFileExtensions: []string{},
ExcludeRoutes: []string{"/ajax"},
},
path: "/unprotected/script.js",
path: "/ajax/foo",
expected: false,
},
{
name: "Excluded route protected (no prefix match)",
config: Config{
ProtectRoutes: []string{"/"},
ProtectFileExtensions: []string{},
ExcludeRoutes: []string{"/ajax"},
},
path: "/not-ajax",
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := CreateConfig()
c.ProtectRoutes = append(c.ProtectRoutes, tt.config.ProtectRoutes...)
c.ExcludeRoutes = append(c.ExcludeRoutes, tt.config.ExcludeRoutes...)
c.ProtectFileExtensions = append(c.ProtectFileExtensions, tt.config.ProtectFileExtensions...)
bc := &CaptchaProtect{
config: c,
Expand Down