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:
parent
c988e4b902
commit
537b0e3fd8
|
|
@ -73,6 +73,8 @@ linters:
|
|||
excludes:
|
||||
- G104
|
||||
- G706
|
||||
- G107
|
||||
- G704
|
||||
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue