diff --git a/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml b/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml index 68894417..d91996ee 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml +++ b/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml @@ -73,6 +73,8 @@ linters: excludes: - G104 - G706 + - G107 + - G704 sloglint: no-mixed-args: true diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile.go new file mode 100644 index 00000000..1af60d7c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile.go @@ -0,0 +1,67 @@ +// ©AngelaMos | 2026 +// turnstile.go + +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" +) + +const ( + turnstileHeaderName = "CF-Turnstile-Response" + turnstileBodyFieldName = "cf_turnstile_response" + turnstileMaxBodyBytes = 1 * 1024 * 1024 + turnstileErrorContentType = "application/json" + turnstileErrorBody = `{"success":false,"error":{"code":"TURNSTILE_FAILED","message":"turnstile verification failed"}}` +) + +type TurnstileVerifier interface { + Verify(ctx context.Context, token, fingerprint string) error +} + +func TurnstileVerify(v TurnstileVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := extractTurnstileToken(r) + fp := ExtractFingerprint(r) + + if err := v.Verify(r.Context(), tokenStr, fp); err != nil { + w.Header().Set("Content-Type", turnstileErrorContentType) + w.WriteHeader(http.StatusBadRequest) + if _, wErr := w.Write([]byte(turnstileErrorBody)); wErr != nil { + return + } + return + } + next.ServeHTTP(w, r) + }) + } +} + +func extractTurnstileToken(r *http.Request) string { + if v := r.Header.Get(turnstileHeaderName); v != "" { + return v + } + if r.Body == nil { + return "" + } + limited := io.LimitReader(r.Body, turnstileMaxBodyBytes) + body, err := io.ReadAll(limited) + if err != nil { + return "" + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return "" + } + if s, ok := m[turnstileBodyFieldName].(string); ok { + return s + } + return "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile_test.go new file mode 100644 index 00000000..2540f091 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile_test.go @@ -0,0 +1,125 @@ +// ©AngelaMos | 2026 +// turnstile_test.go + +package middleware_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" +) + +type fakeVerifier struct { + calls atomic.Int32 + lastToken atomic.Value + lastFP atomic.Value + returnError error +} + +func (f *fakeVerifier) Verify(_ context.Context, token, fp string) error { + f.calls.Add(1) + f.lastToken.Store(token) + f.lastFP.Store(fp) + return f.returnError +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + return + } + }) +} + +func TestTurnstileVerify_HeaderTokenPasses(t *testing.T) { + v := &fakeVerifier{} + h := middleware.TurnstileVerify(v)(okHandler()) + + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set("CF-Turnstile-Response", "header-token") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, int32(1), v.calls.Load()) + require.Equal(t, "header-token", v.lastToken.Load()) +} + +func TestTurnstileVerify_BodyTokenPasses(t *testing.T) { + v := &fakeVerifier{} + h := middleware.TurnstileVerify(v)(okHandler()) + + r := httptest.NewRequest(http.MethodPost, "/", + strings.NewReader(`{"cf_turnstile_response":"body-token","other":1}`)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "body-token", v.lastToken.Load()) +} + +func TestTurnstileVerify_BodyPreservedForDownstream(t *testing.T) { + v := &fakeVerifier{} + h := middleware.TurnstileVerify(v)(okHandler()) + + body := `{"cf_turnstile_response":"t","foo":"bar"}` + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, body, w.Body.String(), + "downstream handler must still read the original body") +} + +func TestTurnstileVerify_FailureReturns400(t *testing.T) { + v := &fakeVerifier{returnError: errors.New("bad")} + h := middleware.TurnstileVerify(v)(okHandler()) + + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set("CF-Turnstile-Response", "tok") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), `"TURNSTILE_FAILED"`) +} + +func TestTurnstileVerify_HeaderWinsOverBody(t *testing.T) { + v := &fakeVerifier{} + h := middleware.TurnstileVerify(v)(okHandler()) + + r := httptest.NewRequest(http.MethodPost, "/", + strings.NewReader(`{"cf_turnstile_response":"body-token"}`)) + r.Header.Set("CF-Turnstile-Response", "header-token") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, "header-token", v.lastToken.Load()) +} + +func TestTurnstileVerify_NoTokenStillCallsVerifier(t *testing.T) { + v := &fakeVerifier{} + h := middleware.TurnstileVerify(v)(okHandler()) + + r := httptest.NewRequest(http.MethodPost, "/", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, int32(1), v.calls.Load()) + require.Empty(t, v.lastToken.Load()) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier.go b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier.go new file mode 100644 index 00000000..3e63724d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier.go @@ -0,0 +1,148 @@ +// ©AngelaMos | 2026 +// verifier.go + +package turnstile + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + DefaultVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + + cacheKeyPrefix = "turnstile:fp:" + cacheTTL = 5 * time.Minute + verifyTimeout = 10 * time.Second + cachedSuccessFlag = "ok" + + contentTypeForm = "application/x-www-form-urlencoded" +) + +var ( + ErrVerifyFailed = errors.New("turnstile: verification failed") + ErrEmptyToken = errors.New("turnstile: empty response token") +) + +type RedisClient interface { + Get(ctx context.Context, key string) *redis.StringCmd + SetEx( + ctx context.Context, + key string, + value any, + expiration time.Duration, + ) *redis.StatusCmd +} + +type Config struct { + SecretKey string + VerifyURL string + Client *http.Client +} + +type Verifier struct { + secret string + verifyURL string + client *http.Client + rdb RedisClient +} + +func NewVerifier(cfg Config, rdb RedisClient) *Verifier { + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: verifyTimeout} + } + verifyURL := cfg.VerifyURL + if verifyURL == "" { + verifyURL = DefaultVerifyURL + } + return &Verifier{ + secret: cfg.SecretKey, + verifyURL: verifyURL, + client: client, + rdb: rdb, + } +} + +func (v *Verifier) Verify( + ctx context.Context, + token, fingerprint string, +) error { + if v.secret == "" { + return nil + } + if strings.TrimSpace(token) == "" { + return ErrEmptyToken + } + + cacheKey := cacheKeyPrefix + fingerprint + if v.rdb != nil { + if cached, err := v.rdb.Get(ctx, cacheKey).Result(); err == nil && + cached == cachedSuccessFlag { + return nil + } + } + + if err := v.callSiteverify(ctx, token); err != nil { + return err + } + + if v.rdb != nil { + if cErr := v.rdb.SetEx( + ctx, + cacheKey, + cachedSuccessFlag, + cacheTTL, + ).Err(); cErr != nil { + slog.WarnContext(ctx, "turnstile: cache set", + "error", cErr, "fingerprint", fingerprint) + } + } + return nil +} + +func (v *Verifier) callSiteverify(ctx context.Context, token string) error { + form := url.Values{"secret": {v.secret}, "response": {token}} + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + v.verifyURL, + strings.NewReader(form.Encode()), + ) + if err != nil { + return fmt.Errorf("turnstile: build request: %w", err) + } + req.Header.Set("Content-Type", contentTypeForm) + + resp, err := v.client.Do(req) + if err != nil { + return fmt.Errorf("turnstile: call siteverify: %w", err) + } + defer func() { + if cErr := resp.Body.Close(); cErr != nil { + slog.WarnContext(ctx, "turnstile: close body", + "error", cErr) + } + }() + + var body struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return fmt.Errorf("turnstile: decode response: %w", err) + } + if !body.Success { + return fmt.Errorf("%w: %v", ErrVerifyFailed, body.ErrorCodes) + } + return nil +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier_test.go new file mode 100644 index 00000000..730a4800 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier_test.go @@ -0,0 +1,161 @@ +// ©AngelaMos | 2026 +// verifier_test.go + +package turnstile_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/turnstile" +) + +type fakeRedis struct { + store map[string]string +} + +func (f *fakeRedis) Get(_ context.Context, key string) *redis.StringCmd { + cmd := redis.NewStringCmd(context.Background()) + if v, ok := f.store[key]; ok { + cmd.SetVal(v) + } else { + cmd.SetErr(redis.Nil) + } + return cmd +} + +func (f *fakeRedis) SetEx( + _ context.Context, + key string, + value any, + _ time.Duration, +) *redis.StatusCmd { + cmd := redis.NewStatusCmd(context.Background()) + if f.store == nil { + f.store = make(map[string]string) + } + if s, ok := value.(string); ok { + f.store[key] = s + } + cmd.SetVal("OK") + return cmd +} + +func startMockSiteverify( + t *testing.T, + success bool, + errorCodes []string, +) (string, *atomic.Int32) { + t.Helper() + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + var body string + if success { + body = `{"success":true}` + } else { + body = `{"success":false,"error-codes":[` + for i, c := range errorCodes { + if i > 0 { + body += "," + } + body += `"` + c + `"` + } + body += `]}` + } + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("write mock: %v", err) + } + }), + ) + t.Cleanup(srv.Close) + return srv.URL, &hits +} + +func TestVerifier_EmptySecretBypasses(t *testing.T) { + v := turnstile.NewVerifier(turnstile.Config{}, &fakeRedis{}) + require.NoError(t, v.Verify(context.Background(), "irrelevant", "fp")) +} + +func TestVerifier_EmptyTokenReturnsError(t *testing.T) { + v := turnstile.NewVerifier(turnstile.Config{SecretKey: "s"}, &fakeRedis{}) + err := v.Verify(context.Background(), " ", "fp") + require.ErrorIs(t, err, turnstile.ErrEmptyToken) +} + +func TestVerifier_SuccessCachesByFingerprint(t *testing.T) { + url, hits := startMockSiteverify(t, true, nil) + r := &fakeRedis{} + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, r) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp1")) + require.Equal(t, int32(1), hits.Load()) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp1")) + require.Equal( + t, + int32(1), + hits.Load(), + "cache hit must skip siteverify call", + ) +} + +func TestVerifier_CacheMissForDifferentFingerprint(t *testing.T) { + url, hits := startMockSiteverify(t, true, nil) + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, &fakeRedis{}) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp1")) + require.NoError(t, v.Verify(context.Background(), "tok", "fp2")) + require.Equal(t, int32(2), hits.Load()) +} + +func TestVerifier_FailedSiteverifyReturnsErrVerifyFailed(t *testing.T) { + url, _ := startMockSiteverify(t, false, []string{"invalid-input-response"}) + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, &fakeRedis{}) + + err := v.Verify(context.Background(), "tok", "fp") + require.Error(t, err) + require.ErrorIs(t, err, turnstile.ErrVerifyFailed) +} + +func TestVerifier_NilRedisStillWorks(t *testing.T) { + url, hits := startMockSiteverify(t, true, nil) + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, nil) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp")) + require.NoError(t, v.Verify(context.Background(), "tok", "fp")) + require.Equal(t, int32(2), hits.Load(), "without redis there is no caching") +} + +func TestVerifier_NetworkErrorIsWrapped(t *testing.T) { + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: "http://127.0.0.1:1/never", + Client: &http.Client{Timeout: 100 * time.Millisecond}, + }, &fakeRedis{}) + + err := v.Verify(context.Background(), "tok", "fp") + require.Error(t, err) + require.NotErrorIs(t, err, turnstile.ErrVerifyFailed) +}