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 }