diff --git a/deliberi/config/config.go b/deliberi/config/config.go index 6f3f66a49..63e8601f1 100644 --- a/deliberi/config/config.go +++ b/deliberi/config/config.go @@ -30,6 +30,8 @@ type Config struct { KvApiToken string `env:"DELIBERI_KV_API_TOKEN"` KvNamespaceID string `env:"DELIBERI_KV_NAMESPACE_ID"` + TurnstileSecretKey string `env:"DELIBERI_TURNSTILE_SECRET_KEY"` + Dev bool `env:"DELIBERI_DEV, default=false"` } diff --git a/deliberi/xrpc/signup.go b/deliberi/xrpc/signup.go index b4277ecb6..9a9cebb3b 100644 --- a/deliberi/xrpc/signup.go +++ b/deliberi/xrpc/signup.go @@ -24,7 +24,6 @@ var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) var errEmailAlreadyRegistered = errors.New("email already registered") -// signupEmailCooldown caps scripted email spam while turnstile verification is unwired. const signupEmailCooldown = time.Minute func withinSignupCooldown(lastSent string) bool { @@ -35,6 +34,74 @@ func withinSignupCooldown(lastSent string) bool { return time.Since(t) < signupEmailCooldown } +var ( + errCaptchaRejected = errors.New("captcha verification failed") + errCaptchaNotConfigured = errors.New("turnstile secret key not configured") +) + +// settable so tests can stub siteverify and shorten the timeout +var ( + turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + turnstileClient = &http.Client{Timeout: 10 * time.Second} +) + +type turnstileResponse struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes,omitempty"` + ChallengeTs string `json:"challenge_ts,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +func (x *Xrpc) verifyTurnstile(token string, r *http.Request) error { + if token == "" { + return errCaptchaRejected + } + if x.Config.TurnstileSecretKey == "" { + return errCaptchaNotConfigured + } + + data := url.Values{} + data.Set("secret", x.Config.TurnstileSecretKey) + data.Set("response", token) + + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { + data.Set("remoteip", remoteIP) + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { + data.Set("remoteip", strings.TrimSpace(ips[0])) + } + } else { + data.Set("remoteip", r.RemoteAddr) + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, turnstileVerifyURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to build turnstile request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := turnstileClient.Do(req) + if err != nil { + return fmt.Errorf("failed to verify turnstile token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("turnstile siteverify status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var ts turnstileResponse + if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil { + return fmt.Errorf("failed to decode turnstile response: %w", err) + } + if !ts.Success { + x.Logger.Warn("turnstile rejected token", "error_codes", ts.ErrorCodes) + return errCaptchaRejected + } + return nil +} + func isValidSubdomain(name string) bool { return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) } @@ -52,6 +119,22 @@ func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { writeError(w, errBadRequestBody, http.StatusBadRequest) return } + + if err := x.verifyTurnstile(input.TurnstileToken, r); err != nil { + switch { + case errors.Is(err, errCaptchaRejected): + l.Warn("captcha verification failed", "err", err) + writeError(w, xrpcErrorTag("CaptchaFailed", "captcha verification failed, please try again"), http.StatusBadRequest) + case errors.Is(err, errCaptchaNotConfigured): + l.Error("turnstile secret key not configured; refusing signups") + writeError(w, errInternal, http.StatusInternalServerError) + default: + l.Error("turnstile verification upstream failed", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + } + return + } + email := canonicalEmail(input.Email) if !appviewemail.IsValidEmail(email) { diff --git a/deliberi/xrpc/signup_test.go b/deliberi/xrpc/signup_test.go index b190e4b8c..407633acf 100644 --- a/deliberi/xrpc/signup_test.go +++ b/deliberi/xrpc/signup_test.go @@ -95,6 +95,52 @@ func newPdsStub(t *testing.T, rec *pdsRecorder) *httptest.Server { return srv } +func newTurnstileStub(t *testing.T, success bool) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if success { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true}`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":false,"error-codes":["invalid-input-response"]}`) + })) + t.Cleanup(srv.Close) + return srv +} + +func newTurnstileStatusStub(t *testing.T, status int) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + fmt.Fprint(w, `{"success":false,"error-codes":["internal-error"]}`) + })) + t.Cleanup(srv.Close) + return srv +} + +func newTurnstileSlowStub(t *testing.T) (*httptest.Server, func()) { + t.Helper() + release := make(chan struct{}) + var once sync.Once + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-time.After(2 * time.Second): + } + })) + t.Cleanup(srv.Close) + return srv, func() { once.Do(func() { close(release) }) } +} + +func pointTurnstileAt(t *testing.T, srv *httptest.Server) { + t.Helper() + old := turnstileVerifyURL + turnstileVerifyURL = srv.URL + t.Cleanup(func() { turnstileVerifyURL = old }) +} + func newSignupXrpc(t *testing.T, baseURL ...string) (http.Handler, *db.DB, *kvRecorder, *pdsRecorder) { t.Helper() rec := &kvRecorder{} @@ -104,10 +150,12 @@ func newSignupXrpc(t *testing.T, baseURL ...string) (http.Handler, *db.DB, *kvRe if len(baseURL) > 0 && baseURL[0] != "" { base = baseURL[0] } + pointTurnstileAt(t, newTurnstileStub(t, true)) x, router, d, _ := newTestXrpcFull(t, func(x *Xrpc) { x.Config.Pds.AdminSecret = testAdminSecret x.Config.Pds.Host = srv.URL x.Config.BaseURL = base + x.Config.TurnstileSecretKey = "test-turnstile-secret" x.Sender = mailer.New(config.ResendConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) x.KV = rec }) @@ -174,6 +222,101 @@ func TestAccountBeginSignupRejectsRegisteredEmail(t *testing.T) { kvRec.assertIdle(t) } +func TestAccountBeginSignupRejectsMissingTurnstile(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"kate@example.com"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "CaptchaFailed") { + t.Fatalf("error body = %s, want CaptchaFailed", rec.Body.String()) + } + if mints := pds.InviteMints(); mints != 0 { + t.Fatalf("invite minted without a captcha (mints=%d)", mints) + } + if n := inflightCount(t, d, "kate@example.com"); n != 0 { + t.Fatalf("inflight row created without a captcha (count=%d)", n) + } + kvRec.assertIdle(t) +} + +func TestAccountBeginSignupRejectsInvalidTurnstile(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + pointTurnstileAt(t, newTurnstileStub(t, false)) + + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", + `{"email":"kate@example.com","turnstileToken":"bogus"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "CaptchaFailed") { + t.Fatalf("error body = %s, want CaptchaFailed", rec.Body.String()) + } + if mints := pds.InviteMints(); mints != 0 { + t.Fatalf("invite minted for a rejected captcha (mints=%d)", mints) + } + if n := inflightCount(t, d, "kate@example.com"); n != 0 { + t.Fatalf("inflight row created for a rejected captcha (count=%d)", n) + } + kvRec.assertIdle(t) +} + +func TestAccountBeginSignupFailsClosedWithoutSecret(t *testing.T) { + pointTurnstileAt(t, newTurnstileStub(t, true)) + _, router, _, _ := newTestXrpcFull(t, func(x *Xrpc) { + x.Config.Pds.AdminSecret = testAdminSecret + x.Config.BaseURL = "https://tangled.example" + }) + + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", + `{"email":"kate@example.com","turnstileToken":"test"}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAccountBeginSignupUpstreamErrorIsInternal(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + pointTurnstileAt(t, newTurnstileStatusStub(t, http.StatusInternalServerError)) + + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", + `{"email":"kate@example.com","turnstileToken":"test"}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } + if mints := pds.InviteMints(); mints != 0 { + t.Fatalf("invite minted despite upstream failure (mints=%d)", mints) + } + if n := inflightCount(t, d, "kate@example.com"); n != 0 { + t.Fatalf("inflight row created despite upstream failure (count=%d)", n) + } + kvRec.assertIdle(t) +} + +func TestAccountBeginSignupStalledSiteverifyDoesNotHang(t *testing.T) { + router, _, kvRec, _ := newSignupXrpc(t) + slow, release := newTurnstileSlowStub(t) + pointTurnstileAt(t, slow) + defer release() + + old := turnstileClient + turnstileClient = &http.Client{Timeout: 50 * time.Millisecond} + t.Cleanup(func() { turnstileClient = old }) + + start := time.Now() + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", + `{"email":"kate@example.com","turnstileToken":"test"}`) + elapsed := time.Since(start) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } + if elapsed > 2*time.Second { + t.Fatalf("signup hung on a stalled siteverify: took %s", elapsed) + } + kvRec.assertIdle(t) +} + func TestAccountCompleteSignupWithToken(t *testing.T) { router, d, kvRec, pds := newSignupXrpc(t)