Skip to content

Commit 16da978

Browse files
committed
Harden Turnstile runner protection
1 parent 21c666d commit 16da978

7 files changed

Lines changed: 139 additions & 42 deletions

File tree

docs/turnstile-runner-protection-spec.md

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ Grounding docs:
1010
- Turnstile client-side rendering: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/>
1111
- Turnstile server-side validation / Siteverify: <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/>
1212
- Turnstile widget concepts and appearance options: <https://developers.cloudflare.com/turnstile/concepts/widget/>
13-
- Cloudflare Managed Challenge: <https://developers.cloudflare.com/cloudflare-challenges/challenge-types/managed-challenge/>
13+
- Turnstile widget configuration options: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/>
14+
- Cloudflare challenge pages and Managed Challenges: <https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/>
15+
- Ruleset Engine actions, including Managed Challenge and Block: <https://developers.cloudflare.com/ruleset-engine/rules-language/actions/>
1416
- WAF custom rules: <https://developers.cloudflare.com/waf/custom-rules/>
1517
- WAF rate limiting rules: <https://developers.cloudflare.com/waf/rate-limiting-rules/>
18+
- WAF rate limiting parameters: <https://developers.cloudflare.com/waf/rate-limiting-rules/parameters/>
19+
- WAF rate limiting request-rate calculation: <https://developers.cloudflare.com/waf/rate-limiting-rules/request-rate/>
20+
- WAF rate limiting dashboard setup: <https://developers.cloudflare.com/waf/rate-limiting-rules/create-zone-dashboard/>
1621
- Ruleset Engine fields and expressions: <https://developers.cloudflare.com/ruleset-engine/rules-language/fields/reference/>
1722
- Workers secrets: <https://developers.cloudflare.com/workers/configuration/secrets/>
1823
- Workers environment variables: <https://developers.cloudflare.com/workers/configuration/environment-variables/>
@@ -37,8 +42,8 @@ Protection desired path:
3742

3843
```text
3944
normal learner → no Turnstile visible, no Siteverify call
40-
new/suspicious/high-rate session → temporary invisible Turnstile → clearance cookie → many normal runs
41-
high-volume abuse → Cloudflare Rate Limiting / Managed Challenge before Worker execution
45+
new/suspicious session → temporary Invisible-mode Turnstile → clearance cookie → many normal runs
46+
high-volume abuse → Cloudflare Rate Limiting Block/429 before Worker execution
4247
```
4348

4449
## Current repository implementation
@@ -91,7 +96,7 @@ If `TURNSTILE_CHALLENGE_MODE=session` and both site/secret keys are configured:
9196
1. A browser without valid clearance posts edited code.
9297
2. The server returns the example page with a hidden `data-turnstile-required` marker and does **not** create a Dynamic Worker.
9398
3. Client JS lazily loads Turnstile using explicit rendering.
94-
4. It renders an invisible Turnstile widget only for that challenge.
99+
4. It renders the configured Invisible-mode Turnstile widget only for that challenge.
95100
5. The Turnstile callback provides a token.
96101
6. The client removes the widget and retries the POST with `cf-turnstile-response`.
97102
7. The Worker validates the token through Siteverify.
@@ -144,7 +149,7 @@ x-pythonbyexample-smoke-secret: <secret>
144149

145150
- Turnstile is not visible on initial page load.
146151
- The Turnstile script is not loaded until a challenge-required response is received.
147-
- The rendered widget uses invisible mode.
152+
- The Turnstile widget should be configured in Cloudflare as **Invisible** mode. Client code uses explicit rendering with `execution: "execute"`; `size: "invisible"` is not a valid current Turnstile size option.
148153
- The widget is removed after callback success or failure.
149154
- Siteverify is called only when a challenge-required request retries with a token.
150155
- A valid challenge creates a signed, HttpOnly, Secure, SameSite=Lax clearance cookie scoped to `/examples`.
@@ -176,47 +181,100 @@ This is only an app-level bypass. If Cloudflare Rate Limiting challenges smoke t
176181

177182
## Recommended Cloudflare layer: Rate Limiting Rules
178183

179-
The best first production protection for Dynamic Worker cost is Cloudflare Rate Limiting, because it runs before the Worker and directly targets repeated POST runs.
184+
The best first production protection for Dynamic Worker cost is Cloudflare Rate Limiting, because it runs before the Worker and limits repeated runner traffic at the edge.
180185

181-
Docs: <https://developers.cloudflare.com/waf/rate-limiting-rules/>
186+
Docs:
182187

183-
Suggested rule:
188+
- Overview: <https://developers.cloudflare.com/waf/rate-limiting-rules/>
189+
- Parameters and plan limits: <https://developers.cloudflare.com/waf/rate-limiting-rules/parameters/>
190+
- Request-rate calculation: <https://developers.cloudflare.com/waf/rate-limiting-rules/request-rate/>
191+
- Dashboard setup: <https://developers.cloudflare.com/waf/rate-limiting-rules/create-zone-dashboard/>
192+
193+
Important Cloudflare details that affect this project:
194+
195+
- Rate limit counters are kept per unique combination of configured characteristics, and Cloudflare implicitly includes the data-center ID (`cf.colo.id`). They are not global counters across the whole Cloudflare network.
196+
- Available expression fields, characteristics, counting periods, mitigation periods, and rule counts vary by Cloudflare plan.
197+
- Per Cloudflare's availability table, matching on HTTP method in a rate limiting rule expression is available on Business and Enterprise plans, not Free/Pro.
198+
- On Free/Pro/Business, challenge actions (`managed_challenge`, `js_challenge`, `challenge`) use request throttling; you do not configure a mitigation duration. After a visitor passes the challenge, that request counter is reset. Enterprise can configure challenge-action duration.
199+
- Cloudflare Challenge Pages interrupt the request flow and are not ideal for AJAX/fetch API-style calls. The runner POST is submitted by browser JavaScript, so a `Block` action with a `429` response is more predictable for high-rate POST abuse than a Managed Challenge on the POST itself. Use app-level Turnstile, or Cloudflare Turnstile Pre-clearance, when the desired behavior is browser verification rather than a hard edge cap.
200+
201+
### Business/Enterprise precise POST rule
202+
203+
If the zone plan supports the `http.request.method` field in rate limiting expressions, use this precise rule:
184204

185205
```text
186206
Rule name:
187-
Protect Python By Example runner
207+
Protect Python By Example runner POSTs
188208
189-
Expression:
209+
When incoming requests match:
190210
http.request.method eq "POST"
191211
and starts_with(http.request.uri.path, "/examples/")
192212
193-
Threshold:
194-
30 requests / 60 seconds / IP
213+
With the same characteristics:
214+
IP
215+
# or IP with NAT support, if available and classroom/shared-network false positives matter
195216
196-
Action:
197-
Managed Challenge
217+
When rate exceeds:
218+
30 requests / 60 seconds
198219
199-
Mitigation timeout:
220+
Then take action:
221+
Block
222+
223+
Response code:
224+
429
225+
226+
Duration:
200227
5 minutes
201228
```
202229

203230
For classrooms/workshops behind one NAT, start higher:
204231

205232
```text
206-
100 requests / 60 seconds / IP
233+
100 requests / 60 seconds / IP or IP-with-NAT-support bucket
234+
```
235+
236+
### Free/Pro fallback rule
237+
238+
If the zone plan does not allow `http.request.method` in rate limiting expressions, do **not** use the POST-only expression above. Use a path-only rule and set the threshold high enough that normal page reading is unaffected:
239+
240+
```text
241+
Rule name:
242+
Protect Python By Example examples path
243+
244+
When incoming requests match:
245+
starts_with(http.request.uri.path, "/examples/")
246+
247+
With the same characteristics:
248+
IP
249+
250+
Then take action:
251+
Block
252+
253+
Response code:
254+
429
255+
```
256+
257+
Plan-aware starting points:
258+
259+
```text
260+
Free: 20 requests / 10 seconds / IP, 10-second mitigation
261+
Pro: 120 requests / 60 seconds / IP, 5-minute mitigation
207262
```
208263

264+
Because this fallback counts both example page GETs and runner POSTs, tune it from Cloudflare Security Events before lowering thresholds.
265+
209266
Why this is the recommended first layer:
210267

211268
- it runs before Worker execution
212269
- normal learners do not see Turnstile
213270
- no app-side state is needed
214-
- it protects the precise expensive path
215-
- it is easier to reason about than broad bot-risk rules
271+
- Business/Enterprise can target the precise expensive POST path
272+
- Free/Pro can still provide a coarse emergency brake on the `/examples/` path
216273

217274
Tradeoffs:
218275

219276
- IP-based thresholds can false-positive shared networks
277+
- Free/Pro path-only rules count GETs as well as POSTs
220278
- distributed abuse can evade a per-IP rule
221279
- rule configuration lives in Cloudflare unless managed through API/Terraform
222280
- smoke can be affected if repeated many times in a short window
@@ -227,11 +285,10 @@ Docs: <https://developers.cloudflare.com/waf/custom-rules/>
227285

228286
WAF rules are a good second layer for risky-looking traffic, for example low reputation, suspicious user agents, or bot-management signals if available on the Cloudflare plan.
229287

230-
Shape:
288+
For top-level HTML page requests, a Managed Challenge custom rule can be appropriate:
231289

232290
```text
233-
http.request.method eq "POST"
234-
and starts_with(http.request.uri.path, "/examples/")
291+
starts_with(http.request.uri.path, "/examples/")
235292
and <risk signal>
236293
```
237294

@@ -241,7 +298,13 @@ Action:
241298
Managed Challenge
242299
```
243300

244-
Managed Challenge docs: <https://developers.cloudflare.com/cloudflare-challenges/challenge-types/managed-challenge/>
301+
Managed Challenge docs: <https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/#managed-challenges>
302+
303+
For the AJAX/fetch runner endpoint itself, avoid returning a Challenge Page directly to the POST request. Cloudflare documents that Challenge Pages can fail when the browser expects a non-HTML AJAX/XHR/fetch response. Prefer one of these instead:
304+
305+
- app-level Turnstile, as implemented here
306+
- Cloudflare Turnstile Pre-clearance if integrating WAF challenges with API requests
307+
- `Block` / `429` for hard abuse caps
245308

246309
Use WAF custom rules for risk filtering. Use Rate Limiting for volume control.
247310

@@ -295,10 +358,18 @@ Recommended settings:
295358
```text
296359
Name: pythonbyexample-production
297360
Hostname: www.pythonbyexample.dev
298-
Mode: Managed or Invisible
361+
Mode: Invisible
299362
```
300363

301-
Docs: <https://developers.cloudflare.com/turnstile/>
364+
Docs:
365+
366+
- Turnstile overview: <https://developers.cloudflare.com/turnstile/>
367+
- Widget modes: <https://developers.cloudflare.com/turnstile/concepts/widget/>
368+
- Widget configuration options: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/>
369+
370+
Invisible mode note: Cloudflare documents that invisible widgets have no visual footprint, and that widget `size` values are `normal`, `flexible`, or `compact`; invisibility is selected by widget mode, not by `size="invisible"`.
371+
372+
Privacy note: Cloudflare documents that using Invisible mode requires referencing the Cloudflare Turnstile Privacy Addendum in your own privacy policy.
302373

303374
Copy:
304375

@@ -341,25 +412,24 @@ Do **not** put secret keys in `wrangler.jsonc`.
341412
Cloudflare Dashboard:
342413

343414
```text
344-
Security → WAF → Rate limiting rules → Create rule
415+
Security rules → Create rule → Rate limiting rule
345416
```
346417

347-
Docs: <https://developers.cloudflare.com/waf/rate-limiting-rules/>
348-
349-
Expression:
418+
or, in the WAF dashboard:
350419

351420
```text
352-
http.request.method eq "POST"
353-
and starts_with(http.request.uri.path, "/examples/")
421+
Security → WAF → Rate limiting rules → Create rule
354422
```
355423

356-
Start with:
424+
Docs: <https://developers.cloudflare.com/waf/rate-limiting-rules/create-zone-dashboard/>
357425

358-
```text
359-
30 requests / 60 seconds / IP → Managed Challenge for 5 minutes
360-
```
426+
Use the plan-aware guidance above:
427+
428+
- Business/Enterprise: match `POST` + `/examples/`, start at `30 requests / 60 seconds`, action `Block`, response `429`, duration `5 minutes`.
429+
- Classroom/shared NAT: start around `100 requests / 60 seconds` or use `IP with NAT support` if available.
430+
- Free/Pro: if method matching is unavailable, use a path-only `/examples/` rule with a higher threshold because it also counts GET page views.
361431

362-
Raise the threshold if teaching cohorts behind a shared IP hit it.
432+
Do not configure `Managed Challenge for 5 minutes` on Free/Pro/Business rate limiting rules. Cloudflare documents that challenge actions on those plans use request throttling and have no selected duration; only Enterprise can configure a challenge-action duration. For this AJAX/fetch POST endpoint, a predictable `Block`/`429` edge cap is preferable.
363433

364434
### 4. Deploy
365435

@@ -423,7 +493,7 @@ Browser behavior:
423493

424494
1. click Run
425495
2. challenge is requested
426-
3. invisible Turnstile runs or displays only if Cloudflare needs interaction
496+
3. Invisible-mode Turnstile runs without page-load widget furniture
427497
4. widget disappears after callback
428498
5. run retries and produces output
429499
6. later runs in the same clearance window skip Turnstile

src/asset_manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Generated by scripts/fingerprint_assets.py. Do not edit by hand.
22
ASSET_PATHS = {'SITE_CSS': '/site.57a55415849b.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.a4a7766e1b9b.js'}
3-
HTML_CACHE_VERSION = '1230b7df07a7'
3+
HTML_CACHE_VERSION = '1d55ad790d2b'

src/main.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,19 @@ async def _run_example(request: Request, slug, code):
332332
"env": {"PYTHON_VERSION": PYTHON_VERSION, "EXAMPLE_SLUG": slug},
333333
}
334334
code_callback = None
335+
code_callback_used = False
335336
try:
336337
env = request.scope["env"]
337338
code_hash = hashlib.sha256(code.encode("utf-8")).hexdigest()
338339
worker_id = f"pythonbyexample:{PYTHON_VERSION}:{slug}:{code_hash}"
339340
js_worker_code = _to_js_object(worker_code)
340-
code_callback = create_once_callable(lambda: js_worker_code)
341+
342+
def provide_worker_code():
343+
nonlocal code_callback_used
344+
code_callback_used = True
345+
return js_worker_code
346+
347+
code_callback = create_once_callable(provide_worker_code)
341348
worker = env.LOADER.get(worker_id, code_callback)
342349
entrypoint = worker.getEntrypoint()
343350
dynamic_request = JsRequest.new(
@@ -369,7 +376,10 @@ async def _run_example(request: Request, slug, code):
369376
event["level"] = "error"
370377
raise
371378
finally:
372-
if code_callback is not None and hasattr(code_callback, "destroy"):
379+
# create_once_callable destroys itself after the Dynamic Loader invokes it.
380+
# Destroy only unused callbacks, such as cache-hit callbacks, to avoid
381+
# false cleanup errors like "OnceProxy has already been destroyed".
382+
if code_callback is not None and not code_callback_used and hasattr(code_callback, "destroy"):
373383
try:
374384
code_callback.destroy()
375385
except Exception as exc:

src/templates/example.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ <h3>Example code</h3>
6969
challengeBox.innerHTML = '';
7070
turnstileWidgetId = turnstile.render(challengeBox, {
7171
sitekey: challengeBox.dataset.turnstileSitekey,
72-
size: 'invisible',
72+
execution: 'execute',
7373
callback: (token) => { removeTurnstile(); resolve(token); },
7474
'error-callback': () => { removeTurnstile(); reject(new Error('Turnstile challenge failed')); },
7575
'expired-callback': () => { removeTurnstile(); reject(new Error('Turnstile challenge expired')); }

tests/test_app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,8 @@ def test_turnstile_widget_is_conditional_and_temporary(self):
314314
self.assertIn('data-turnstile-sitekey="site-key-123"', protected)
315315
self.assertIn("https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", protected)
316316
self.assertIn("turnstile.render", protected)
317-
self.assertIn("size: 'invisible'", protected)
317+
self.assertIn("execution: 'execute'", protected)
318+
self.assertNotIn("size: 'invisible'", protected)
318319
self.assertIn("turnstile.remove", protected)
319320
self.assertNotIn('class="cf-turnstile"', protected)
320321

@@ -446,6 +447,8 @@ def test_dynamic_worker_execution_uses_hash_keyed_get_cache(self):
446447
self.assertIn('worker_id = f"pythonbyexample:{PYTHON_VERSION}:{slug}:{code_hash}"', main_source)
447448
self.assertIn("env.LOADER.get(worker_id", main_source)
448449
self.assertIn("create_once_callable", main_source)
450+
self.assertIn("code_callback_used = True", main_source)
451+
self.assertIn('if code_callback is not None and not code_callback_used and hasattr(code_callback, "destroy")', main_source)
449452
self.assertIn('module_name = "runner.py"', main_source)
450453
self.assertIn("python_from_rpc", main_source)
451454
self.assertIn("dynamic_request = JsRequest.new(", main_source)

tests/test_observability.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,17 @@ def test_invocation_logs_are_disabled_for_single_custom_event_per_request(self):
326326
self.assertEqual(config["observability"]["head_sampling_rate"], 1)
327327
self.assertEqual(config["observability"]["logs"], {"invocation_logs": False})
328328
self.assertEqual(config["version_metadata"], {"binding": "CF_VERSION_METADATA"})
329-
self.assertNotIn("vars", config)
329+
self.assertEqual(
330+
config.get("vars"),
331+
{
332+
"TURNSTILE_CHALLENGE_MODE": "session",
333+
"TURNSTILE_CLEARANCE_SECONDS": "28800",
334+
},
335+
)
336+
self.assertNotIn("TURNSTILE_SITE_KEY", config.get("vars", {}))
337+
self.assertNotIn("TURNSTILE_SECRET_KEY", config.get("vars", {}))
338+
self.assertNotIn("TURNSTILE_CLEARANCE_SECRET", config.get("vars", {}))
339+
self.assertNotIn("PBE_SMOKE_BYPASS_SECRET", config.get("vars", {}))
330340

331341

332342
class WideEventMiddlewareTests(unittest.TestCase):

wrangler.jsonc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"version_metadata": {
2929
"binding": "CF_VERSION_METADATA"
3030
},
31+
"vars": {
32+
"TURNSTILE_CHALLENGE_MODE": "session",
33+
"TURNSTILE_CLEARANCE_SECONDS": "28800"
34+
},
3135
"assets": {
3236
"directory": "./public"
3337
},

0 commit comments

Comments
 (0)