Convert the bundled backend/internal/plugins/calcomblock package into a standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms import (forbidden in standalone plugins): - package calcomblock -> package main; add wasip1 main.go with wasmguest.Serve(Registration). go.mod module git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace directives. - Settings persistence: replace the pool-backed poolQuerier (direct SQL against the public-schema `settings` table, which a sandboxed plugin role cannot read) with capabilityQuerier over the SDK settings.Settings / settings.Updater capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side. - Vendor the small CMS-internal helpers the plugin used into internal/helpers (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier settings helpers, PluginCrypto for tests) and internal/db (minimal Setting / UpsertSettingParams), each with a provenance header. - Inline the captcha widget: vendor blocks.CaptchaWidget as a local CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the block/cms/blocks import from booking.templ. - blockConfig (server-authoritative captcha requirement via a page_block_snapshots scan) has no wasm-ABI capability, so it is left nil: honeypot + per-IP rate limit still apply. Documented as a follow-up. - web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js repointed at ../../../cms/web/eslint.config.js; rebuild web/dist. - Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore. Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp; check-safety passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
341 lines
12 KiB
Go
341 lines
12 KiB
Go
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
|
|
}
|