feat!: captcha via host-stamped X-Bn-Verified-Captcha; drop block/core entirely
SetCaptchaServer was .so-era injection — nothing wasm-side ever called it, so the verifier was permanently nil and captcha-enabled booking blocks failed closed on every submission. A guest can't hold the host's stateful captcha server; the host now verifies+consumes the cap-token before dispatch and stamps the unforgeable X-Bn-Verified-Captcha trusted header (pluginsdk v0.2.2 auth.CaptchaVerified). Fail-closed semantics preserved: no stamp = reject. Deletes the last block/core import (captcha) and the test-side PoW solver; go.mod no longer requires core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
28b50128d9
commit
7e9265c6a7
3
go.mod
3
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
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
31
handler.go
31
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user