package main import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync/atomic" "testing" "github.com/google/uuid" "git.dev.alexdunmow.com/block/core/captcha" ) // fakeBlockResolver is an in-memory blockContentResolver so the booking handler // can resolve the server-authoritative "does this username+eventType require a // captcha?" answer without a live database. It mirrors the DB query // (CalcomBookingRequiresCaptcha), which returns true iff a published // calcom:booking block for that username+eventTypeSlug has captchaEnabled=true. // The key is username+eventTypeSlug — deliberately NOT the blockId — so tests // prove the requirement no longer depends on the client-supplied id. type fakeBlockResolver struct { // required maps "username\x00eventTypeSlug" → whether a published block // requires captcha for that pair. required map[string]bool // err, when set, is returned to exercise the resolver-error path. err error } func (f fakeBlockResolver) CalcomBookingRequiresCaptcha(_ context.Context, username, eventTypeSlug string) (bool, error) { if f.err != nil { return false, f.err } return f.required[username+"\x00"+eventTypeSlug], nil } // newCalcomCaptchaHandler builds a CalcomHandler whose published calcom:booking // block for username "alice" / eventType "30min" carries the given // captchaEnabled flag, plus a persisted API key so the booking flow reaches the // Cal.com call. The requirement is keyed on username+eventType, NOT a blockId — // so the booking form may post any blockId (or none) without affecting it. func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler { t.Helper() h, _ := newTestHandler() if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil { t.Fatalf("SetAPIKey: %v", err) } h.blockConfig = fakeBlockResolver{required: map[string]bool{ "alice\x0030min": captchaEnabled, }} return h } // newCalcomTestCaptchaServer builds a captcha.Server with a low PoW difficulty // so the challenge solves quickly in tests. func newCalcomTestCaptchaServer() *captcha.Server { return captcha.New([]byte("calcom-captcha-test-secret"), captcha.WithChallenge(4, 8, 2)) } // captchaBookingForm builds a booking POST body that passes every cheap // validation (name+email present, valid email) so the only thing standing // between the request and the Cal.com call is the captcha check. blockID is // posted only as a render target (as a real widget would); when empty it is // omitted entirely, proving the captcha requirement does not depend on it. func captchaBookingForm(blockID string) url.Values { v := url.Values{ "username": {"alice"}, "eventType": {"30min"}, "start": {"2026-05-27T10:00:00Z"}, "name": {"Bob"}, "email": {"bob@example.com"}, } if blockID != "" { v.Set("blockId", blockID) } return v } func postCaptchaBooking(h *CalcomHandler, form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Forwarded-For", "198.51.100.7") // TEST-NET-2, isolated bucket rec := httptest.NewRecorder() h.HandleCreateBooking(rec, req) return rec } // fakeCalcomServer stands in for Cal.com and counts booking creations, so tests // can assert whether a submission got past the captcha gate to actually book. func fakeCalcomServer(t *testing.T) (*httptest.Server, *atomic.Int32) { t.Helper() var bookings atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/bookings") { bookings.Add(1) } _, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u"}}`) })) t.Cleanup(srv.Close) return srv, &bookings } func TestCalcomBooking_CaptchaEnabled_RejectsWithoutToken(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) h := newCalcomCaptchaHandler(t, true) SetCaptchaServer(newCalcomTestCaptchaServer()) t.Cleanup(func() { SetCaptchaServer(nil) }) rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString())) // no cap-token if got := bookings.Load(); got != 0 { t.Fatalf("Cal.com booking created %d times; want 0 (missing captcha token must be rejected)", got) } if !strings.Contains(rec.Body.String(), "aptcha") { t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String()) } } func TestCalcomBooking_CaptchaEnabled_RejectsInvalidToken(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) h := newCalcomCaptchaHandler(t, true) SetCaptchaServer(newCalcomTestCaptchaServer()) t.Cleanup(func() { SetCaptchaServer(nil) }) form := captchaBookingForm(uuid.NewString()) form.Set("cap-token", "not-a-real-token") rec := postCaptchaBooking(h, form) if got := bookings.Load(); got != 0 { t.Fatalf("Cal.com booking created %d times; want 0 (invalid token rejected)", got) } if !strings.Contains(rec.Body.String(), "aptcha") { t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String()) } } func TestCalcomBooking_CaptchaEnabled_ProceedsWithValidToken(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) srv := newCalcomTestCaptchaServer() h := newCalcomCaptchaHandler(t, true) SetCaptchaServer(srv) t.Cleanup(func() { SetCaptchaServer(nil) }) token := mintCalcomCaptchaToken(t, srv) form := captchaBookingForm(uuid.NewString()) form.Set("cap-token", token) rec := postCaptchaBooking(h, form) if got := bookings.Load(); got != 1 { t.Fatalf("Cal.com booking created %d times; want 1 (valid token must pass captcha)", got) } if rec.Code != http.StatusOK { t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String()) } // The captcha token must never be forwarded to Cal.com as a booking field. if strings.Contains(rec.Body.String(), "cap-token") { t.Fatalf("cap-token leaked into rendered response: %s", rec.Body.String()) } } func TestCalcomBooking_CaptchaDisabled_TokenNotRequired(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) h := newCalcomCaptchaHandler(t, false) // A non-nil server would still reject if enforcement wrongly kicked in; nil // proves the disabled path never touches the verifier. SetCaptchaServer(nil) rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString())) // no cap-token if got := bookings.Load(); got != 1 { t.Fatalf("Cal.com booking created %d times; want 1 (captcha disabled)", got) } if rec.Code != http.StatusOK { t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "aptcha") { t.Fatalf("captcha error fragment rendered when captcha disabled: %s", rec.Body.String()) } } func TestCalcomBooking_CaptchaEnabled_FailsClosedWhenServerNil(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) h := newCalcomCaptchaHandler(t, true) // nil captcha server: the instance secret failed at startup. An enabled // block must reject rather than allow the booking through unverified. SetCaptchaServer(nil) form := captchaBookingForm(uuid.NewString()) form.Set("cap-token", "anything") rec := postCaptchaBooking(h, form) if got := bookings.Load(); got != 0 { t.Fatalf("Cal.com booking created %d times; want 0 (fail closed on nil server)", got) } if !strings.Contains(rec.Body.String(), "aptcha") { t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String()) } } // TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement is the // regression guard for the security finding (commit 37e88d3b9): the captcha // requirement derives from published content (username+eventType), NEVER the // client-supplied blockId. A bot that omits blockId OR forges a random one must // still be rejected when a published calcom:booking block for that // username+eventType has captchaEnabled=true. Before the fix, omitting blockId // caused blockCaptchaEnabled to fail open and book with no captcha. func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.T) { for _, tc := range []struct{ name, blockID string }{ {"blockId omitted", ""}, {"blockId forged (random, unresolvable)", uuid.NewString()}, } { t.Run(tc.name, func(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) h := newCalcomCaptchaHandler(t, true) // published block requires captcha SetCaptchaServer(newCalcomTestCaptchaServer()) t.Cleanup(func() { SetCaptchaServer(nil) }) // No valid cap-token; the requirement must not depend on blockId. rec := postCaptchaBooking(h, captchaBookingForm(tc.blockID)) if got := bookings.Load(); got != 0 { t.Fatalf("Cal.com booking created %d times; want 0 (captcha requirement must not depend on blockId)", got) } if !strings.Contains(rec.Body.String(), "aptcha") { t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String()) } }) } } // TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen documents the // deliberate choice for the requirement lookup: a resolver (DB) error is logged // and treated as "not required", so a transient DB blip cannot block every // booking site-wide. The honeypot + per-IP rate limit remain the baseline // protection. (This is distinct from the nil-captcha-server path, which fails // CLOSED when a block IS known to require captcha.) func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) { fake, bookings := fakeCalcomServer(t) withCalcomBaseURL(t, fake.URL) h, _ := newTestHandler() if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil { t.Fatalf("SetAPIKey: %v", err) } h.blockConfig = fakeBlockResolver{err: errors.New("db unavailable")} SetCaptchaServer(newCalcomTestCaptchaServer()) t.Cleanup(func() { SetCaptchaServer(nil) }) rec := postCaptchaBooking(h, captchaBookingForm("")) // no cap-token if got := bookings.Load(); got != 1 { t.Fatalf("Cal.com booking created %d times; want 1 (resolver error → captcha not enforced; honeypot + rate limit still apply)", got) } if rec.Code != http.StatusOK { t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String()) } } // --------------------------------------------------------------------------- // captcha PoW helpers — local copies of the (unexported) core/captcha PRNG so // the test can solve a challenge and mint a real verification token. Mirrors // the helpers in internal/handlers/captcha_challenge_test.go. // --------------------------------------------------------------------------- func mintCalcomCaptchaToken(t *testing.T, srv *captcha.Server) string { t.Helper() ch, err := srv.CreateChallenge() if err != nil { t.Fatalf("CreateChallenge: %v", err) } intSols := solveCalcomChallenge(ch.Token, ch.Challenge.C, ch.Challenge.S, ch.Challenge.D) sols := make([]string, len(intSols)) for i, n := range intSols { sols[i] = strconv.Itoa(n) } rd := srv.Redeem(ch.Token, sols) if !rd.Success || rd.Token == "" { t.Fatalf("Redeem failed: %+v", rd) } return rd.Token } func solveCalcomChallenge(token string, c, s, d int) []int { sols := make([]int, c) for i := 1; i <= c; i++ { salt := calcomPRNG(token+strconv.Itoa(i), s) target := calcomPRNG(token+strconv.Itoa(i)+"d", d) for n := 0; ; n++ { sum := sha256.Sum256([]byte(salt + strconv.Itoa(n))) h := hex.EncodeToString(sum[:]) if len(h) >= len(target) && h[:len(target)] == target { sols[i-1] = n break } } } return sols } func calcomPRNG(seed string, length int) string { const mask = 0xFFFFFFFF state := calcomFNV1a(seed) var b strings.Builder for b.Len() < length { state ^= (state << 13) & mask state ^= state >> 17 state ^= (state << 5) & mask state &= mask fmt.Fprintf(&b, "%08x", state) } return b.String()[:length] } func calcomFNV1a(s string) uint32 { var h uint32 = 2166136261 for _, ch := range s { h ^= uint32(ch) h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF } return h }