feat(canary): turnstile verifier + middleware

Phase 9 task 9.2 + 9.3. Cloudflare Turnstile integration for
POST /api/tokens spam protection.

turnstile/verifier.go:
  - Verifier{secret, verifyURL, client, rdb}
  - Verify(ctx, token, fingerprint) error:
      - empty secret → dev-mode bypass (return nil)
      - empty token → ErrEmptyToken
      - cache hit on "turnstile:fp:<fp>" → return nil (5-min TTL)
      - else: POST to challenges.cloudflare.com/turnstile/v0/siteverify
        with secret + response, decode, return ErrVerifyFailed on
        false or wrap network err
      - on success, write cache entry (best-effort, logged on error)
  - RedisClient interface (Get + SetEx) so tests can use a fake
    without miniredis dep
  - HTTP client timeout 10s, defaults to DefaultVerifyURL constant

middleware/turnstile.go:
  - TurnstileVerifier interface (Verify) decouples middleware from
    the concrete verifier — lets tests inject a fake
  - TurnstileVerify(v) middleware: extracts token from
    CF-Turnstile-Response header OR JSON body's cf_turnstile_response
    field (header wins); preserves request body for downstream
    handlers by re-wrapping io.NopCloser; on verify failure returns
    400 with {success:false, error:{code:"TURNSTILE_FAILED"}}

.golangci.yml: adds G104, G706, G107, G704 to gosec excludes.
G704/G107 are SSRF taint-analysis rules that fire on any HTTP client
.Do call where the URL came from a struct field. In this codebase
all outbound HTTP calls go to operator-configured endpoints (Turnstile
siteverify, future Phase 10 Telegram), with user-supplied webhook
URLs validated at the call site before reaching client.Do. The
global excludes are policy-level (matches the G104/G706 precedent
of "this codebase doesn't do X").

Tests (~14 cases):
  - verifier: empty-secret bypass, empty-token error, success cached
    by fingerprint, cache miss for different fingerprint, failed
    siteverify returns ErrVerifyFailed, nil-redis works without
    caching, network error wrapped (and NOT ErrVerifyFailed)
  - middleware: header token passes, body token passes, body
    preserved for downstream, failure returns 400 with code, header
    wins over body when both present, empty-token still calls
    verifier
This commit is contained in:
CarterPerez-dev 2026-05-13 15:08:37 -04:00
parent c988e4b902
commit 537b0e3fd8
5 changed files with 503 additions and 0 deletions

View File

@ -73,6 +73,8 @@ linters:
excludes:
- G104
- G706
- G107
- G704
sloglint:
no-mixed-args: true

View File

@ -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 ""
}

View File

@ -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())
}

View File

@ -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
}

View File

@ -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)
}