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.

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.rscreate_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:

# 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

  1. Add a turnstile_token: UseStateHandle<Option<String>> state handle.
  2. Add the Turnstile widget div in the form, before the submit button:
    <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:

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.rsCreateQuoteInput + verify_turnstile
  • src/bin/api/handlers/mod.rscreate_handler
  • src/bin/ui/pages/submit.rs — widget embed + token state
  • index.html — Turnstile JS script
  • Cargo.tomlreqwest 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