diff --git a/main.go b/main.go index 5be2db1..9021c68 100644 --- a/main.go +++ b/main.go @@ -38,8 +38,23 @@ const ( DefaultHealthCheckFailureThreshold = 0 // Number of consecutive health check failures before opening circuit goodBotLookupTimeout = 2 * time.Second maxCaptchaChallengeAge = 5 * time.Minute + turnstileTestHostname = "example.com" ) +var turnstileTestSiteKeys = map[string]struct{}{ + "1x00000000000000000000AA": {}, + "2x00000000000000000000AB": {}, + "1x00000000000000000000BB": {}, + "2x00000000000000000000BB": {}, + "3x00000000000000000000FF": {}, +} + +var turnstileTestSecretKeys = map[string]struct{}{ + "1x0000000000000000000000000000000AA": {}, + "2x0000000000000000000000000000000AA": {}, + "3x0000000000000000000000000000000AA": {}, +} + type circuitState int const ( @@ -696,7 +711,8 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http. success = captchaResponse.Success if success && activeConfig.key == "cf-turnstile" { expectedHostname := captchaValidationHostname(req) - if captchaResponse.Hostname != expectedHostname { + skipHostnameValidation := bc.usesTurnstileTestKeys() && captchaResponse.Hostname == turnstileTestHostname + if !skipHostnameValidation && captchaResponse.Hostname != expectedHostname { bc.log.Warn("captcha hostname mismatch", "hostname", captchaResponse.Hostname, "expectedHostname", expectedHostname) success = false } else { @@ -743,6 +759,12 @@ func captchaValidationHostname(req *http.Request) string { return host } +func (bc *CaptchaProtect) usesTurnstileTestKeys() bool { + _, hasTestSiteKey := turnstileTestSiteKeys[bc.config.SiteKey] + _, hasTestSecretKey := turnstileTestSecretKeys[bc.config.SecretKey] + return hasTestSiteKey && hasTestSecretKey +} + func randomUUID() (string, error) { var b [16]byte if _, err := crand.Read(b[:]); err != nil { diff --git a/main_test.go b/main_test.go index 86b1de6..0fe60b7 100644 --- a/main_test.go +++ b/main_test.go @@ -1161,6 +1161,74 @@ func TestVerifyChallengePageRejectsInvalidSiteverifyMetadata(t *testing.T) { } } +func TestVerifyChallengePageAllowsTurnstileTestKeysExampleHostname(t *testing.T) { + validChallengeTS := time.Now().Format(time.RFC3339Nano) + mockResponse := fmt.Sprintf(`{"success":true,"hostname":"example.com","challenge_ts":%q}`, validChallengeTS) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(mockResponse)) + })) + defer mockServer.Close() + + config := CreateConfig() + config.SiteKey = "1x00000000000000000000AA" + config.SecretKey = "1x0000000000000000000000000000000AA" + config.ProtectRoutes = []string{"/"} + config.CaptchaProvider = "turnstile" + + bc, _ := NewCaptchaProtect(context.Background(), nil, config, "test") + bc.captchaConfig.validate = mockServer.URL + + req := httptest.NewRequest(http.MethodPost, "http://localhost/challenge", nil) + req.Form = make(map[string][]string) + req.Form.Set("cf-turnstile-response", "valid-token") + + rr := httptest.NewRecorder() + status := bc.verifyChallengePage(rr, req, "1.2.3.4") + + if status != http.StatusFound { + t.Fatalf("expected status %d, got %d", http.StatusFound, status) + } + if _, found := bc.verifiedCache.Get("1.2.3.4"); !found { + t.Fatal("expected turnstile test key response to set verified cache") + } +} + +func TestVerifyChallengePageRejectsExampleHostnameWithoutTurnstileTestKeys(t *testing.T) { + validChallengeTS := time.Now().Format(time.RFC3339Nano) + mockResponse := fmt.Sprintf(`{"success":true,"hostname":"example.com","challenge_ts":%q}`, validChallengeTS) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(mockResponse)) + })) + defer mockServer.Close() + + config := CreateConfig() + config.SiteKey = "test" + config.SecretKey = "test" + config.ProtectRoutes = []string{"/"} + config.CaptchaProvider = "turnstile" + + bc, _ := NewCaptchaProtect(context.Background(), nil, config, "test") + bc.captchaConfig.validate = mockServer.URL + + req := httptest.NewRequest(http.MethodPost, "http://localhost/challenge", nil) + req.Form = make(map[string][]string) + req.Form.Set("cf-turnstile-response", "valid-token") + + rr := httptest.NewRecorder() + status := bc.verifyChallengePage(rr, req, "1.2.3.4") + + if status != http.StatusForbidden { + t.Fatalf("expected status %d, got %d", http.StatusForbidden, status) + } + if _, found := bc.verifiedCache.Get("1.2.3.4"); found { + t.Fatal("did not expect non-test key response to set verified cache") + } +} + func TestVerifyChallengePageAllowsNonTurnstileWithoutMetadata(t *testing.T) { mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")