Files
summercms/modules/bouncer/turnstile.go
Jakub Zych 597c79bae7 feat(19.3-02): Cloudflare Turnstile verification as turnstile:open|closed middleware
- bouncer: Turnstile.Verify posts secret/response/remoteip to siteverify with
  a 5 s bound and PHP's open and closed modes (503 unconfigured in production,
  422 on a missing, rejected or overlong token, an error or a timeout); errors
  never carry the secret or the token
- bouncer: Turnstile.Middleware reads the token from the query, a JSON body or
  a form body, restores the body and writes refusals through a published
  TurnstileWriter or neutral JSON
- surf: factory turnstile:open|closed from turnstile.secret/turnstile.field
  (a published *bouncer.Turnstile wins), client IP with trusted proxies; any
  other param fails boot
- docs: Turnstile section, middleware table, README rows
2026-10-15 17:54:58 +02:00

305 lines
9.8 KiB
Go

package bouncer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const (
// TurnstileSiteverifyURL is Cloudflare's server-side validation endpoint.
TurnstileSiteverifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
// TurnstileTimeout bounds one siteverify call.
TurnstileTimeout = 5 * time.Second
// TurnstileMaxTokenLength is Cloudflare's documented token limit; a
// longer token fails without a call.
TurnstileMaxTokenLength = 2048
// DefaultTurnstileField is the request field carrying the token.
DefaultTurnstileField = "turnstile_token"
)
// turnstileAnswerLimit caps how much of the siteverify answer is read.
const turnstileAnswerLimit = 4 << 10
// errTurnstileRefused marks a siteverify answer with success false: a normal
// rejection, logged below warn level.
var errTurnstileRefused = errors.New("bouncer: turnstile siteverify refused the token")
// TurnstileVerdict is the outcome of a Turnstile check.
type TurnstileVerdict int
const (
// TurnstilePassed lets the request through.
TurnstilePassed TurnstileVerdict = iota
// TurnstileFailed is a missing or rejected token, or a siteverify call
// that failed or timed out (422).
TurnstileFailed
// TurnstileUnavailable is fail-closed mode without a secret in the
// production environment (503).
TurnstileUnavailable
)
// String names the verdict for logs.
func (v TurnstileVerdict) String() string {
switch v {
case TurnstilePassed:
return "passed"
case TurnstileFailed:
return "failed"
case TurnstileUnavailable:
return "unavailable"
default:
return fmt.Sprintf("TurnstileVerdict(%d)", int(v))
}
}
// TurnstileWriter writes a Turnstile refusal in the application's response
// envelope: status is 503 for TurnstileUnavailable and 422 for
// TurnstileFailed. An application publishes one on the backpack app to keep
// its own error shape.
type TurnstileWriter func(w http.ResponseWriter, r *http.Request, status int, verdict TurnstileVerdict)
// Turnstile verifies Cloudflare Turnstile tokens against siteverify.
type Turnstile struct {
// Secret is the site's secret key; empty means unconfigured.
Secret string
// Production makes an unconfigured fail-closed check unavailable
// instead of passing.
Production bool
// Endpoint defaults to TurnstileSiteverifyURL; tests point it at a fake.
Endpoint string
// Client defaults to an *http.Client with Timeout TurnstileTimeout.
Client *http.Client
}
// NewTurnstile returns a verifier for secret with the default endpoint and
// client.
func NewTurnstile(secret string, production bool) *Turnstile {
return &Turnstile{
Secret: secret,
Production: production,
Endpoint: TurnstileSiteverifyURL,
Client: &http.Client{Timeout: TurnstileTimeout},
}
}
// Verify checks token for the client at ip. With failClosed false (open
// mode, for registration) an empty secret passes; with failClosed true
// (closed mode, for guest entry) an empty secret is TurnstileUnavailable in
// production and passes elsewhere. With a secret set both modes require a
// siteverify answer with success true: an empty token, a token longer than
// TurnstileMaxTokenLength, success false, a transport error, a timeout, a
// non-2xx status or an undecodable answer is TurnstileFailed. The returned
// error explains a failure for logging; it never contains the secret or the
// token.
func (t *Turnstile) Verify(ctx context.Context, token, ip string, failClosed bool) (TurnstileVerdict, error) {
if t == nil || strings.TrimSpace(t.Secret) == "" {
if failClosed && (t == nil || t.Production) {
return TurnstileUnavailable, nil
}
return TurnstilePassed, nil
}
if token == "" || len(token) > TurnstileMaxTokenLength {
return TurnstileFailed, nil
}
form := url.Values{"secret": {t.Secret}, "response": {token}}
if ip != "" {
form.Set("remoteip", ip)
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, TurnstileTimeout)
defer cancel()
endpoint := t.Endpoint
if endpoint == "" {
endpoint = TurnstileSiteverifyURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return TurnstileFailed, errors.New("bouncer: turnstile siteverify request could not be built")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := t.Client
if client == nil {
client = &http.Client{Timeout: TurnstileTimeout}
}
res, err := client.Do(req)
if err != nil {
return TurnstileFailed, fmt.Errorf("bouncer: turnstile siteverify failed: %s", turnstileTransportError(err))
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, turnstileAnswerLimit))
if err != nil {
return TurnstileFailed, fmt.Errorf("bouncer: turnstile siteverify answer could not be read: %s", turnstileTransportError(err))
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return TurnstileFailed, fmt.Errorf("bouncer: turnstile siteverify answered %d", res.StatusCode)
}
var answer struct {
Success *bool `json:"success"`
ErrorCodes []string `json:"error-codes"`
}
if err := json.Unmarshal(body, &answer); err != nil || answer.Success == nil {
return TurnstileFailed, errors.New("bouncer: turnstile siteverify answer is not a siteverify JSON object")
}
if !*answer.Success {
return TurnstileFailed, fmt.Errorf("%w (error-codes: %s)", errTurnstileRefused, turnstileCodes(answer.ErrorCodes))
}
return TurnstilePassed, nil
}
// turnstileTransportError describes a failed call without the URL.
func turnstileTransportError(err error) string {
var uerr *url.Error
if errors.As(err, &uerr) {
err = uerr.Err
}
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return err.Error()
}
// turnstileCodes joins Cloudflare's error codes for a log line, keeping only
// the characters the documented codes use.
func turnstileCodes(codes []string) string {
out := make([]string, 0, len(codes))
for _, c := range codes {
c = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return -1
}, c)
if c != "" {
out = append(out, c)
}
if len(out) == 8 {
break
}
}
if len(out) == 0 {
return "none"
}
return strings.Join(out, ",")
}
// Middleware returns middleware that verifies the token in the request field
// named field (DefaultTurnstileField when empty): the query parameter first,
// else the body, a JSON object's top-level string field or an
// application/x-www-form-urlencoded field. The body is read fully (bound it
// with the router's body limit) and restored with the same bytes before the
// handler runs. TurnstileUnavailable writes 503 and TurnstileFailed 422
// through write, or through neutral JSON bodies ({"error":"turnstile_unavailable"},
// {"error":"turnstile_failed"}) when write is nil; TurnstilePassed calls the
// handler. clientIP supplies the remoteip (nil uses the connection address).
// A failed siteverify call is logged once at warn with the error text only.
func (t *Turnstile) Middleware(failClosed bool, field string, clientIP func(*http.Request) string, write TurnstileWriter) func(http.Handler) http.Handler {
if field == "" {
field = DefaultTurnstileField
}
if write == nil {
write = defaultTurnstileWriter
}
if clientIP == nil {
clientIP = remoteAddrIP
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := turnstileToken(r, field)
if err != nil {
status := http.StatusBadRequest
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
status = http.StatusRequestEntityTooLarge
}
write(w, r, status, TurnstileFailed)
return
}
verdict, verr := t.Verify(r.Context(), token, clientIP(r), failClosed)
if verr != nil {
if errors.Is(verr, errTurnstileRefused) {
slog.DebugContext(r.Context(), "bouncer: turnstile token refused", "error", verr.Error())
} else {
slog.WarnContext(r.Context(), "bouncer: turnstile verification failed", "error", verr.Error())
}
}
switch verdict {
case TurnstilePassed:
next.ServeHTTP(w, r)
case TurnstileUnavailable:
write(w, r, http.StatusServiceUnavailable, verdict)
default:
write(w, r, http.StatusUnprocessableEntity, TurnstileFailed)
}
})
}
}
// turnstileToken reads field from the query, else from the body, and
// restores r.Body.
func turnstileToken(r *http.Request, field string) (string, error) {
if v := r.URL.Query().Get(field); v != "" {
return v, nil
}
if r.Body == nil || r.Body == http.NoBody {
return "", nil
}
body, err := io.ReadAll(r.Body)
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
if err != nil {
return "", err
}
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
switch {
case mediaType == "application/x-www-form-urlencoded":
values, err := url.ParseQuery(string(body))
if err != nil {
return "", nil
}
return values.Get(field), nil
case mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") ||
(mediaType == "" && bytes.HasPrefix(bytes.TrimSpace(body), []byte("{"))):
var obj map[string]json.RawMessage
if json.Unmarshal(body, &obj) != nil {
return "", nil
}
var v string
if raw, ok := obj[field]; !ok || json.Unmarshal(raw, &v) != nil {
return "", nil
}
return v, nil
}
return "", nil
}
func defaultTurnstileWriter(w http.ResponseWriter, _ *http.Request, status int, verdict TurnstileVerdict) {
code := "turnstile_failed"
if verdict == TurnstileUnavailable {
code = "turnstile_unavailable"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, `{"error":"`+code+`"}`)
}
func remoteAddrIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}