diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1ce7cc..9c453798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,20 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad the link-only fallback that needs no Zoom app. Answers [#35](https://github.com/Calnode/calnode/issues/35). +- **Booking no longer fails with a 400 for people whose browser autofills the hidden + honeypot field.** The booking page's anti-bot field was labelled "Company" and named + `company`, which is exactly what Chrome looks for when filling an organisation from an + address profile, so autofill filled it despite `autocomplete="off"` and the server + rejected a real person as a bot. Fixes + [#33](https://github.com/Calnode/calnode/issues/33), diagnosed by + [@MinosChatzidakis](https://github.com/MinosChatzidakis). + + The input now has no label and a neutral `name="hp"`/`id="f-hp"`, checked against + Chromium's own field-classification patterns. The API is unchanged: the page still + posts the value as `company`, so the embed widget and any third-party client keep + working, and a filled value is still rejected. The embed widget needed no change; its + honeypot never had a label, name or id. + ## [0.9.0] - 2026-09-10 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58defaab..dca93072 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -428,6 +428,14 @@ them - the most common "why can't I see those times". (`maxBookingsPerEmailPerHour`) backstops rotating-IP spam; and the booking form has a hidden **honeypot** (`company`) that rejects bots. Booker email *verification* is intentionally absent — it would need a pending-booking state (a deliberate non-goal). + - **The honeypot input must give browser autofill nothing to classify.** Autofill + fills fields it recognises by label text and `name`/`id`, and a "Company" label with + `name="company"` made Chrome fill it from real bookers' address profiles despite + `autocomplete="off"`, so the server rejected people as bots (#33). On + `book.html` it has no label and a neutral `name="hp"`/`id="f-hp"`; the embed + widget's has no label, name or id at all. Only the JSON field is called `company`. + `TestBookPage_honeypotGivesAutofillNothingToClassify` holds the rendered markup to + that. - **Cancel:** `CancelBooking` (admin) and `CancelByToken` (manage link) share `Handler.cancelSideEffects` — loops `booking_hosts`, cancels each host's calendar event by its stored id, notifies each host + the attendee (attendee "With:" = the diff --git a/internal/handler/booking_abuse_test.go b/internal/handler/booking_abuse_test.go index 4398b64a..02424e45 100644 --- a/internal/handler/booking_abuse_test.go +++ b/internal/handler/booking_abuse_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "regexp" "strings" "testing" ) @@ -25,6 +26,80 @@ func TestCreateBooking_honeypotRejected(t *testing.T) { } } +// autofillTokens is a small subset of the words Chromium classifies a field by +// (components/autofill/core/browser/form_parsing/resources/legacy_regex_patterns.json): +// company, name, email, phone and address, in the languages this project ships. It is +// deliberately a tripwire for the obvious regression, not a copy of that file. +var autofillTokens = regexp.MustCompile(`(?i)compan|business|organi[sz]ation|firma|empresa|soci[eé]t[eé]|azienda|bedrijf|f[öo]retag|name|nom|nombre|e.?mail|courriel|correo|phone|tel|mobile|addr|street|city|zip|postal|country`) + +// TestBookPage_honeypotGivesAutofillNothingToClassify: browser autofill fills fields it +// recognises by label and name, and a "Company" label with name="company" made Chrome +// fill the honeypot from a real booker's address profile, so the server rejected people +// as bots (#33). The rendered input must carry no label and no autofill-recognisable +// name or id, while the page still posts it as "company". +func TestBookPage_honeypotGivesAutofillNothingToClassify(t *testing.T) { + h, key, _ := setupWorkspace(t) + slug, _ := seedEventTypeHTTP(t, h, key) + + req := httptest.NewRequest(http.MethodGet, "/book/"+slug, nil) + req.SetPathValue("slug", slug) + rec := httptest.NewRecorder() + h.BookPage(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("BookPage: %d — %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + + const wrapperOpen = `