- 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
305 lines
9.8 KiB
Go
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
|
|
}
|