diff --git a/go.mod b/go.mod index d08cb63..1079639 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,7 @@ module git.dev.alexdunmow.com/block/calcomblock go 1.26.4 require ( - git.dev.alexdunmow.com/block/core v0.20.3 - git.dev.alexdunmow.com/block/pluginsdk v0.2.1 + git.dev.alexdunmow.com/block/pluginsdk v0.2.2 github.com/a-h/templ v0.3.1020 github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index c0325f9..c4b966f 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -git.dev.alexdunmow.com/block/core v0.20.3 h1:esa09ctNGtXZgGWLER0EKPJybRf+0aoKqBOJDUfEeV4= -git.dev.alexdunmow.com/block/core v0.20.3/go.mod h1:G3MNnwiM4i9roLamhs9DAlVO2mXiYAlTRQoHPpw1AmI= -git.dev.alexdunmow.com/block/pluginsdk v0.2.1 h1:sbNDxVvbmZthLJ4kuJj3kyu7yurnsdkIF1gT/qBuGhU= -git.dev.alexdunmow.com/block/pluginsdk v0.2.1/go.mod h1:uz6oRurbuHdTcUxg47ymVgBqRARKlpXp1JIziXevzms= +git.dev.alexdunmow.com/block/pluginsdk v0.2.2 h1:jXq6ZAIVmsKtjAGdpYQvey9Uwl+poero/V2X1MHH80c= +git.dev.alexdunmow.com/block/pluginsdk v0.2.2/go.mod h1:uz6oRurbuHdTcUxg47ymVgBqRARKlpXp1JIziXevzms= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= diff --git a/handler.go b/handler.go index 288cd12..e9bdebd 100644 --- a/handler.go +++ b/handler.go @@ -16,7 +16,6 @@ import ( "time" "git.dev.alexdunmow.com/block/pluginsdk/auth" - "git.dev.alexdunmow.com/block/core/captcha" "git.dev.alexdunmow.com/block/pluginsdk/plugin" "git.dev.alexdunmow.com/block/pluginsdk/rbac" "git.dev.alexdunmow.com/block/calcomblock/internal/helpers" @@ -24,21 +23,6 @@ import ( "github.com/nyaruka/phonenumbers" ) -// captchaVerifier is the shared Cap (trycap.dev) proof-of-work verifier, built -// once from the per-instance secret and injected at server startup via -// SetCaptchaServer — mirroring SetNotifyFn. It is nil when the instance captcha -// secret failed to load: an enabled booking block then FAILS CLOSED (rejects -// rather than allowing an unverified booking through). Read directly by -// HandleCreateBooking; the mailing-list / block-form handlers hold the same -// server on their struct, but the plugin HTTP mount is generic so a package -// seam is the established injection point here. -var captchaVerifier *captcha.Server - -// SetCaptchaServer wires the CMS's shared captcha server into the Cal.com -// booking handler. Called once at server startup from server.go. Safe to pass -// nil (captcha then unavailable → enabled blocks fail closed). -func SetCaptchaServer(s *captcha.Server) { captchaVerifier = s } - // blockContentResolver answers the server-authoritative question "does a // booking for this Cal.com username + event-type slug require a captcha token?" // by scanning currently-published block content — NEVER a client-supplied @@ -697,13 +681,16 @@ func (h *CalcomHandler) HandleCreateBooking(w http.ResponseWriter, r *http.Reque // authoritative source is whether any currently-published calcom:booking // block for this username+eventType has captchaEnabled=true — resolved from // published content, never the POST body/blockId — so a bot cannot strip the - // requirement by omitting or forging blockId. When captcha IS required we - // fail CLOSED: a nil shared captcha server (instance secret failed at - // startup) rejects rather than allows. On failure we render the same error - // partial as other validation failures (not a 500), so the visitor can - // re-solve and retry. + // requirement by omitting or forging blockId. Verification is HOST-side: a + // guest cannot hold the host's stateful captcha server, so the host consumes + // the cap-token and stamps the unforgeable X-Bn-Verified-Captcha trusted + // header (pluginsdk/auth). When captcha IS required we fail CLOSED: no + // stamp (invalid token, or the instance captcha server failed at startup) + // rejects rather than allows. On failure we render the same error partial + // as other validation failures (not a 500), so the visitor can re-solve + // and retry. if h.bookingRequiresCaptcha(r.Context(), username, eventType) { - if captchaVerifier == nil || !captchaVerifier.VerifyRequest(r) { + if !auth.CaptchaVerified(r.Header) { h.renderError(w, "Captcha check failed, please try again.", blockID, false) return } diff --git a/handler_captcha_test.go b/handler_captcha_test.go index 1bafbd7..6bf40d7 100644 --- a/handler_captcha_test.go +++ b/handler_captcha_test.go @@ -2,22 +2,18 @@ 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" + "git.dev.alexdunmow.com/block/pluginsdk/auth" ) // fakeBlockResolver is an in-memory blockContentResolver so the booking handler @@ -46,7 +42,7 @@ func (f fakeBlockResolver) CalcomBookingRequiresCaptcha(_ context.Context, usern // 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. +// see fakeBlockResolver. func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler { t.Helper() h, _ := newTestHandler() @@ -59,12 +55,6 @@ func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler { 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 @@ -84,10 +74,18 @@ func captchaBookingForm(blockID string) url.Values { return v } -func postCaptchaBooking(h *CalcomHandler, form url.Values) *httptest.ResponseRecorder { +// postCaptchaBooking submits the form. captchaHeader is the raw value of the +// host-stamped X-Bn-Verified-Captcha trusted header ("" = host stamped +// nothing: no valid cap-token crossed the host, or the instance captcha +// server was unavailable). Guests never see raw tokens — the host verifies +// and consumes them before dispatch (pluginsdk/auth/trustedheaders.go). +func postCaptchaBooking(h *CalcomHandler, form url.Values, captchaHeader string) *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 + if captchaHeader != "" { + req.Header.Set(auth.HeaderVerifiedCaptcha, captchaHeader) + } rec := httptest.NewRecorder() h.HandleCreateBooking(rec, req) return rec @@ -108,60 +106,55 @@ func fakeCalcomServer(t *testing.T) (*httptest.Server, *atomic.Int32) { return srv, &bookings } -func TestCalcomBooking_CaptchaEnabled_RejectsWithoutToken(t *testing.T) { +func TestCalcomBooking_CaptchaEnabled_RejectsWithoutVerifiedHeader(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 + rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "") // host stamped nothing if got := bookings.Load(); got != 0 { - t.Fatalf("Cal.com booking created %d times; want 0 (missing captcha token must be rejected)", got) + t.Fatalf("Cal.com booking created %d times; want 0 (missing verified-captcha header 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) { +func TestCalcomBooking_CaptchaEnabled_RejectsNonCanonicalHeaderValue(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) + // Only the exact host-stamped value "1" counts; anything else reads as + // unverified (auth.CaptchaVerified). + rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "true") if got := bookings.Load(); got != 0 { - t.Fatalf("Cal.com booking created %d times; want 0 (invalid token rejected)", got) + t.Fatalf("Cal.com booking created %d times; want 0 (non-canonical header value 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) { +func TestCalcomBooking_CaptchaEnabled_ProceedsWithVerifiedHeader(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) + // A real widget still posts the cap-token field; the HOST consumes it and + // stamps the header. The guest must trust the header and never forward the + // raw token to Cal.com. form := captchaBookingForm(uuid.NewString()) - form.Set("cap-token", token) - rec := postCaptchaBooking(h, form) + form.Set("cap-token", "host-already-consumed-this") + rec := postCaptchaBooking(h, form, "1") if got := bookings.Load(); got != 1 { - t.Fatalf("Cal.com booking created %d times; want 1 (valid token must pass captcha)", got) + t.Fatalf("Cal.com booking created %d times; want 1 (host-verified captcha must pass)", got) } if rec.Code != http.StatusOK { t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String()) @@ -172,16 +165,13 @@ func TestCalcomBooking_CaptchaEnabled_ProceedsWithValidToken(t *testing.T) { } } -func TestCalcomBooking_CaptchaDisabled_TokenNotRequired(t *testing.T) { +func TestCalcomBooking_CaptchaDisabled_HeaderNotRequired(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 + rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "") // no header if got := bookings.Load(); got != 1 { t.Fatalf("Cal.com booking created %d times; want 1 (captcha disabled)", got) @@ -194,21 +184,22 @@ func TestCalcomBooking_CaptchaDisabled_TokenNotRequired(t *testing.T) { } } -func TestCalcomBooking_CaptchaEnabled_FailsClosedWhenServerNil(t *testing.T) { +// TestCalcomBooking_CaptchaEnabled_FailsClosedWithoutHostStamp pins the +// fail-closed contract: a request that carries a cap-token but NO host stamp +// (instance captcha server down, or the token was invalid/replayed host-side) +// must be rejected — the guest never verifies tokens itself. +func TestCalcomBooking_CaptchaEnabled_FailsClosedWithoutHostStamp(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) + 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) + t.Fatalf("Cal.com booking created %d times; want 0 (fail closed without host stamp)", got) } if !strings.Contains(rec.Body.String(), "aptcha") { t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String()) @@ -232,11 +223,9 @@ func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.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)) + // No host stamp; 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) @@ -252,7 +241,7 @@ func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.T) // 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 +// protection. (This is distinct from the missing-host-stamp path, which fails // CLOSED when a block IS known to require captcha.) func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) { fake, bookings := fakeCalcomServer(t) @@ -263,10 +252,8 @@ func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) { 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 + rec := postCaptchaBooking(h, captchaBookingForm(""), "") // no host stamp 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) } @@ -274,67 +261,3 @@ func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) { 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 -}