Skip to content

Commit 75d71aa

Browse files
Merge pull request olivierlambert#122 from florian-SV/feat/captcha-booking
feat(book): add captcha system for secure booking endpoint
2 parents 8453b36 + 53ba1ee commit 75d71aa

15 files changed

Lines changed: 852 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,104 @@ File: `src/web/mod.rs`, templates in `templates/`
317317

318318
---
319319

320+
## Captcha (trycap / Cap)
321+
322+
### What it does
323+
324+
Protects booking endpoints against bots with a privacy-first proof-of-work CAPTCHA. The feature is **opt-in**: when no configuration is stored the booking flow works exactly as before — no widget, no server-side check. The captcha is only active on booking form pages (guest-facing), never on registration or dashboard routes.
325+
326+
The chosen provider is [Cap](https://trycap.dev) — self-hosted, no GAFAM, no tracking, Docker-deployable. It is API-compatible with the reCAPTCHA/hCaptcha verification protocol, so switching providers in the future only requires updating `src/web/captcha.rs`.
327+
328+
### Configuration (admin panel)
329+
330+
Configured at `/dashboard/admin`**Captcha** section. Four fields:
331+
332+
| Field | Required | Description |
333+
|---|---|---|
334+
| Instance URL | Yes | Base URL of your Cap server, e.g. `https://captcha.example.com` |
335+
| Site key | Yes | Site key from the Cap dashboard |
336+
| Secret | Yes | Secret key for server-to-server verification (encrypted at rest with AES-256-GCM, same pattern as OIDC client secret) |
337+
| Widget script URL | No | Override the JS bundle URL. Defaults to `https://cdn.jsdelivr.net/npm/cap-widget`. Useful for air-gapped deployments. Changes take effect immediately after saving (CSP is rebuilt in memory). |
338+
339+
Leaving all three main fields empty disables the captcha. The secret uses the keep-current pattern: leaving the password field empty on save preserves the stored value.
340+
341+
### How it works end-to-end
342+
343+
**GET (booking form):** The handler reads `state.captcha_config` (a `RwLock<Option<CaptchaConfig>>`). If `Some`, it passes `captcha_enabled = true`, `captcha_api_endpoint`, and `captcha_widget_url` to the template. The `<cap-widget>` element is rendered conditionally in `templates/book.html` with all i18n attributes pre-filled via the Fluent `t()` helper.
344+
345+
**POST (booking submit):** The `BookForm` struct has a `captcha_token: Option<String>` field with `#[serde(rename = "cap-token")]` — the field name the Cap widget submits. After the CSRF check, `captcha::verify(&captcha_cfg, form.captcha_token.as_deref()).await` is called:
346+
- `config = None``Ok(())` immediately (pass-through)
347+
- `config = Some`, token missing/empty → `Err(())` → renders `booking_action_error.html`
348+
- `config = Some`, token present → POST to `<instance>/<site-key>/siteverify` with JSON `{"secret": "...", "response": "<token>"}` → parses `{"success": bool}`
349+
350+
The verification is implemented in `src/web/captcha.rs` and applies to **3 handlers**: `handle_booking`, `handle_booking_for_user`, `handle_group_booking`. The dynamic group booking path (`username.contains('+')`) is also protected because the check happens before the branch.
351+
352+
### Code structure
353+
354+
| File | Role |
355+
|---|---|
356+
| `src/web/captcha.rs` | Self-contained module: `CaptchaConfig` struct, `load_captcha_config()`, `verify()`, `extract_origin()` helper, unit tests |
357+
| `src/web/mod.rs` | `AppState.captcha_config: RwLock<Option<CaptchaConfig>>`, `build_csp()`, `csp_middleware()`, `admin_update_captcha()` handler, wiring in the 4 GET form handlers and 3 POST booking handlers |
358+
| `migrations/053_captcha.sql` | Adds `captcha_instance_url`, `captcha_site_key`, `captcha_secret`, `captcha_widget_url` columns to `auth_config` |
359+
| `templates/admin.html` | Captcha configuration section (status badge, 4 inputs, save button) |
360+
| `templates/book.html` | Conditional `<cap-widget>` with all `data-cap-i18n-*` attributes |
361+
| `templates/base.html` | CSS custom property overrides for `cap-widget` theming |
362+
363+
### Content-Security-Policy
364+
365+
The CSP is **dynamic** — it is rebuilt whenever the admin saves captcha settings, without a server restart (except for the widget script URL, which controls the `script-src` domain). It is stored as a pre-built string in `AppState.csp: RwLock<String>` and applied by `csp_middleware` (an Axum `from_fn_with_state` layer).
366+
367+
`build_csp(captcha: &Option<CaptchaConfig>) -> String` produces:
368+
369+
**Without captcha configured:**
370+
```
371+
default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';
372+
script-src 'self' 'unsafe-inline';
373+
connect-src 'self';
374+
object-src 'none'; base-uri 'self'; frame-ancestors 'self'
375+
```
376+
377+
**With captcha configured:**
378+
```
379+
default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';
380+
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' <widget-origin>;
381+
worker-src blob:;
382+
connect-src 'self' <instance-origin> <widget-origin>;
383+
object-src 'none'; base-uri 'self'; frame-ancestors 'self'
384+
```
385+
386+
The three extra directives and why they are needed:
387+
- **`'wasm-unsafe-eval'` in `script-src`** — Cap's proof-of-work solver runs as a WASM module. Without this, browsers block `WebAssembly.instantiate()` under a strict CSP. Falls back to a JS solver if blocked, but WASM is much faster.
388+
- **`worker-src blob:`** — The Cap widget spawns a Web Worker via a `blob:` URL to run the solver off the main thread. `worker-src` is not inherited from `script-src` in all browsers, so it must be explicit.
389+
- **`<widget-origin>` in `connect-src`** — The widget's JS fetches the WASM binary from the same CDN origin via `fetch()`. This is controlled by `connect-src`, not `script-src`. Both the Cap server origin (for token verification XHR) and the widget CDN origin (for WASM fetch) must be in `connect-src`.
390+
391+
The `csp_middleware` skips setting the header if it is already present, so individual handlers can override it if needed in the future.
392+
393+
### Translations
394+
395+
All visible strings in the Cap widget are localised via the standard Fluent system. Keys use the `captcha-` prefix and live in each language file:
396+
397+
Keys: `captcha-label`, `captcha-initial-state`, `captcha-verifying`, `captcha-solved`, `captcha-error`, `captcha-troubleshooting`, `captcha-wasm-disabled`, plus five `*-aria` keys for accessibility.
398+
399+
### Styling
400+
401+
The `<cap-widget>` custom element exposes CSS custom properties. They are overridden globally in `templates/base.html` to follow the app's existing theme variables:
402+
403+
```css
404+
cap-widget {
405+
--cap-background: var(--surface);
406+
--cap-border-color: var(--border);
407+
--cap-color: var(--text);
408+
--cap-checkbox-background: var(--surface-hover);
409+
--cap-spinner-color: var(--text);
410+
--cap-spinner-background-color: var(--border);
411+
}
412+
```
413+
414+
Because `--surface`, `--border`, etc. are already overridden by `html.dark { ... }` (in `base.html`) and by each preset/custom theme (via `theme_css` in `AppState`), the widget automatically adapts to dark mode and all custom themes (Nord, Dracula, Tokyo Night, etc.) with no additional CSS.
415+
416+
---
417+
320418
## Known issues & TODOs
321419

322420
### Security

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ Each team member's CalDAV calendars are checked for conflicts. The availability
194194

195195
- **Credential encryption** — CalDAV and SMTP passwords encrypted at rest with AES-256-GCM; secret key auto-generated or provided via `CALRS_SECRET_KEY`
196196
- **Hidden password input** — passwords never echoed to the terminal
197+
- **Privacy-first CAPTCHA** — optional [Cap](https://trycap.dev) integration (self-hosted, proof-of-work, no GAFAM). When configured, a `<cap-widget>` is shown on all booking pages and the token is verified server-to-server before any booking is processed. Disabled by default — no widget, no network call if unconfigured
197198

198199
### Localization
199200

@@ -368,6 +369,64 @@ calrs serve --port 3000
368369

369370
The login page will show a "Sign in with SSO" button. With `--auto-register true`, users are created automatically on first OIDC login. Existing local users are linked by email.
370371

372+
## Captcha setup (Cap / trycap.dev)
373+
374+
calrs integrates [Cap](https://trycap.dev), a self-hosted proof-of-work CAPTCHA that requires no third-party service and collects no user data. The widget appears only on booking pages — never on the dashboard or registration form.
375+
376+
**The feature is opt-in.** If you do not configure it, the booking flow works exactly as before: no widget, no network call.
377+
378+
### 1. Deploy a Cap instance
379+
380+
Cap is distributed as a Docker image. Minimal compose example:
381+
382+
```yaml
383+
services:
384+
cap:
385+
image: ghcr.io/trycap/cap:latest
386+
ports:
387+
- "3001:3001"
388+
environment:
389+
CAP_SECRET: your-secret-key
390+
```
391+
392+
Create a site in the Cap dashboard and note the **site key** and **secret**.
393+
394+
### 2. Configure calrs
395+
396+
Go to **Admin panel → Captcha** and fill in:
397+
398+
| Field | Example | Notes |
399+
|---|---|---|
400+
| Instance URL | `https://captcha.example.com` | Base URL of your Cap server |
401+
| Site key | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | From the Cap dashboard |
402+
| Secret | `your-secret-key` | Encrypted at rest with AES-256-GCM |
403+
| Widget script URL | *(leave empty)* | Defaults to `https://cdn.jsdelivr.net/npm/cap-widget`. Override for air-gapped deployments. |
404+
405+
Click **Save captcha settings**. All changes — including the widget script URL — take effect immediately, no restart required.
406+
407+
To disable, clear the Instance URL, Site key, and Secret fields and save.
408+
409+
### How verification works
410+
411+
1. The guest completes the proof-of-work challenge in the browser (the Cap widget handles this automatically, with a WASM solver for speed)
412+
2. The widget writes the resulting token into a hidden `cap-token` form field
413+
3. On submit, calrs calls `POST <instance>/<site-key>/siteverify` with the token and the secret (server-to-server, never exposed to the browser)
414+
4. If verification fails, the guest sees the standard error page — no booking is created
415+
416+
### Content-Security-Policy
417+
418+
calrs sets a strict CSP on all responses. When Cap is configured, the following directives are added and updated automatically on every save:
419+
420+
| Directive | Added value | Why |
421+
|---|---|---|
422+
| `script-src` | `'wasm-unsafe-eval' <widget-origin>` | The widget JS loads from an external origin; `wasm-unsafe-eval` allows the WASM proof-of-work solver |
423+
| `worker-src` | `blob:` | The widget spawns a Web Worker via a blob URL to run the solver off the main thread |
424+
| `connect-src` | `<instance-origin> <widget-origin>` | The browser fetches the WASM binary from the widget CDN and sends the solved token to the Cap server |
425+
426+
When captcha is disabled, none of these directives are present — the CSP reverts to its strict baseline immediately after saving.
427+
428+
---
429+
371430
## Reverse proxy
372431

373432
calrs listens on HTTP (port 3000 by default). Use a reverse proxy for TLS termination.

i18n/de/main.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ book-additional-guests-label = Weitere Teilnehmer
7474
book-additional-guests-hint = (optional, bis zu { $max })
7575
book-add-guest-btn = + Teilnehmer hinzufügen
7676
book-guest-email-placeholder = kollege@example.com
77+
captcha-label = Sicherheitsüberprüfung
78+
captcha-initial-state = Bestätigen Sie, dass Sie ein Mensch sind
79+
captcha-verifying = Überprüfung läuft...
80+
captcha-solved = Sie sind ein Mensch
81+
captcha-error = Fehler
82+
captcha-troubleshooting = Fehlerbehebung
83+
captcha-wasm-disabled = WASM aktivieren für deutlich schnellere Lösung
84+
captcha-verify-aria = Klicken Sie, um zu bestätigen, dass Sie ein Mensch sind
85+
captcha-verifying-aria = Überprüfung läuft, bitte warten
86+
captcha-verified-aria = Bestätigt
87+
captcha-required = Bitte bestätigen Sie, dass Sie ein Mensch sind
88+
captcha-error-aria = Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut
7789
book-confirm-button = Buchung bestätigen
7890
7991
# Shared labels used across the cancel / decline / approve / reschedule / claim flows

i18n/en/main.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ book-additional-guests-label = Additional guests
7474
book-additional-guests-hint = (optional, up to { $max })
7575
book-add-guest-btn = + Add guest email
7676
book-guest-email-placeholder = colleague@example.com
77+
captcha-label = Security verification
78+
captcha-initial-state = Verify you're human
79+
captcha-verifying = Verifying...
80+
captcha-solved = You're human
81+
captcha-error = Error
82+
captcha-troubleshooting = Troubleshooting
83+
captcha-wasm-disabled = Enable WASM for significantly faster solving
84+
captcha-verify-aria = Click to verify you're a human
85+
captcha-verifying-aria = Verifying, please wait
86+
captcha-verified-aria = Verified
87+
captcha-required = Please verify you're human
88+
captcha-error-aria = An error occurred, please try again
7789
book-confirm-button = Confirm booking
7890
7991
# Shared labels used across the cancel / decline / approve / reschedule / claim flows

i18n/es/main.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ book-additional-guests-label = Invitados adicionales
7474
book-additional-guests-hint = (opcional, hasta { $max })
7575
book-add-guest-btn = + Añadir invitado
7676
book-guest-email-placeholder = colega@example.com
77+
captcha-label = Verificación de seguridad
78+
captcha-initial-state = Verifique que es humano
79+
captcha-verifying = Verificando...
80+
captcha-solved = Eres humano
81+
captcha-error = Error
82+
captcha-troubleshooting = Solución de problemas
83+
captcha-wasm-disabled = Active WASM para una resolución significativamente más rápida
84+
captcha-verify-aria = Haga clic para verificar que es humano
85+
captcha-verifying-aria = Verificando, por favor espere
86+
captcha-verified-aria = Verificado
87+
captcha-required = Por favor, verifique que es humano
88+
captcha-error-aria = Se ha producido un error, por favor inténtelo de nuevo
7789
book-confirm-button = Confirmar reserva
7890
7991
# Shared labels used across the cancel / decline / approve / reschedule / claim flows

i18n/fr/main.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ book-additional-guests-label = Invités supplémentaires
7474
book-additional-guests-hint = (facultatif, jusqu'à { $max })
7575
book-add-guest-btn = + Ajouter un invité
7676
book-guest-email-placeholder = collegue@example.com
77+
captcha-label = Vérification de sécurité
78+
captcha-initial-state = Vérifiez que vous êtes humain
79+
captcha-verifying = Vérification en cours...
80+
captcha-solved = Vous êtes humain
81+
captcha-error = Erreur
82+
captcha-troubleshooting = Dépannage
83+
captcha-wasm-disabled = Activez WASM pour une résolution plus rapide
84+
captcha-verify-aria = Cliquez pour vérifier que vous êtes humain
85+
captcha-verifying-aria = Vérification en cours, veuillez patienter
86+
captcha-verified-aria = Vérifié
87+
captcha-required = Veuillez vérifier que vous êtes humain
88+
captcha-error-aria = Une erreur est survenue, veuillez réessayer
7789
book-confirm-button = Confirmer la réservation
7890
7991
# Shared labels used across the cancel / decline / approve / reschedule / claim flows

i18n/it/main.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ book-additional-guests-label = Ospiti aggiuntivi
7474
book-additional-guests-hint = (opzionale, fino a { $max })
7575
book-add-guest-btn = + Aggiungi ospite
7676
book-guest-email-placeholder = collega@example.com
77+
captcha-label = Verifica di sicurezza
78+
captcha-initial-state = Verifica di essere umano
79+
captcha-verifying = Verifica in corso...
80+
captcha-solved = Sei umano
81+
captcha-error = Errore
82+
captcha-troubleshooting = Risoluzione dei problemi
83+
captcha-wasm-disabled = Abilita WASM per una risoluzione significativamente più rapida
84+
captcha-verify-aria = Clicca per verificare di essere umano
85+
captcha-verifying-aria = Verifica in corso, attendere prego
86+
captcha-verified-aria = Verificato
87+
captcha-required = Verifica di essere umano
88+
captcha-error-aria = Si è verificato un errore, riprova
7789
book-confirm-button = Conferma prenotazione
7890
7991
# Shared labels used across the cancel / decline / approve / reschedule / claim flows

i18n/pl/main.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ book-additional-guests-label = Dodatkowi goście
7474
book-additional-guests-hint = (opcjonalne, do { $max })
7575
book-add-guest-btn = + Dodaj gościa
7676
book-guest-email-placeholder = wspolpracownik@example.com
77+
captcha-label = Weryfikacja bezpieczeństwa
78+
captcha-initial-state = Potwierdź, że jesteś człowiekiem
79+
captcha-verifying = Weryfikacja...
80+
captcha-solved = Jesteś człowiekiem
81+
captcha-error = Błąd
82+
captcha-troubleshooting = Rozwiązywanie problemów
83+
captcha-wasm-disabled = Włącz WASM dla znacznie szybszego rozwiązywania
84+
captcha-verify-aria = Kliknij, aby potwierdzić, że jesteś człowiekiem
85+
captcha-verifying-aria = Weryfikacja, proszę czekać
86+
captcha-verified-aria = Zweryfikowano
87+
captcha-required = Proszę potwierdzić, że jesteś człowiekiem
88+
captcha-error-aria = Wystąpił błąd, spróbuj ponownie
7789
book-confirm-button = Potwierdź rezerwację
7890
7991
# Shared labels used across the cancel / decline / approve / reschedule / claim flows

migrations/054_captcha.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE auth_config ADD COLUMN captcha_instance_url TEXT;
2+
ALTER TABLE auth_config ADD COLUMN captcha_site_key TEXT;
3+
ALTER TABLE auth_config ADD COLUMN captcha_secret TEXT;
4+
ALTER TABLE auth_config ADD COLUMN captcha_widget_url TEXT;

src/db.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ pub async fn migrate(pool: &SqlitePool) -> Result<()> {
231231
"053_oauth2_caldav",
232232
include_str!("../migrations/053_oauth2_caldav.sql"),
233233
),
234+
("054_captcha", include_str!("../migrations/054_captcha.sql")),
234235
];
235236

236237
let mut applied_count = 0u32;
@@ -838,7 +839,7 @@ mod tests {
838839
.fetch_one(&pool)
839840
.await
840841
.unwrap();
841-
assert_eq!(count.0, 53, "All 53 migrations should be tracked");
842+
assert_eq!(count.0, 54, "All 54 migrations should be tracked");
842843
}
843844

844845
#[tokio::test]
@@ -852,7 +853,7 @@ mod tests {
852853
.fetch_one(&pool)
853854
.await
854855
.unwrap();
855-
assert_eq!(count.0, 53, "Still 53 migrations after second run");
856+
assert_eq!(count.0, 54, "Still 54 migrations after second run");
856857
}
857858

858859
#[tokio::test]

0 commit comments

Comments
 (0)