diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index bfc2024..92a9623 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -21,7 +21,9 @@ services: traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectParameters: "${PROTECT_PARAMETERS:-false}" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: "" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "true" - traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "/" + traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex" + traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/" + traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.persistentStateFile: "/tmp/state.json" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableStateReconciliation: "true" healthcheck: @@ -53,7 +55,9 @@ services: traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectParameters: "${PROTECT_PARAMETERS:-false}" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: "" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "true" - traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "/" + traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex" + traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/" + traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.persistentStateFile: "/tmp/state.json" traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableStateReconciliation: "true" healthcheck: diff --git a/ci/test.go b/ci/test.go index 7f25399..f1aed01 100755 --- a/ci/test.go +++ b/ci/test.go @@ -65,6 +65,7 @@ func main() { fmt.Printf("Making sure attempt #%d causes a redirect to the challenge page\n", rateLimit+1) ensureRedirect(ips, "http://localhost") + testExcludeRouteRegexBypass(ips) fmt.Println("\nTesting state sharing between nginx instances...") time.Sleep(cp.StateSaveInterval + cp.StateSaveJitter + (5 * time.Second)) @@ -189,6 +190,36 @@ func ensureRedirect(ips []string, url string) { } } +func testExcludeRouteRegexBypass(ips []string) { + fmt.Println("\nTesting regex excludeRoutes bypass...") + + testIP := ips[0] + tests := []struct { + url string + name string + }{ + { + url: "http://localhost/node/123/manifest", + name: "/node/123/manifest", + }, + { + url: "http://localhost/oai/request?foo=bar", + name: "/oai/request?foo=bar", + }, + } + + for _, tt := range tests { + fmt.Printf("Checking excluded route %s with IP %s\n", tt.name, testIP) + output := httpRequest(testIP, tt.url) + if output != "" { + slog.Error("Excluded route was unexpectedly challenged", "ip", testIP, "route", tt.name, "output", output) + os.Exit(1) + } + } + + fmt.Println("✓ regex excludeRoutes bypass works for excluded paths") +} + func testStateSharing(ips []string) { // Use first IP to test state sharing testIP := ips[0] diff --git a/main_test.go b/main_test.go index 7aae468..b5f4e66 100644 --- a/main_test.go +++ b/main_test.go @@ -803,6 +803,52 @@ func TestVerifiedCacheBypasses(t *testing.T) { } } +func TestShouldApplyRegexExcludeRoutesIgnoreQueryString(t *testing.T) { + config := CreateConfig() + config.SiteKey = "test" + config.SecretKey = "test" + config.Mode = "regex" + config.ProtectRoutes = []string{"^/"} + config.ExcludeRoutes = []string{`\/oai\/request`, `\/node\/\d+\/(book-)?manifest`} + config.RateLimit = 0 + + bc, err := NewCaptchaProtect(context.Background(), nil, config, "test") + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + tests := []struct { + name string + url string + want bool + }{ + { + name: "query string does not prevent exclude route match", + url: "http://example.com/oai/request?foo=bar", + want: false, + }, + { + name: "regex exclude route matches manifest path", + url: "http://example.com/node/123/manifest", + want: false, + }, + { + name: "non excluded route still protected", + url: "http://example.com/node/123/other", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + if got := bc.shouldApply(req, "1.2.3.4"); got != tt.want { + t.Errorf("shouldApply(%q) = %v; want %v", tt.url, got, tt.want) + } + }) + } +} + func TestStatsPage(t *testing.T) { config := CreateConfig() config.SiteKey = "test"