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:
parent
c1f8fe8a2c
commit
7c7bceb9d7
64
block_resolver.go
Normal file
64
block_resolver.go
Normal 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
69
block_resolver_test.go
Normal 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
2
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
64
handler.go
64
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
|
||||
|
||||
@ -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
35
router_paths_test.go
Normal 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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user