fix!: absolute routes + live captcha requirement — v2.0.3

Two wasm-era regressions fixed:

Routes: the wasm host forwards the FULL request path with no prefix
strip, but every route was registered relative — the plugin's entire
HTTP surface (slots/book/cancel/date-grid/admin) 404'd at runtime on
every instance since 2.0.0. All routes now register under
/api/plugins/calcomblock (testplugin's httpBase convention), pinned by
a route-matching test.

Captcha requirement: blockConfig was hardcoded nil (no capability
exposed published block content), so bookingRequiresCaptcha always
reported false — the widget never rendered and the gate never ran.
publishedBlockResolver now answers it via pluginsdk v0.2.4's
content.published_block_configs: any currently-published calcom:booking
block matching the posted username+eventTypeSlug with
captchaEnabled=true demands the host-stamped X-Bn-Verified-Captcha
header; an empty config username applies to any posted username.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-07 23:47:59 +08:00
parent c1f8fe8a2c
commit 7c7bceb9d7
7 changed files with 207 additions and 31 deletions

64
block_resolver.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"context"
"strings"
)
// httpBase is the prefix the host mounts the plugin HTTP handler at; the wasm
// host forwards full paths, so every route registers under it.
const httpBase = "/api/plugins/calcomblock"
// publishedConfigsSource is the slice of content.Content the resolver needs
// (satisfied by deps.Content).
type publishedConfigsSource interface {
PublishedBlockConfigs(ctx context.Context, blockKey string) ([]map[string]any, error)
}
// publishedBlockResolver answers CalcomBookingRequiresCaptcha from published
// block content served by the host (content.published_block_configs) — the
// server-authoritative source, never the POST body/blockId. A config with an
// empty username applies to any posted username (it falls back to the
// plugin's default account), erring toward requiring captcha.
type publishedBlockResolver struct {
source publishedConfigsSource
}
func (r publishedBlockResolver) CalcomBookingRequiresCaptcha(ctx context.Context, username, eventTypeSlug string) (bool, error) {
configs, err := r.source.PublishedBlockConfigs(ctx, "calcom:booking")
if err != nil {
return false, err
}
for _, cfg := range configs {
if !configBool(cfg["captchaEnabled"]) {
continue
}
if !configFieldMatches(cfg["eventTypeSlug"], eventTypeSlug, false) {
continue
}
if configFieldMatches(cfg["username"], username, true) {
return true, nil
}
}
return false, nil
}
func configBool(v any) bool {
switch b := v.(type) {
case bool:
return b
case string:
return strings.EqualFold(strings.TrimSpace(b), "true")
default:
return false
}
}
func configFieldMatches(cfgValue any, posted string, emptyMatchesAny bool) bool {
s, _ := cfgValue.(string)
s = strings.TrimSpace(s)
if s == "" {
return emptyMatchesAny
}
return strings.EqualFold(s, strings.TrimSpace(posted))
}

69
block_resolver_test.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"context"
"errors"
"testing"
)
type fakeConfigsSource struct {
configs []map[string]any
err error
gotKey string
}
func (f *fakeConfigsSource) PublishedBlockConfigs(_ context.Context, blockKey string) ([]map[string]any, error) {
f.gotKey = blockKey
return f.configs, f.err
}
func TestPublishedBlockResolverRequiresCaptchaOnMatch(t *testing.T) {
src := &fakeConfigsSource{configs: []map[string]any{
{"username": "other", "eventTypeSlug": "30min", "captchaEnabled": true},
{"username": "Alice", "eventTypeSlug": "30min", "captchaEnabled": true},
}}
r := publishedBlockResolver{source: src}
got, err := r.CalcomBookingRequiresCaptcha(context.Background(), "alice", "30min")
if err != nil || !got {
t.Fatalf("RequiresCaptcha = %v, %v; want true (case-insensitive username match)", got, err)
}
if src.gotKey != "calcom:booking" {
t.Fatalf("queried block key %q, want calcom:booking", src.gotKey)
}
}
func TestPublishedBlockResolverNoRequirementWhenDisabledOrUnmatched(t *testing.T) {
src := &fakeConfigsSource{configs: []map[string]any{
{"username": "alice", "eventTypeSlug": "30min", "captchaEnabled": false},
{"username": "alice", "eventTypeSlug": "60min", "captchaEnabled": true},
}}
r := publishedBlockResolver{source: src}
if got, _ := r.CalcomBookingRequiresCaptcha(context.Background(), "alice", "30min"); got {
t.Fatal("RequiresCaptcha = true; want false (disabled for 30min, 60min doesn't match)")
}
}
func TestPublishedBlockResolverEmptyConfigUsernameMatchesAny(t *testing.T) {
// A block may omit username (falls back to the plugin's default account);
// the requirement then applies to the event type for any posted username —
// erring toward requiring captcha, never stripping it.
src := &fakeConfigsSource{configs: []map[string]any{
{"eventTypeSlug": "30min", "captchaEnabled": true},
}}
r := publishedBlockResolver{source: src}
if got, _ := r.CalcomBookingRequiresCaptcha(context.Background(), "whoever", "30min"); !got {
t.Fatal("RequiresCaptcha = false; want true (config without username applies to any)")
}
}
func TestPublishedBlockResolverPropagatesError(t *testing.T) {
src := &fakeConfigsSource{err: errors.New("hostcall failed")}
r := publishedBlockResolver{source: src}
if _, err := r.CalcomBookingRequiresCaptcha(context.Background(), "alice", "30min"); err == nil {
t.Fatal("expected error to propagate (caller decides fail-open policy)")
}
}

2
go.mod
View File

@ -3,7 +3,7 @@ module git.dev.alexdunmow.com/block/calcomblock
go 1.26.4
require (
git.dev.alexdunmow.com/block/pluginsdk v0.2.2
git.dev.alexdunmow.com/block/pluginsdk v0.2.4
github.com/a-h/templ v0.3.1020
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0

2
go.sum
View File

@ -2,6 +2,8 @@ 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/pluginsdk v0.2.2 h1:jXq6ZAIVmsKtjAGdpYQvey9Uwl+poero/V2X1MHH80c=
git.dev.alexdunmow.com/block/pluginsdk v0.2.2/go.mod h1:uz6oRurbuHdTcUxg47ymVgBqRARKlpXp1JIziXevzms=
git.dev.alexdunmow.com/block/pluginsdk v0.2.4 h1:JC+VDHOE/SzXzR3znwAJyrN8c9gKhB91cfxUQBX+jAs=
git.dev.alexdunmow.com/block/pluginsdk v0.2.4/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=

View File

@ -277,38 +277,44 @@ func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
limiter := NewRateLimiter(bookingsPerHourPerIP)
helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
h := NewCalcomHandler(settings, limiter, deps.AppURL)
// blockConfig scans published block content for the server-authoritative
// captcha requirement (CalcomBookingRequiresCaptcha over page_block_snapshots
// in the public schema). No wasm-ABI capability exposes that today, and the
// sandboxed plugin role cannot read it via SQL, so it is left nil:
// bookingRequiresCaptcha then reports false and the baseline honeypot +
// per-IP rate limit still apply. Restoring server-authoritative captcha
// enforcement needs a future core capability that exposes published-block
// content across the wasm ABI (tracked as a follow-up).
h.blockConfig = nil
// blockConfig resolves the server-authoritative captcha requirement from
// published block content via the content.published_block_configs
// capability (pluginsdk >= v0.2.4) — the sandboxed plugin role cannot read
// page_block_snapshots itself. deps.Content is nil in native/DESCRIBE
// builds; blockConfig then stays nil and bookingRequiresCaptcha reports
// false (honeypot + per-IP rate limit remain the baseline).
if deps.Content != nil {
h.blockConfig = publishedBlockResolver{source: deps.Content}
}
// Public booking endpoints — reachable by anonymous visitors.
r.Get("/slots", h.HandleGetSlots)
r.Get("/form", h.HandleGetForm)
r.Post("/book", h.HandleCreateBooking)
r.Post("/cancel", h.HandleCancelBooking)
r.Get("/reset", h.HandleReset)
r.Get("/date-grid", h.HandleGetDateGrid)
// Routes are registered ABSOLUTE: the wasm host forwards the full request
// path (no prefix strip), so relative registrations never match — the
// 2.0.02.0.2 regression that 404'd the plugin's entire HTTP surface.
// Mirrors testplugin's httpBase convention.
r.Route(httpBase, func(r chi.Router) {
// Public booking endpoints — reachable by anonymous visitors.
r.Get("/slots", h.HandleGetSlots)
r.Get("/form", h.HandleGetForm)
r.Post("/book", h.HandleCreateBooking)
r.Post("/cancel", h.HandleCancelBooking)
r.Get("/reset", h.HandleReset)
r.Get("/date-grid", h.HandleGetDateGrid)
// Webhook receiver — HMAC-verified inside the handler, so no admin gate.
wh := NewWebhookHandler(settings, slog.Default())
r.Post("/webhook", wh.Handle)
// Webhook receiver — HMAC-verified inside the handler, so no admin gate.
wh := NewWebhookHandler(settings, slog.Default())
r.Post("/webhook", wh.Handle)
// Admin endpoints — gated by requireAdmin. The plugin HTTP mount has no
// outer admin middleware, so this is the only line keeping the API key
// + webhook secret + Cal.com proxy out of attackers' hands.
r.Group(func(admin chi.Router) {
admin.Use(requireAdmin)
admin.Get("/settings", h.HandleGetSettings)
admin.Post("/settings", h.HandleSaveSettings)
admin.Post("/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
admin.Post("/test", h.HandleTestConnection)
admin.Get("/event-types", h.HandleListEventTypes)
// Admin endpoints — gated by requireAdmin. The plugin HTTP mount has no
// outer admin middleware, so this is the only line keeping the API key
// + webhook secret + Cal.com proxy out of attackers' hands.
r.Group(func(admin chi.Router) {
admin.Use(requireAdmin)
admin.Get("/settings", h.HandleGetSettings)
admin.Post("/settings", h.HandleSaveSettings)
admin.Post("/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
admin.Post("/test", h.HandleTestConnection)
admin.Get("/event-types", h.HandleListEventTypes)
})
})
return r

View File

@ -2,7 +2,7 @@
name = "calcomblock"
display_name = "Cal.com Booking"
scope = "@ninja"
version = "2.0.2"
version = "2.0.3"
description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver."
kind = "plugin"
categories = ["forms"]

35
router_paths_test.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"net/http"
"testing"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"github.com/go-chi/chi/v5"
)
// TestRouterMatchesAbsolutePluginPaths pins the wasm routing contract: the
// host forwards the FULL request path (/api/plugins/calcomblock/...) to the
// guest with no prefix strip, so every route must be registered absolute.
// Relative registrations 404 the plugin's entire HTTP surface at runtime —
// exactly the regression that shipped in 2.0.02.0.2.
func TestRouterMatchesAbsolutePluginPaths(t *testing.T) {
router, ok := NewCalcomRouter(plugin.CoreServices{}).(chi.Routes)
if !ok {
t.Fatal("NewCalcomRouter no longer returns a chi router")
}
for _, tc := range []struct{ method, path string }{
{http.MethodGet, "/api/plugins/calcomblock/reset"},
{http.MethodGet, "/api/plugins/calcomblock/slots"},
{http.MethodGet, "/api/plugins/calcomblock/date-grid"},
{http.MethodPost, "/api/plugins/calcomblock/book"},
{http.MethodPost, "/api/plugins/calcomblock/cancel"},
{http.MethodGet, "/api/plugins/calcomblock/event-types"},
} {
rctx := chi.NewRouteContext()
if !router.Match(rctx, tc.method, tc.path) {
t.Errorf("%s %s does not match any route — must be registered at the absolute plugin path", tc.method, tc.path)
}
}
}