Skip to content

Commit ae7b47d

Browse files
committed
Add optional Turnstile protection
1 parent 8447b00 commit ae7b47d

8 files changed

Lines changed: 136 additions & 19 deletions

File tree

docs/lessons-learned.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,4 @@ git diff --check
123123
- **Journey audit is graph audit plus outcome audit.** A journey section is healthy only when its examples form a prerequisite-respecting mental map and its declared outcomes are backed by cells on those examples. A section can have a beautiful figure and still fail if the support list is a catalog slice, if the `See also` graph isolates one example, or if the section caption describes a conceptual shift the examples do not actually make.
124124
- **A green total can still hide a weak criterion.** The journey-figure audit found every section figure above the project gate while 15 sections still reused a lesson paint function, which weakened the independence-from-lesson-figures dimension. Bespoke runtime, control-flow, iteration, types, and reliability section figures cleared that watchlist; keep tracking criterion-level weakness even when the aggregate score remains shippable.
125125
- **Deployment smoke belongs beside CI.** `scripts/smoke_deployment.py` checks rendered Worker pages, runtime-boundary pages, journey pages, prototype review pages, and representative Dynamic Worker POST runs for HTTP failures, exception markers, and stale edited-code output. Build success is not enough; the deployed Worker must render and execute edited examples.
126+
- **Turnstile should be secret-gated, not development-gated.** Protect edited-code POST runs only when `TURNSTILE_SECRET_KEY` is configured, render the widget only when `TURNSTILE_SITE_KEY` is present, and keep local/dev runs frictionless. If production smoke must POST through a protected endpoint, use a separate `PBE_SMOKE_BYPASS_SECRET` header so smoke remains a deployment check rather than a CAPTCHA solver.

scripts/lint_seo_cache.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def assert_asset_manifest(failures: list[str]) -> None:
6565
def assert_worker_cache_policy(failures: list[str]) -> None:
6666
source = (ROOT / "src" / "main.py").read_text()
6767
required = [
68-
"def html_cache_key_url(url: str) -> str:",
68+
"def html_cache_key_url(url: str, turnstile_site_key: str = \"\") -> str:",
6969
"__html_v={HTML_CACHE_VERSION}",
7070
"caches.default.match(cache_key)",
7171
"caches.default.put(cache_key, response.clone())",

scripts/smoke_deployment.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import argparse
10+
import os
1011
import sys
1112
import urllib.error
1213
import urllib.parse
@@ -39,15 +40,18 @@ def fetch(url: str) -> tuple[int, str]:
3940
return response.status, body
4041

4142

42-
def post_code(url: str, code: str) -> tuple[int, str]:
43+
def post_code(url: str, code: str, smoke_bypass_secret: str = "") -> tuple[int, str]:
4344
data = urllib.parse.urlencode({"code": code}).encode()
45+
headers = {
46+
"User-Agent": "pythonbyexample-smoke/1.0",
47+
"Content-Type": "application/x-www-form-urlencoded",
48+
}
49+
if smoke_bypass_secret:
50+
headers["x-pythonbyexample-smoke-secret"] = smoke_bypass_secret
4451
request = urllib.request.Request(
4552
url,
4653
data=data,
47-
headers={
48-
"User-Agent": "pythonbyexample-smoke/1.0",
49-
"Content-Type": "application/x-www-form-urlencoded",
50-
},
54+
headers=headers,
5155
method="POST",
5256
)
5357
with urllib.request.urlopen(request, timeout=30) as response:
@@ -91,11 +95,13 @@ def main() -> int:
9195
failures.append(f"{url}: rendered exception marker {marker!r}")
9296
print(f"GET {status} {url}")
9397

98+
smoke_bypass_secret = os.environ.get("PBE_SMOKE_BYPASS_SECRET", "")
99+
94100
if not args.skip_post:
95101
for slug, code, expected in POST_SMOKES:
96102
url = urljoin(base, f"examples/{slug}")
97103
try:
98-
status, body = post_code(url, code)
104+
status, body = post_code(url, code, smoke_bypass_secret)
99105
except urllib.error.HTTPError as exc:
100106
failures.append(f"POST {url}: HTTP {exc.code}")
101107
continue

src/app.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,17 @@ def _render_cell(step):
748748
return f'<section class="lesson-step lp-cell"><div class="lp-prose">{prose_html}</div><div class="cell-code-stack"><div class="cell-source"><p class="cell-label">Source</p><pre><code class="language-python">{source}</code></pre></div><div class="cell-output"><p class="cell-label">Output</p><pre><code>{html.escape(step["output"])}</code></pre></div></div></section>'
749749

750750

751-
def render_example_page(example, output=None, code=None, execution_time_ms=None):
751+
def _turnstile_widget(site_key: str | None) -> str:
752+
if not site_key:
753+
return ""
754+
escaped = html.escape(site_key)
755+
return (
756+
'<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>'
757+
f'<div class="cf-turnstile" data-sitekey="{escaped}"></div>'
758+
)
759+
760+
761+
def render_example_page(example, output=None, code=None, execution_time_ms=None, turnstile_site_key=None):
752762
notes = [render_inline(note) for note in example.get("notes", [])]
753763
walkthrough = _walkthrough_cells(example)
754764
shown_output = output if output is not None else example.get("expected_output", "Run this example to see output here.")
@@ -796,6 +806,7 @@ def render_example_page(example, output=None, code=None, execution_time_ms=None)
796806
"SLUG": html.escape(example["slug"]),
797807
"EDITOR_ROWS": str(max(18, editable_code.count("\n") + 2)),
798808
"EDITABLE_CODE": html.escape(editable_code),
809+
"TURNSTILE_WIDGET": _turnstile_widget(turnstile_site_key),
799810
"OUTPUT_PLACEHOLDER": " data-output-placeholder" if output is None else "",
800811
"OUTPUT_HEADING": html.escape(output_heading),
801812
"SHOWN_OUTPUT": html.escape(shown_output),
@@ -813,7 +824,7 @@ def render_example_page(example, output=None, code=None, execution_time_ms=None)
813824
)
814825

815826

816-
def route(url: str, method: str = "GET") -> AppResponse:
827+
def route(url: str, method: str = "GET", turnstile_site_key: str | None = None) -> AppResponse:
817828
without_scheme = url.split("://", 1)[-1]
818829
path_part = without_scheme.split("/", 1)[1] if "/" in without_scheme else ""
819830
path = ("/" + path_part.split("?", 1)[0]).rstrip("/") or "/"
@@ -850,6 +861,7 @@ def route(url: str, method: str = "GET") -> AppResponse:
850861
body = f'<h1>Example not found</h1><p class="meta">Try one of these nearby examples.</p><h2>Recommended examples</h2><ul>{recommendations}</ul>'
851862
return AppResponse(_layout("Not Found", body), status=404)
852863
return AppResponse(
853-
render_example_page(example), headers={"Content-Type": "text/html; charset=utf-8"}
864+
render_example_page(example, turnstile_site_key=turnstile_site_key),
865+
headers={"Content-Type": "text/html; charset=utf-8"},
854866
)
855867
return AppResponse(_layout("Not Found", "<h1>Not found</h1>"), status=404)

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 = '934a3a7aa7f5'
3+
HTML_CACHE_VERSION = '05479c782a32'

src/main.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hashlib
2+
import json
23
import time
3-
from urllib.parse import parse_qs, urlparse
4+
from urllib.parse import parse_qs, urlencode, urlparse
45

56
from fastapi import FastAPI, Request
67
from fastapi.responses import HTMLResponse, Response
@@ -12,8 +13,11 @@
1213

1314
import worker_asgi_bridge as asgi
1415

16+
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
17+
SMOKE_BYPASS_HEADER = "x-pythonbyexample-smoke-secret"
18+
1519
try:
16-
from js import Object, Request as JsRequest, caches
20+
from js import Object, Request as JsRequest, caches, fetch as js_fetch
1721
from pyodide.ffi import create_once_callable, jsnull, to_js
1822
except ImportError: # Allows editor tooling outside Workers.
1923
Object = None
@@ -22,6 +26,7 @@
2226
create_once_callable = None
2327
to_js = None
2428
caches = None
29+
js_fetch = None
2530

2631
app = FastAPI(title="Python By Example")
2732
_CURRENT_WORKER_REQUEST = None
@@ -48,9 +53,13 @@ def should_cache_get_url(url: str) -> bool:
4853
return not path.startswith("/layout-options/")
4954

5055

51-
def html_cache_key_url(url: str) -> str:
56+
def html_cache_key_url(url: str, turnstile_site_key: str = "") -> str:
5257
separator = "&" if "?" in url else "?"
53-
return f"{url}{separator}__html_v={HTML_CACHE_VERSION}"
58+
turnstile_fragment = ""
59+
if turnstile_site_key:
60+
digest = hashlib.sha256(turnstile_site_key.encode("utf-8")).hexdigest()[:8]
61+
turnstile_fragment = f"&__turnstile={digest}"
62+
return f"{url}{separator}__html_v={HTML_CACHE_VERSION}{turnstile_fragment}"
5463

5564

5665
@app.get("/favicon.svg")
@@ -64,9 +73,19 @@ async def home(request: Request):
6473
return _html(response.body, response.status)
6574

6675

76+
def _env_text(request: Request, name: str) -> str:
77+
env = request.scope.get("env")
78+
value = getattr(env, name, "") if env is not None else ""
79+
return value if isinstance(value, str) else ""
80+
81+
82+
def _turnstile_site_key(request: Request) -> str:
83+
return _env_text(request, "TURNSTILE_SITE_KEY")
84+
85+
6786
@app.get("/examples/{slug}", response_class=HTMLResponse)
6887
async def example_page(slug: str, request: Request):
69-
response = route(str(request.url), method="GET")
88+
response = route(str(request.url), method="GET", turnstile_site_key=_turnstile_site_key(request))
7089
return _html(response.body, response.status)
7190

7291

@@ -76,11 +95,66 @@ async def run_example(slug: str, request: Request):
7695
if example is None:
7796
return _html("<h1>Example not found</h1>", 404)
7897
body = (await request.body()).decode("utf-8")
79-
submitted = parse_qs(body).get("code", [example["code"]])[0]
98+
form = parse_qs(body)
99+
submitted = form.get("code", [example["code"]])[0]
100+
turnstile_token = form.get("cf-turnstile-response", [""])[0]
101+
ok, message = await _verify_turnstile(request, turnstile_token)
102+
if not ok:
103+
return _html(
104+
render_example_page(
105+
example,
106+
output=message,
107+
code=submitted,
108+
turnstile_site_key=_turnstile_site_key(request),
109+
)
110+
)
80111
started = time.perf_counter()
81112
output = await _run_example(request, example["slug"], submitted)
82113
elapsed_ms = (time.perf_counter() - started) * 1000
83-
return _html(render_example_page(example, output=output, code=submitted, execution_time_ms=elapsed_ms))
114+
return _html(
115+
render_example_page(
116+
example,
117+
output=output,
118+
code=submitted,
119+
execution_time_ms=elapsed_ms,
120+
turnstile_site_key=_turnstile_site_key(request),
121+
)
122+
)
123+
124+
125+
async def _verify_turnstile(request: Request, token: str) -> tuple[bool, str]:
126+
secret = _env_text(request, "TURNSTILE_SECRET_KEY")
127+
if not secret:
128+
return True, ""
129+
130+
bypass_secret = _env_text(request, "PBE_SMOKE_BYPASS_SECRET")
131+
if bypass_secret and request.headers.get(SMOKE_BYPASS_HEADER) == bypass_secret:
132+
return True, ""
133+
134+
if not token:
135+
return False, "Turnstile verification is required before running edited code. Please retry."
136+
if js_fetch is None or JsRequest is None:
137+
return False, "Turnstile verification is unavailable outside the Cloudflare runtime."
138+
139+
payload = {"secret": secret, "response": token}
140+
remote_ip = request.headers.get("CF-Connecting-IP")
141+
if remote_ip:
142+
payload["remoteip"] = remote_ip
143+
verify_request = JsRequest.new(
144+
TURNSTILE_VERIFY_URL,
145+
_to_js_object(
146+
{
147+
"method": "POST",
148+
"body": urlencode(payload),
149+
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
150+
}
151+
),
152+
)
153+
response = await js_fetch(verify_request)
154+
result = json.loads(await response.text())
155+
if result.get("success") is True:
156+
return True, ""
157+
return False, "Turnstile verification failed. Please refresh the challenge and try again."
84158

85159

86160
async def _run_example(request: Request, slug, code):
@@ -134,7 +208,8 @@ async def fetch(self, request):
134208
# POST requests and are intentionally never cached.
135209
if getattr(request, "method", None) == "GET" and caches is not None:
136210
if should_cache_get_url(getattr(request, "url", "")):
137-
cache_key = JsRequest.new(html_cache_key_url(getattr(request, "url", "")), _to_js_object({"method": "GET"}))
211+
turnstile_site_key = getattr(self.env, "TURNSTILE_SITE_KEY", "")
212+
cache_key = JsRequest.new(html_cache_key_url(getattr(request, "url", ""), turnstile_site_key), _to_js_object({"method": "GET"}))
138213
cached = await caches.default.match(cache_key)
139214
if cached:
140215
return cached

src/templates/example.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ <h2>Run the complete example</h2>
1616
<form class="runner-panel runner-editor" method="post" action="/examples/__SLUG__">
1717
<h3>Example code</h3>
1818
<textarea name="code" id="code-editor" spellcheck="false" rows="__EDITOR_ROWS__">__EDITABLE_CODE__</textarea>
19+
__TURNSTILE_WIDGET__
1920
<div class="playground-toolbar">
2021
<button class="button" type="submit">Run</button>
2122
<button class="tool-button" type="button" data-reset onclick="resetCode()">Reset</button>
@@ -52,6 +53,8 @@ <h3>Example code</h3>
5253
if (nextOutput) outputPanel.innerHTML = nextOutput.innerHTML;
5354
} catch (error) {
5455
outputPanel.querySelector('code').textContent = 'Run failed: ' + error.message;
56+
} finally {
57+
if (window.turnstile) turnstile.reset();
5558
}
5659
});
5760
const hash = new URL(window.location.href).hash;

tests/test_app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,26 @@ def test_ui_polish_principles_are_applied(self):
305305
self.assertNotIn('<article class="card"', home)
306306
self.assertIn('class="example-shell"', html)
307307

308+
def test_turnstile_widget_is_optional_and_form_bound(self):
309+
html = render_example_page(get_example("hello-world"))
310+
self.assertNotIn("cf-turnstile", html)
311+
self.assertNotIn("challenges.cloudflare.com/turnstile", html)
312+
313+
protected = render_example_page(get_example("hello-world"), turnstile_site_key="site-key-123")
314+
self.assertIn("https://challenges.cloudflare.com/turnstile/v0/api.js", protected)
315+
self.assertIn('class="cf-turnstile"', protected)
316+
self.assertIn('data-sitekey="site-key-123"', protected)
317+
self.assertIn("window.turnstile", protected)
318+
self.assertIn("turnstile.reset", protected)
319+
320+
def test_turnstile_verification_is_secret_gated_in_worker(self):
321+
main_source = (ROOT / "src" / "main.py").read_text()
322+
self.assertIn("TURNSTILE_SECRET_KEY", main_source)
323+
self.assertIn("cf-turnstile-response", main_source)
324+
self.assertIn("/turnstile/v0/siteverify", main_source)
325+
self.assertIn("PBE_SMOKE_BYPASS_SECRET", main_source)
326+
self.assertIn("x-pythonbyexample-smoke-secret", main_source)
327+
308328
def test_cf_workers_design_system_and_playground_lessons(self):
309329
html = render_example_page(get_example("hello-world"), output="hello world\n")
310330
css = (ROOT / "public" / "site.css").read_text()

0 commit comments

Comments
 (0)