Repository navigation
feat(agent): Turnstile on the public chat and site feedback - #12555
Merged
Merged
Conversation
Cloudflare Turnstile guards the calls that open or store something for an anonymous visitor: opening a public chat session, a public chat feedback case and a site feedback entry. api/turnstile.py redeems the X-Turnstile-Token at siteverify and fails closed; production keeps the public door shut without TURNSTILE_SECRET_KEY and refuses Cloudflare's test secrets, and the deploy smoke proves the secret with the dummy token. The page loads Cloudflare's script only for those calls, takes a fresh token per call and shows one calm line with a retry on failure. The site CSP allows challenges.cloudflare.com for scripts and frames, and /legal names what the check reads and stores. Also lowers AGENT_IP_DATASET_BYTES to 32 MiB and removes the unused public GET /agent/session/bundle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
The production check accepts only a token solved on anyplot.ai or a subdomain and only an HTTP 200 success; bad-request counts as a server fault. The public feedback route redeems the token before the hourly network cap, so a script without one cannot spend it. The browser check fails at once when Cloudflare renders no widget and gives an interactive wait a 300 s ceiling. The fixed slot keeps Cloudflare's 300 px width on a phone. A test makes every public state change take a token or an open session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Approval is blocked by two active moderate issues: test secrets can leave the public chat door open despite unusable sessions, and verifier logs disclose fields not covered by the stated privacy copy.
2 open issues
What changed in this PR
This pull request adds Cloudflare Turnstile protection for anonymous chat-session creation, agent feedback, plot reactions, and site feedback, with fail-closed API verification and lazy frontend widget integration.
Changes:
- Added server-side Turnstile verification, retries, hostname validation, deployment safeguards, and protected public routes.
- Added frontend token handling, retry messaging, widget integration, CSP updates, legal disclosures, and test coverage.
- Reduced the per-network dataset quota to 32 MiB and removed the unused public session-bundle route.
Open review findings:
- Moderate, 3 votes —
api/routers/agent_public.py: Production/agent/laneshould remain closed when configured with a Cloudflare testing secret, not only when the secret is empty. - Moderate, 3 votes —
api/turnstile.py: Logging the siteverify hostname conflicts with the legal statement that Anyplot learns only whether the check passed; logging or disclosure should be corrected. - Moderate, 1 vote —
app/src/components/FeedbackWidget.tsx: Handle503 bot_check_unavailableas the intended browser-check error rather than showing the generic send-failure message.
This pull request requires changes.
| File | Description |
|---|---|
tests/unit/test_cloudbuild_configs.py |
Verifies Turnstile secret bindings, smoke probes, and site-key build requirements. |
tests/unit/api/test_turnstile.py |
Tests token verification, retries, hostname checks, test secrets, and failure modes. |
tests/unit/api/test_routers.py |
Pins legal-page privacy language. |
tests/unit/api/test_preview_relay.py |
Updates expected CSP frame sources. |
tests/unit/api/test_feedback_router.py |
Tests feedback endpoint bot-check behavior. |
tests/unit/api/test_csp_policy.py |
Verifies Turnstile CSP scope. |
tests/unit/api/test_agent_public_router.py |
Tests public-route protection, token requirements, and bundle removal. |
tests/unit/agents/test_settings.py |
Updates the dataset-store network limit assertion. |
docs/reference/api.md |
Documents Turnstile behavior, endpoints, configuration, and failures. |
docs/concepts/agent-network.md |
Updates public-lane architecture and dataset-limit documentation. |
core/config.py |
Adds the normalized TURNSTILE_SECRET_KEY setting. |
changelog.d/public-chat-turnstile.md |
Records security, limit, and route changes. |
app/src/vite-env.d.ts |
Declares the frontend Turnstile site-key environment variable. |
app/src/sections/agent-chat/ResultFeedback.tsx |
Adds bot-check feedback messages. |
app/src/sections/agent-chat/ResultFeedback.test.tsx |
Tests retry behavior while preserving form content. |
app/src/sections/agent-chat/ReloadHint.tsx |
Adds retry links for failed session-opening checks. |
app/src/sections/agent-chat/messages.ts |
Adds public chat bot-check error text. |
app/src/pages/LegalPage.tsx |
Adds Turnstile processing and storage disclosures. |
app/src/pages/LegalPage.test.tsx |
Tests the new legal disclosures. |
app/src/pages/AgentChatPage.tsx |
Enables retry messaging during session opening. |
app/src/pages/AgentChatPage.public.test.tsx |
Tests public chat bot-check failures. |
app/src/lib/turnstile.ts |
Implements lazy widget loading, token rendering, cleanup, and timeouts. |
app/src/lib/turnstile.test.ts |
Tests the frontend Turnstile lifecycle and failure handling. |
app/src/lib/agent.ts |
Adds Turnstile headers to protected public-agent calls. |
app/src/lib/agent.test.ts |
Tests token usage across public and admin agent calls. |
app/src/hooks/useQuickReaction.ts |
Adds Turnstile tokens to plot votes. |
app/src/hooks/useQuickReaction.test.ts |
Tests vote token submission and refusal behavior. |
app/src/components/FeedbackWidget.tsx |
Adds Turnstile to site feedback submissions. |
app/src/components/FeedbackWidget.test.tsx |
Tests widget token rendering and failure messages. |
app/security-headers.conf |
Allows Cloudflare scripts and frames in the CSP. |
app/scripts/agent-bff-mock.mjs |
Adds mock Turnstile modes and public bundle-route behavior. |
app/Dockerfile |
Passes the Turnstile site key into the frontend build. |
app/cloudbuild.yaml |
Requires and forwards the frontend site key. |
api/turnstile.py |
Provides server-side Cloudflare verification and verdict mapping. |
api/routers/seo.py |
Mirrors the Turnstile disclosure in bot-facing legal content. |
api/routers/feedback.py |
Requires Turnstile before storing site feedback. |
api/routers/agent_public.py |
Protects public session creation and feedback, and removes public bundles. |
api/deploy_smoke_public.sh |
Adds end-to-end public-lane and dummy-token deployment probes. |
api/cloudbuild.yaml |
Binds the Turnstile secret and invokes the public smoke script. |
agents/README.md |
Updates the documented network dataset limit. |
agents/anyplot/settings.py |
Lowers the default per-network dataset allocation to 32 MiB. |
Review effort: Lite
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
A production revision given one of Cloudflare's test secrets now keeps the public door shut and refuses without asking siteverify, recognised by the secret's value as well as by siteverify's answer. The verifier no longer logs the hostname of a foreign token, so the legal line "anyplot learns only whether the check passed" holds for the logs too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
The deploy builds with the production widget's public site key (Managed, hostname anyplot.ai, pre-clearance off); tests and local checks keep Cloudflare's test keys. The legal page's storage sentence names Cloudflare Turnstile's own item, as the owner chose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8 # Conflicts: # api/routers/agent_public.py # app/src/lib/agent.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
appearance: 'interaction-only') now guards the three calls that open or store something for an anonymous visitor:POST /agent/sessions,POST /agent/session/feedbackandPOST /feedback(the site widget and the plot pages' 👍/👎, which share that endpoint).api/turnstile.pyredeemsX-Turnstile-Tokenat siteverify (5 s per attempt, one retry with one idempotency key) and fails closed:403 bot_check_failedfor a missing, refused, reused or foreign-host token,503 bot_check_unavailablewhen siteverify cannot answer or the secret is wrong. Chat messages and other session calls take no token. Closes feat(feedback): add Cloudflare Turnstile to feedback widget #7249.TURNSTILE_SECRET_KEYis set (bound with--update-secretson every API deploy). Unset, dev and tests skip the check; production refuses the three calls and keeps the public door shut (404on/agent/lane), like the origin gate. A Cloudflare test secret counts as no secret in production. The public-lane deploy probes moved toapi/deploy_smoke_public.sh(the inline step passed Cloud Build's 10,000-character limit) and now post Cloudflare's dummy token, expecting403.app/cloudbuild.yamlrefuses to build without_VITE_TURNSTILE_SITE_KEY.app/src/lib/turnstile.tsloads Cloudflare's script only on the first protected call (or when the feedback widget opens), renders one widget per call and removes it after the token; the admin lane skips it. CSP addshttps://challenges.cloudflare.comtoscript-srcandframe-srconly./legaland its bot mirror name what the check reads and stores.AGENT_IP_DATASET_BYTESdrops from 64 to 32 MiB, and the unused publicGET /agent/session/bundleis gone (the admin route stays).Plan
Week-1 hardening of the public agent chat (owner decision 2026-11-02).
Deploy order
All four prerequisites hold (2026-11-04); without any of them the merge would break chat sessions and feedback:
TURNSTILE_SECRET_KEYexists: version 1 enabled, user-managed replication in europe-west4, 35 characters, no trailing newline. Without it the API deploy would fail atgcloud run deployand the old revision would keep serving.anyplot-api@anyplot.iam.gserviceaccount.comhasroles/secretmanager.secretAccessoron it (verified by metadata)._VITE_TURNSTILE_SITE_KEYdefault inapp/cloudbuild.yaml:_VITE_TURNSTILE_SITE_KEY: '0x4AAAAAAFzWhQ0F4XjBVJV4'(public by design, like_VITE_API_URL). Leave the deploy-app trigger without its own value, or give it the same one: an empty override makes the build refuse to start. Tests and local checks keep Cloudflare's test keys.anyplot.ai(Cloudflare lets the widget's hostnames cover their subdomains), pre-clearance No: pre-clearance would set acf_clearancecookie, which/legalsays there is none of.Between the two deploys: once the API revision is promoted, tabs still running the old bundle get
403on opening a session, on feedback and on plot votes until they reload; their old copy shows its generic line ("the session could not be opened.", "Couldn't send"). After both deploys, one real-browser check is needed: open/agentonce (the real-token path, which no automated check can prove). Rollback is a revert; unbinding the secret shuts the public chat and refuses feedback, it never fails open.Decisions taken here (decide-and-document)
remoteip. Cloudflare documents it as optional and documents no check it enables; Cloudflare already sees the address in the widget and at the edge, so leaving it out keeps the address off one more request without weakening anything. One line to add if relayed tokens ever show up.actioncomparison. Whoever renders the widget picks the action, so comparing it refuses nothing a script could not mint; the page sets it for Cloudflare's per-action counts.anyplot.aior a subdomain; the dashboard's hostname list stays the first fence./feedbackanswers 403 rather than feat(feedback): add Cloudflare Turnstile to feedback widget #7249's silent 200. A visitor's check can fail for honest reasons, and the widget should then say so and offer to send again; the honeypot and link filter keep their silent 200.The owner approved both defaults on 2026-11-04: the
/legal"bot check" row and storage sentence, noremoteip, and the token on every/feedbackPOST including the plot votes, loaded on the first tap.Reviews
renderreturning no id would hang 30 s → fixed. Per-request httpx client → kept (same pattern asget_agent_client). Mock regex finding → false (subis/sessions/<sid>/feedbackthere).remoteiprationale → reworded; token before the feedback cap → done;successonly on HTTP 200,bad-requestas 503 → done; 300 s ceiling on an interactive wait → done; a test that every public state change takes a token or an open session → added; deploy prerequisites → this PR's Deploy order; no incident switch → documented indocs/reference/api.md./feedback's503to the bot-check line, since a503there can also be a database outage and the generic "Couldn't send — try again in a moment." fits both.Texts to read
Every visitor-facing string, before → after.
/legal, "what is processed" (new rowbot check): "when you send feedback, rate a plot or open the data assistant, Cloudflare Turnstile checks that a browser, not a script, is asking: it reads your IP address, user-agent and TLS fingerprint and keeps an item of its own in your browser's storage, with no cookie. anyplot learns only whether the check passed. Cloudflare also uses these signals, as its own controller, to improve the check."/legal, "what we do not do": "no cookies for visitors; the browser's storage keeps UI settings, your plot votes, a release number and the random ids named here." → "…your plot votes, a release number, the random ids named here and Cloudflare Turnstile's own item." (the owner's wording, 2026-11-04)/legal, "where it runs": row labelcdn / ddos→cdn / ddos / bot check.Bot mirror (
_LEGAL_BOT_BODY), new item: "Bot check: when you send feedback, rate a plot or open the data assistant, Cloudflare Turnstile checks that a browser, not a script, is asking. It reads your IP address, user-agent and TLS fingerprint and keeps an item of its own in your browser's storage, with no cookie; anyplot learns only whether the check passed. Cloudflare also uses these signals, as its own controller, to improve the check."Chat, the session could not open (new):
the browser check did not go through · try againandthe browser check is not available right now · try again(try againreloads; nothing is lost before a session exists).Chat, feedback form (new):
the browser check did not go through; send againandthe browser check is not available right now; send again in a minute(the form keeps what was typed).Site feedback widget (new): "The browser check didn't go through. Please send again." Unchanged otherwise: "Couldn't send — try again in a moment."
Cloudflare's own "Verify you are human" box appears only when Cloudflare asks a visitor to click.
Sources for the legal lines and the design: Turnstile Privacy Addendum (signals: client IP, TLS fingerprint, User-Agent, sitekey and origin; processor for bot protection, controller for improving detection; updated 2025-06-18), Server-side validation, CSP, Widget configurations, Testing keys. Measured in headless Chromium with the test site key: no cookie on any domain, nothing in anyplot.ai's storage, one
localStorageitemcf.turnstile.uin Cloudflare's own frame, stable across page loads.Test plan
uv run pytest tests/unit tests/integration -n auto: 26,681 passed after mergingorigin/main(new:tests/unit/api/test_turnstile.py; bot-check, door, bundle-removal and route-guard tests intest_agent_public_router.py;/feedbackbot-check tests; cloudbuild bindings, CSP breadth, 32 MiB default)api core), changelog fragment checkyarn lint,yarn fm:check,yarn type-check,yarn test(1,622 passed),yarn buildwith the production site keyapi/deploy_smoke_public.shpasses in development with the always-failing secret; in production mode it fails as intended with no secret (lane404) and with a test secret (measured as503 bot_check_unavailablebefore the Copilot fix; since 44cd368 the lane answers404for it, pinned by unit tests);/feedbackrefuses without a token and before any database query; the honeypot still answers 200; the agents stub saw no session openMOCK_TURNSTILE, Cloudflare test keys):/agentopens with a token, no layout shift, slot removed, 1440 and 390 px, light and dark; the landing page and plot pages load no Cloudflare script until a vote is tapped; the widget preloads on open and sends a token; the admin lane sends none; refusal lines as above; the forced-interaction key shows Cloudflare's box at the window foot and inside the popover, and a click finishes the callmain's policy blocksapi.jsARG/ENV VITE_TURNSTILE_SITE_KEY, whose name matches none of DL3064's patterns./agentonce with the real site keyhttps://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8