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
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
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/a-h/templ v0.3.1020
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.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=
|
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 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.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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
|
|||||||
24
handler.go
24
handler.go
@ -277,16 +277,21 @@ func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
|
|||||||
limiter := NewRateLimiter(bookingsPerHourPerIP)
|
limiter := NewRateLimiter(bookingsPerHourPerIP)
|
||||||
helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
|
helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
|
||||||
h := NewCalcomHandler(settings, limiter, deps.AppURL)
|
h := NewCalcomHandler(settings, limiter, deps.AppURL)
|
||||||
// blockConfig scans published block content for the server-authoritative
|
// blockConfig resolves the server-authoritative captcha requirement from
|
||||||
// captcha requirement (CalcomBookingRequiresCaptcha over page_block_snapshots
|
// published block content via the content.published_block_configs
|
||||||
// in the public schema). No wasm-ABI capability exposes that today, and the
|
// capability (pluginsdk >= v0.2.4) — the sandboxed plugin role cannot read
|
||||||
// sandboxed plugin role cannot read it via SQL, so it is left nil:
|
// page_block_snapshots itself. deps.Content is nil in native/DESCRIBE
|
||||||
// bookingRequiresCaptcha then reports false and the baseline honeypot +
|
// builds; blockConfig then stays nil and bookingRequiresCaptcha reports
|
||||||
// per-IP rate limit still apply. Restoring server-authoritative captcha
|
// false (honeypot + per-IP rate limit remain the baseline).
|
||||||
// enforcement needs a future core capability that exposes published-block
|
if deps.Content != nil {
|
||||||
// content across the wasm ABI (tracked as a follow-up).
|
h.blockConfig = publishedBlockResolver{source: deps.Content}
|
||||||
h.blockConfig = nil
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Public booking endpoints — reachable by anonymous visitors.
|
||||||
r.Get("/slots", h.HandleGetSlots)
|
r.Get("/slots", h.HandleGetSlots)
|
||||||
r.Get("/form", h.HandleGetForm)
|
r.Get("/form", h.HandleGetForm)
|
||||||
@ -310,6 +315,7 @@ func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
|
|||||||
admin.Post("/test", h.HandleTestConnection)
|
admin.Post("/test", h.HandleTestConnection)
|
||||||
admin.Get("/event-types", h.HandleListEventTypes)
|
admin.Get("/event-types", h.HandleListEventTypes)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
name = "calcomblock"
|
name = "calcomblock"
|
||||||
display_name = "Cal.com Booking"
|
display_name = "Cal.com Booking"
|
||||||
scope = "@ninja"
|
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."
|
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"
|
kind = "plugin"
|
||||||
categories = ["forms"]
|
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