4.9 KiB
+++ 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.
# 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:
wrangler secret put TURNSTILE_SECRET_KEY
# paste the value from `tofu output -raw turnstile_secret_key`
Validate:
# 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:
- Read
TURNSTILE_SECRET_KEYfrom the environment. - If the env var is set:
- Extract
cf_turnstile_tokenfrom the request body. - If the token is absent, return
400 Bad Request. - Call
verify_turnstile(token, secret, remote_ip). - If verification fails, return
403 Forbiddenwith{"error": "CAPTCHA verification failed"}.
- Extract
- 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:
# 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>:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
src/bin/ui/pages/submit.rs
- Add a
turnstile_token: UseStateHandle<Option<String>>state handle. - Add the Turnstile widget div in the form, before the submit button:
The<div class="cf-turnstile" data-sitekey="TURNSTILE_SITE_KEY_HERE" data-callback="turnstile_callback"> </div>data-callbackJS function name must be registered inwindow. Useweb_sys::window()andjs_sys::Functionto expose a Rust closure that setsturnstile_tokenstate. - Include the token in the
CreateQuoteInputsent 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:
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_turnstilesrc/bin/api/handlers/mod.rs—create_handlersrc/bin/ui/pages/submit.rs— widget embed + token stateindex.html— Turnstile JS scriptCargo.toml—reqwestdependency (non-wasm32)api/openapi.yaml— documentcf_turnstile_tokenfielddocs/LOCAL_DEV.md— note on local dev CAPTCHA bypass
Commit scope
feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit