diff --git a/block_resolver.go b/block_resolver.go new file mode 100644 index 0000000..40c1fa1 --- /dev/null +++ b/block_resolver.go @@ -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)) +} diff --git a/block_resolver_test.go b/block_resolver_test.go new file mode 100644 index 0000000..bd060f6 --- /dev/null +++ b/block_resolver_test.go @@ -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)") + } +} diff --git a/go.mod b/go.mod index 1079639..60c8e78 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c4b966f..65933b4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handler.go b/handler.go index e9bdebd..c8aa63a 100644 --- a/handler.go +++ b/handler.go @@ -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.0–2.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 diff --git a/plugin.mod b/plugin.mod index 6a7f474..5d0d9b4 100644 --- a/plugin.mod +++ b/plugin.mod @@ -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"] diff --git a/router_paths_test.go b/router_paths_test.go new file mode 100644 index 0000000..e49155f --- /dev/null +++ b/router_paths_test.go @@ -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.0–2.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) + } + } +}