+++ title = "Cloudflare Turnstile CAPTCHA on quote submission" priority = 7 status = "done" ticket_type = "feature" dependencies = [] +++ ## Feature Add Cloudflare Turnstile CAPTCHA to protect the `PUT /api/quotes` endpoint (and the submit form in the UI) from bots and spam. This is a three-part change: infra, API, and UI. --- ## Part 1 — Infra: Turnstile widget resource Create `infra/turnstile.tf` with a `cloudflare_turnstile_widget` resource. ```hcl # Turnstile CAPTCHA widget protecting the quote submission form. # Provides a site_key (public, embedded in the UI) and secret_key # (private, used by the API to verify tokens server-side). resource "cloudflare_turnstile_widget" "submit" { account_id = var.cloudflare_account_id name = "quotesdb-submit" # "managed" mode: Turnstile decides whether to show a visible challenge. mode = "managed" # Restrict the widget to the production domain. domains = [var.domain] } output "turnstile_site_key" { description = "Turnstile site key — safe to embed in the UI." value = cloudflare_turnstile_widget.submit.id } output "turnstile_secret_key" { description = "Turnstile secret key — inject into Workers via wrangler secret." value = cloudflare_turnstile_widget.submit.secret sensitive = true } ``` The `var.domain` variable should already exist or be added alongside `var.cloudflare_account_id` in `providers.tf` / `variables.tf`. After `tofu apply`, inject the secret into the Worker: ```sh wrangler secret put TURNSTILE_SECRET_KEY # paste the value from `tofu output -raw turnstile_secret_key` ``` **Validate:** ```sh # From infra/ directory tofu validate tofu plan ``` --- ## Part 2 — API: Verify Turnstile token in create handler The API must verify the Turnstile token before creating a quote. ### Changes **`src/lib.rs` (or a new `turnstile` module in `src/bin/api/`):** Add a `verify_turnstile(token: &str, secret: &str, remote_ip: Option<&str>) -> Result` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`. **`quotesdb::CreateQuoteInput` in `src/lib.rs`:** Add a `cf_turnstile_token: Option` field. It is optional so that local/test environments can skip verification when no secret is configured. **`src/bin/api/handlers/mod.rs` — `create_handler`:** Before calling `repo.create_quote(input)`, check: 1. Read `TURNSTILE_SECRET_KEY` from the environment. 2. If the env var is set: - Extract `cf_turnstile_token` from the request body. - If the token is absent, return `400 Bad Request`. - Call `verify_turnstile(token, secret, remote_ip)`. - If verification fails, return `403 Forbidden` with `{"error": "CAPTCHA verification failed"}`. 3. If the env var is absent (local dev), skip verification. **HTTP client:** Add `reqwest` (with `default-features = false, features = ["json"]`) as a non-wasm32 dependency for the Turnstile API call. On wasm32 the create handler does not exist, so no conflict. **Important:** Strip `cf_turnstile_token` from the `CreateQuoteInput` before passing it to the repository — the DB doesn't store it. **Validation:** ```sh # From quotesdb/ root cargo fmt && cargo check && cargo clippy && cargo test ``` --- ## Part 3 — UI: Embed Turnstile widget in submit form ### `index.html` Add the Turnstile JS script tag to the ``: ```html ``` ### `src/bin/ui/pages/submit.rs` 1. Add a `turnstile_token: UseStateHandle>` state handle. 2. Add the Turnstile widget div in the form, before the submit button: ```html
``` The `data-callback` JS function name must be registered in `window`. Use `web_sys::window()` and `js_sys::Function` to expose a Rust closure that sets `turnstile_token` state. 3. Include the token in the `CreateQuoteInput` sent to the API. **Site key:** The Turnstile site key is public and safe to hardcode in the UI source. Retrieve it from `tofu output -raw turnstile_site_key` after applying infra. Add a note in `docs/LOCAL_DEV.md` that local dev skips CAPTCHA (no env var set). **Validation:** ```sh cargo fmt && cargo check && cargo clippy && cargo test trunk build ``` Manually verify: the submit form shows the Turnstile widget and submission is blocked if the challenge is not completed. --- ## Files touched - `infra/turnstile.tf` (new) - `src/lib.rs` — `CreateQuoteInput` + `verify_turnstile` - `src/bin/api/handlers/mod.rs` — `create_handler` - `src/bin/ui/pages/submit.rs` — widget embed + token state - `index.html` — Turnstile JS script - `Cargo.toml` — `reqwest` dependency (non-wasm32) - `api/openapi.yaml` — document `cf_turnstile_token` field - `docs/LOCAL_DEV.md` — note on local dev CAPTCHA bypass ## Commit scope `feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit`