From 8729ff1f59ba649d166363d44b5a46417600abc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:11:49 +0000 Subject: [PATCH 01/14] fix(turnstile): render the challenge in a frame on our own origin Turnstile validates the hostname of the document that RENDERS the challenge, not the top-level page. The script embed puts our tree inside the host's document, so every partner site not on the widget's Turnstile hostname list failed its captcha with 110200 and the visitor got the captcha-blocked screen. The free plan caps that list at 10 hostnames, 5 of which were already spent. The challenge now renders in a small frame served from /challenge.html on the widget's own origin, so Turnstile always sees sahajatlas.com whatever page hosts the widget. One hostname list covers every partner, with no per-site onboarding step and no SahajCloud change. - challenge.html + src/challenge.ts: a third Vite HTML input. Loads api.js under its own policy, renders the site key with the theme and language the parent gives it on the query string, and talks to its parent over postMessage. The reset lives in style ATTRIBUTES because every stylesheet in this build is rewritten to :where(.sy-atlas), which matches nothing here. - src/lib/turnstile-frame.ts: the protocol, shared by both sides of the frame rather than duplicated, so it cannot drift silently. - useTurnstile keeps its public shape. It appends the frame instead of calling turnstile.render, accepts only messages whose source is the frame's own contentWindow and whose origin is the frame's, and posts `reset` instead of calling turnstile.reset. - probeTurnstile mounts the frame in probe mode: the page hands back its handshake and renders nothing, so the eager check spends no solve. It now SEES a frame-src refusal, through securitypolicyviolation, which it could never see before; the hostname mismatch it also could not see is gone rather than unobserved. - CSP_ADVICE names our origin under frame-src, read off the frame URL so a preview and a dev server each name the host they need. - embed.smoke.test.ts crawls the challenge page's graph too: the site key moved there, and the loader's graph alone reads as production carrying no key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mg9rjYffj6R24HDkeuBGpj --- CHANGELOG.md | 17 + challenge.html | 32 ++ docs/embedding.md | 167 ++++---- docs/environment.md | 2 +- docs/testing.md | 2 +- src/challenge.ts | 126 ++++++ .../RegistrationForm.submit.test.tsx | 28 +- .../ReportIssueForm.submit.test.tsx | 33 +- src/hooks/use-turnstile-guard.test.tsx | 16 +- src/hooks/use-turnstile-guard.ts | 23 +- src/hooks/use-turnstile.test.tsx | 373 ++++++++++++++++++ src/hooks/use-turnstile.ts | 296 +++++++------- src/lib/turnstile-frame.test.ts | 141 +++++++ src/lib/turnstile-frame.ts | 141 +++++++ src/test-support/turnstile-frame.ts | 49 +++ .../CompactEmbedView.mount.test.tsx | 24 +- tests/smoke/embed.smoke.test.ts | 110 +++++- vite.config.ts | 4 + 18 files changed, 1302 insertions(+), 282 deletions(-) create mode 100644 challenge.html create mode 100644 src/challenge.ts create mode 100644 src/hooks/use-turnstile.test.tsx create mode 100644 src/lib/turnstile-frame.test.ts create mode 100644 src/lib/turnstile-frame.ts create mode 100644 src/test-support/turnstile-frame.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e55bd12..bb98d789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,22 @@ cover everything a host would notice since the widget was first deployed. ### Changed +- ⚠ **BREAKING: your CSP needs `frame-src https://sahajatlas.com`, and the three + `challenges.cloudflare.com` entries can go.** ([#343]) The captcha that gates every form now + renders in a small frame served from the widget's own origin, instead of inside your document. + So `challenges.cloudflare.com` leaves your `script-src`, `connect-src` and `frame-src`, and + the widget's origin joins `frame-src`. **A policy that does not allow our origin to be framed + shows the error screen**, exactly as a blocked Turnstile did before — and the console names + the directive. Leaving the old three in place breaks nothing; they are simply dead. + + Two things improve for you, and neither needs a change. **Onboarding a site needs no captcha + step at all**: Turnstile validates the hostname of the document that renders the challenge, so + every partner site used to need its hostname registered with us before a registration could be + sent — and the free plan caps that list at ten. It is our one hostname now, whatever page + hosts the widget. And **no script of Cloudflare's runs in your document any more**: the + requests to `challenges.cloudflare.com` are made by our frame, so your page's URL is no longer + the document asking. + - **A repeat embed report turns into at most one write in any 6 h window.** ([#160]) The widget still reports on every page load, and an unchanged report inside that window is answered without a write. This guide and the embedding guide had both published the window as one hour. @@ -844,6 +860,7 @@ must-revalidate`, pinned rather than left to the CDN default. The production dom [#312]: https://github.com/sydevs/SahajAtlasWeb/pull/312 [#320]: https://github.com/sydevs/SahajAtlasWeb/issues/320 [#326]: https://github.com/sydevs/SahajAtlasWeb/issues/326 +[#343]: https://github.com/sydevs/SahajAtlasWeb/issues/343 [#322]: https://github.com/sydevs/SahajAtlasWeb/issues/322 [#329]: https://github.com/sydevs/SahajAtlasWeb/pull/329 [#325]: https://github.com/sydevs/SahajAtlasWeb/issues/325 diff --git a/challenge.html b/challenge.html new file mode 100644 index 00000000..3e73b548 --- /dev/null +++ b/challenge.html @@ -0,0 +1,32 @@ + + + + + + + + + Security check + + +
+ + + diff --git a/docs/embedding.md b/docs/embedding.md index 0ee41d76..e71d1381 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -793,15 +793,20 @@ and `cloud.sydevelopers.com` is in `connect-src` already, for the widget's own r If your page sends a CSP, this is what the widget needs added to it. **It is a list of additions to your policy, not a policy** — your own sources still have to be in there. +**There is nothing to configure per site for the captcha.** Every form in the widget is behind a +Cloudflare Turnstile challenge, and that challenge is served, rendered and validated on the +widget's own origin. So onboarding a new site needs no key of yours, no account of yours, and no +hostname of yours registered with us. The `frame-src` line below is the whole of it. + ``` -script-src https://sahajatlas.com https://api.mapbox.com https://challenges.cloudflare.com https://cdn.usefathom.com +script-src https://sahajatlas.com https://api.mapbox.com https://cdn.usefathom.com worker-src blob: child-src blob: style-src 'unsafe-inline' font-src https://sahajatlas.com img-src data: https://api.mapbox.com https://imagedelivery.net https://cloud.sydevelopers.com https://react-circle-flags.pages.dev -connect-src https://cloud.sydevelopers.com https://api.mapbox.com https://events.mapbox.com https://ipwho.is https://challenges.cloudflare.com https://cdn.usefathom.com https://*.sentry.io -frame-src https://challenges.cloudflare.com +connect-src https://cloud.sydevelopers.com https://api.mapbox.com https://events.mapbox.com https://ipwho.is https://cdn.usefathom.com https://*.sentry.io +frame-src https://sahajatlas.com ``` **One line per directive, deliberately: CSP ignores every repeat of a directive name after the @@ -814,7 +819,6 @@ believing you allowed something you hadn't. | -------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `script-src` | the widget's origin | The ` - - diff --git a/docs/embedding.md b/docs/embedding.md index b186fb9c..e93a6976 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -239,6 +239,7 @@ Some things are **not** parameters, on purpose: | ---------------------------------- | ---------------------------------------------------------------------- | | Display name, brand colours | your client record | | The path prefix for `routing=path` | your client record — the same value your canonical URLs are built from | +| The domains you embed on | your client record — the API refuses reads from anywhere else, and the captcha renders nowhere else | Identity is the same on every page you embed on, so making it per-embed only risks two of your pages disagreeing about what the product is called. The routing prefix is worse than merely @@ -793,10 +794,13 @@ and `cloud.sydevelopers.com` is in `connect-src` already, for the widget's own r If your page sends a CSP, this is what the widget needs added to it. **It is a list of additions to your policy, not a policy** — your own sources still have to be in there. -**There is nothing to configure per site for the captcha.** Every form in the widget is behind a -Cloudflare Turnstile challenge, and that challenge is served, rendered and validated on the -widget's own origin. So onboarding a new site needs no key of yours, no account of yours, and no -hostname of yours registered with us. The `frame-src` line below is the whole of it. +**The captcha needs nothing of yours but a listed domain.** Every form in the widget is behind a +Cloudflare Turnstile challenge, and that challenge is served, rendered and validated by +`cloud.sydevelopers.com` — the origin the widget already reads its data from. So onboarding a +new site needs no key of yours and no account of yours. It renders only for a page on one of the +domains on your client record, the same list that already gates the API ([What you configure in +the CMS instead](#what-you-configure-in-the-cms-instead)), so tell us every domain you embed on. +The `frame-src` line below is the whole of your policy's part. ``` script-src https://sahajatlas.com https://api.mapbox.com https://cdn.usefathom.com @@ -806,7 +810,7 @@ style-src 'unsafe-inline' font-src https://sahajatlas.com img-src data: https://api.mapbox.com https://imagedelivery.net https://cloud.sydevelopers.com https://react-circle-flags.pages.dev connect-src https://cloud.sydevelopers.com https://api.mapbox.com https://events.mapbox.com https://ipwho.is https://cdn.usefathom.com https://*.sentry.io -frame-src https://sahajatlas.com +frame-src https://cloud.sydevelopers.com ``` **One line per directive, deliberately: CSP ignores every repeat of a directive name after the @@ -833,12 +837,12 @@ believing you allowed something you hadn't. | | `ipwho.is` | the once-per-session IP→city lookup behind "classes near you" | **degrades**: the nearby suggestion is skipped | | | `cdn.usefathom.com` | analytics, conditional as above | analytics only | | | `*.sentry.io` | crash reporting. Contacted **only after the widget has already failed**, and only on a build with a DSN. | **degrades**: the widget notices the refusal and stops trying for the rest of the page's life | -| `frame-src` | the widget's origin | **The captcha — required.** Every write the widget makes is captcha-gated, so registration is impossible without it. The challenge is Cloudflare Turnstile, and it renders in a small frame the widget serves from its own origin rather than in your document, so `challenges.cloudflare.com` belongs in no directive of yours. The frame is mounted as soon as the interface does, so a policy that omits this fails immediately rather than at the moment somebody registers. | **the widget shows an error screen and does not run.** Not a degradation — see the note below | +| `frame-src` | `cloud.sydevelopers.com` | **The captcha — required.** Every write the widget makes is captcha-gated, so registration is impossible without it. The challenge is Cloudflare Turnstile, and it renders in a small frame `cloud.sydevelopers.com` serves rather than in your document, so `challenges.cloudflare.com` belongs in no directive of yours. The frame is mounted as soon as the interface does, so a policy that omits this fails immediately rather than at the moment somebody registers. | **the widget shows an error screen and does not run.** Not a degradation — see the note below | Notes on that table: **Nothing of Cloudflare's runs in your document, and `'unsafe-eval'` is not wanted here.** The -challenge is a separate document on the widget's own origin, which loads Turnstile's `api.js` +challenge is a separate document on `cloud.sydevelopers.com`, which loads Turnstile's `api.js` under its own policy and nests Cloudflare's own challenge frame inside itself. So your `frame-src` above is the only directive of yours that reaches any of it. **Do not add `'unsafe-eval'`**, and do not add `challenges.cloudflare.com` — neither buys you anything, and @@ -849,8 +853,10 @@ is ours rather than yours. forced interactive challenge rendered in the host's own document was clicked through in six runs of six, Chromium 141.0.7390.37, on the policy of that day: the challenge reached its success state, the widget took the token, and Submit went from disabled to enabled. What has been -measured since is that the challenge reaches its success state in a nested frame on our origin -and that `frame-src` is what refuses it — not a full solve-and-submit on a partner site. Two +measured since is that the challenge reaches its success state in a nested frame on the widget's +origin, the arrangement before this one, and that `frame-src` is what refuses it — not a full +solve-and-submit on a partner site. The page `cloud.sydevelopers.com` serves now is the same +protocol on a different origin, and has not yet been watched solving in a browser. Two things have therefore never been covered in either arrangement: nothing was posted to the backend, so the measurement ends at the token rather than at an accepted submission, and map mode was not measured, for want of a Mapbox token. Tell us if a challenge will not solve on @@ -902,7 +908,7 @@ nothing else, allow these — each breaks the widget as a whole: | | | | ------------------------------------ | ----------------------------------- | | `script-src` the widget's origin | nothing renders | -| `frame-src` the widget's origin | the widget shows an error and stops | +| `frame-src cloud.sydevelopers.com` | the widget shows an error and stops | | `connect-src cloud.sydevelopers.com` | no data at all | | `worker-src blob:` | the map never renders | | `img-src data:` | the map renders with no pins | @@ -923,12 +929,13 @@ silently fail to sign up for one, with no reason for the page owner to suspect a wrong. Failing loudly is worse for five minutes and better thereafter. ⚠ **Which line that entry is has CHANGED.** It used to be `challenges.cloudflare.com`, under -`script-src`, `frame-src` and `connect-src`. It is now `https://sahajatlas.com` under -`frame-src` alone, and the three Cloudflare entries can go. A policy still carrying the old -three is not broken by this — they are simply dead — but one that never had `frame-src -https://sahajatlas.com` will show the error screen. The widget writes the specific directive it -needs to the browser console, so a developer looking at the page gets the answer without opening -this document. +`script-src`, `frame-src` and `connect-src`. It is now `https://cloud.sydevelopers.com` under +`frame-src` alone — the origin already in your `connect-src` — and the three Cloudflare entries +can go. A policy still carrying the old three is not broken by this — they are simply dead — but +one that never had `frame-src https://cloud.sydevelopers.com` will show the error screen. The +widget writes the fix to the browser console — the directive, or that your domain is not yet on +your client record — so a developer looking at the page gets the answer without opening this +document. Everything else degrades to exactly what its "Blocked ⇒" column says — a missing typeface, a search field that offers no places, missing photography, missing flags, no analytics, no @@ -960,7 +967,7 @@ an iframe, put them in its `allow` attribute as shown above. ### The captcha frame asks for six more, and needs none of them -The captcha renders in a frame on our own origin (see +The captcha renders in a frame on `cloud.sydevelopers.com` (see [Content-Security-Policy](#content-security-policy)), and that frame carries `allow="cross-origin-isolated; fullscreen; autoplay; keyboard-map; gamepad; xr-spatial-tracking"` — the same six Cloudflare's own Turnstile widget asks for one level @@ -1081,7 +1088,7 @@ Seven honest exceptions, none of them styling your content: - **A 1-pixel hidden `