You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
144 lines
4.9 KiB
Markdown
144 lines
4.9 KiB
Markdown
+++
|
|
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<bool, Error>` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
|
|
|
|
**`quotesdb::CreateQuoteInput` in `src/lib.rs`:**
|
|
|
|
Add a `cf_turnstile_token: Option<String>` 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 `<head>`:
|
|
```html
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
```
|
|
|
|
### `src/bin/ui/pages/submit.rs`
|
|
|
|
1. Add a `turnstile_token: UseStateHandle<Option<String>>` state handle.
|
|
2. Add the Turnstile widget div in the form, before the submit button:
|
|
```html
|
|
<div class="cf-turnstile"
|
|
data-sitekey="TURNSTILE_SITE_KEY_HERE"
|
|
data-callback="turnstile_callback">
|
|
</div>
|
|
```
|
|
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` |