From 709a978da5646f88ee25cd5e2c50737df1c871aa Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Sat, 24 Oct 2026 12:05:51 +0200 Subject: [PATCH] feat(web): add Cloudflare Turnstile to the public forms Render the widget on the contact, event registration and both application forms, and verify the token with Siteverify in each action before anything is written to the CMS. Verification fails closed. The check is off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. Allow challenges.cloudflare.com in script-src and frame-src. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 5 + README.md | 1 + apps/web/.env.example | 12 ++ .../app/components/ui/Turnstile/Turnstile.tsx | 187 ++++++++++++++++++ apps/web/app/components/ui/Turnstile/index.ts | 2 + apps/web/app/entry.server.tsx | 4 +- apps/web/app/i18n/messages/ar.json | 2 + apps/web/app/i18n/messages/en.json | 2 + apps/web/app/lib/env.server.ts | 23 +++ apps/web/app/lib/turnstile.server.ts | 81 ++++++++ apps/web/app/root.tsx | 5 +- .../app/routes/_events-opportunities/forms.ts | 3 +- .../applications-and-opportunities.$slug.tsx | 43 +++- .../routes/applications-and-opportunities.tsx | 50 ++++- apps/web/app/routes/contact.tsx | 20 ++ apps/web/app/routes/events-calendar.$slug.tsx | 47 ++++- docker-compose.yml | 3 + 17 files changed, 475 insertions(+), 15 deletions(-) create mode 100644 apps/web/app/components/ui/Turnstile/Turnstile.tsx create mode 100644 apps/web/app/components/ui/Turnstile/index.ts create mode 100644 apps/web/app/lib/turnstile.server.ts diff --git a/.env.example b/.env.example index cd5f577..c7fe0fb 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,11 @@ DOCKER_STRAPI_URL=http://cms:1337 # Settings -> API Tokens, then paste the value here (it is shown once). STRAPI_TOKEN= +# Cloudflare Turnstile on the public forms. Both keys or neither: with either +# one empty, the forms are not checked. See apps/web/.env.example. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + # --------------------------------------------------------------------------- # CMS RUNTIME diff --git a/README.md b/README.md index 446aadd..905b054 100644 --- a/README.md +++ b/README.md @@ -929,6 +929,7 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly | `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it | | `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server | | `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public | +| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | web | Cloudflare Turnstile on the public forms. Set both or neither — with either empty, no widget renders and submissions are not checked | | `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links | | `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins | | `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted | diff --git a/apps/web/.env.example b/apps/web/.env.example index 74ddf7a..1fa060a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -45,3 +45,15 @@ STRAPI_URL=http://localhost:1337 # Optional Strapi API token. Only needed to read draft or otherwise protected # content; published content is public. Settings -> API Tokens in the admin. STRAPI_TOKEN= + +# --- Bot protection -------------------------------------------------------- + +# Cloudflare Turnstile on the public forms (contact, event registration and +# both application forms). Both keys or neither: with either one empty, no +# widget renders and submissions are not checked. The site key is public and +# reaches the browser; the secret key never does. +# Cloudflare dashboard -> Turnstile -> your widget. For local testing, +# Cloudflare's always-pass pair is 1x00000000000000000000AA (site) and +# 1x0000000000000000000000000000000AA (secret). +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= diff --git a/apps/web/app/components/ui/Turnstile/Turnstile.tsx b/apps/web/app/components/ui/Turnstile/Turnstile.tsx new file mode 100644 index 0000000..7989c39 --- /dev/null +++ b/apps/web/app/components/ui/Turnstile/Turnstile.tsx @@ -0,0 +1,187 @@ +/* ============================================================================ + TURNSTILE - Cloudflare's bot check on the public forms + ---------------------------------------------------------------------------- + Rendered INSIDE a
: the widget injects a hidden `cf-turnstile-response` + input into its container, so the token travels with the other fields and + the route's action checks it with `verifyTurnstile` (~/lib/turnstile.server). + + The site key is read off the root loader. Without one the component renders + nothing and the server skips the check, so an unconfigured deploy keeps + working exactly as before. + + EXPLICIT RENDERING, NOT THE `cf-turnstile` CLASS + Implicit rendering scans the DOM once, when api.js loads - a form reached by + a client-side navigation would never get a widget. Rendering from an effect + ties the widget to the component instead, and `remove` on unmount keeps + Cloudflare's iframes from piling up across navigations. + + THE SCRIPT AND THE CSP + api.js is appended from here, once per page, the first time a form mounts - + so pages without a form never load it. The CSP (entry.server.tsx) admits it + through 'strict-dynamic', because the bundle that appends it is nonced; the + widget's iframe needs `frame-src https://challenges.cloudflare.com`. + + SCRIPTING OFF + The check cannot run without JavaScript, so the forms that otherwise work + as plain posts cannot pass it. The