Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions internal/handler/booking_abuse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
Expand All @@ -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 = `<div aria-hidden="true" style="position:absolute;left:-5000px;`
start := strings.Index(body, wrapperOpen)
if start < 0 {
t.Fatalf("honeypot wrapper not found in the booking page")
}
end := strings.Index(body[start:], "</div>")
if end < 0 {
t.Fatalf("honeypot wrapper is not closed")
}
block := body[start : start+end]

inputs := regexp.MustCompile(`<input\b[^>]*>`).FindAllString(block, -1)
if len(inputs) != 1 {
t.Fatalf("honeypot wrapper holds %d inputs; want exactly 1:\n%s", len(inputs), block)
}
if text := strings.TrimSpace(regexp.MustCompile(`<[^>]*>`).ReplaceAllString(block, "")); text != "" {
t.Errorf("honeypot wrapper contains text %q, which autofill reads as the field's label", text)
}
if strings.Contains(block, "<label") {
t.Errorf("honeypot has a <label>; autofill classifies fields by label text:\n%s", block)
}

attrs := map[string]string{}
for _, m := range regexp.MustCompile(`([a-z-]+)="([^"]*)"`).FindAllStringSubmatch(inputs[0], -1) {
attrs[m[1]] = m[2]
}
if attrs["id"] == "" {
t.Fatalf("honeypot input has no id for the submit script to read: %s", inputs[0])
}
for _, a := range []string{"name", "id"} {
if autofillTokens.MatchString(attrs[a]) {
t.Errorf("honeypot %s=%q is a word autofill classifies fields by", a, attrs[a])
}
}
for _, a := range []string{"placeholder", "aria-label", "title", "value"} {
if v, ok := attrs[a]; ok {
t.Errorf("honeypot has %s=%q, which autofill can read as a label", a, v)
}
}
if strings.Contains(body, `name="company"`) || strings.Contains(body, `id="f-company"`) {
t.Error(`the page still renders a field named or id'd "company"`)
}

// The API field is unchanged: the submit script must read this input by its id and
// post it as "company", or the server-side check silently stops seeing bots.
if want := "company: $('" + attrs["id"] + "').value"; !strings.Contains(body, want) {
t.Errorf("submit script does not post the honeypot as company; want %q in the page", want)
}
}

// TestCreateBooking_perEmailThrottle: one email can create up to the hourly cap
// across distinct slots, then is throttled with 429.
func TestCreateBooking_perEmailThrottle(t *testing.T) {
Expand Down
12 changes: 9 additions & 3 deletions internal/handler/templates/book.html
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,15 @@ <h1 class="event-name">{{.Name}}</h1>
<p class="slot-label" id="form-slot-label"></p>
<form id="booking-form" novalidate>
<!-- Honeypot: hidden from humans (off-screen, not tabbable); a bot that fills
"company" is rejected server-side. Real submissions leave it empty. -->
it is rejected server-side. It is still posted as "company" (the API field),
but the input itself must give browser autofill nothing to classify: no
label, and a name/id that matches none of Chromium's field patterns
(components/autofill/core/browser/form_parsing/resources/
legacy_regex_patterns.json). A "Company" label and name="company" made Chrome
fill it from the booker's address profile, rejecting real people (#33),
although autocomplete="off" was already set. -->
<div aria-hidden="true" style="position:absolute;left:-5000px;height:0;width:0;overflow:hidden;">
<label>Company<input id="f-company" name="company" type="text" tabindex="-1" autocomplete="off"></label>
<input id="f-hp" name="hp" type="text" tabindex="-1" autocomplete="off">
</div>
<div class="field">
<label for="f-name">{{call .T "name_label"}}</label>
Expand Down Expand Up @@ -682,7 +688,7 @@ <h3>{{call .T "booking_confirmed"}}</h3>
email: form.email.value.trim(),
timezone: TZ,
language: LOCALE,
company: form.company.value,
company: $('f-hp').value,
answers,
}),
});
Expand Down
Loading