Skip to content

feat(agent): Turnstile on the public chat and site feedback - #12555

Merged
MarkusNeusinger merged 8 commits into
mainfrom
feat/public-chat-turnstile
Nov 4, 2026
Merged

MarkusNeusinger merged 8 commits into
mainfrom
feat/public-chat-turnstile

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Bot check. Cloudflare Turnstile (Managed, appearance: 'interaction-only') now guards the three calls that open or store something for an anonymous visitor: POST /agent/sessions, POST /agent/session/feedback and POST /feedback (the site widget and the plot pages' 👍/👎, which share that endpoint). api/turnstile.py redeems X-Turnstile-Token at siteverify (5 s per attempt, one retry with one idempotency key) and fails closed: 403 bot_check_failed for a missing, refused, reused or foreign-host token, 503 bot_check_unavailable when 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.
  • Rollout. On while TURNSTILE_SECRET_KEY is set (bound with --update-secrets on every API deploy). Unset, dev and tests skip the check; production refuses the three calls and keeps the public door shut (404 on /agent/lane), like the origin gate. A Cloudflare test secret counts as no secret in production. The public-lane deploy probes moved to api/deploy_smoke_public.sh (the inline step passed Cloud Build's 10,000-character limit) and now post Cloudflare's dummy token, expecting 403. app/cloudbuild.yaml refuses to build without _VITE_TURNSTILE_SITE_KEY.
  • Frontend, CSP, legal, cleanup. app/src/lib/turnstile.ts loads 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 adds https://challenges.cloudflare.com to script-src and frame-src only. /legal and its bot mirror name what the check reads and stores. AGENT_IP_DATASET_BYTES drops from 64 to 32 MiB, and the unused public GET /agent/session/bundle is 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:

  1. Done 2026-11-04. Secret Manager TURNSTILE_SECRET_KEY exists: version 1 enabled, user-managed replication in europe-west4, 35 characters, no trailing newline. Without it the API deploy would fail at gcloud run deploy and the old revision would keep serving.
  2. Done 2026-11-04. anyplot-api@anyplot.iam.gserviceaccount.com has roles/secretmanager.secretAccessor on it (verified by metadata).
  3. Done in this PR. The site key is the _VITE_TURNSTILE_SITE_KEY default in app/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.
  4. Done 2026-11-04. The Cloudflare widget: Managed mode, hostname anyplot.ai (Cloudflare lets the widget's hostnames cover their subdomains), pre-clearance No: pre-clearance would set a cf_clearance cookie, which /legal says there is none of.

Between the two deploys: once the API revision is promoted, tabs still running the old bundle get 403 on 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 /agent once (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)

  • No 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.
  • No action comparison. 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.
  • Production hostname check. A passing token must have been solved on anyplot.ai or a subdomain; the dashboard's hostname list stays the first fence.
  • /feedback answers 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 plot votes need a token too. Same endpoint as the widget, indistinguishable server-side; the script loads on the first tap, never with the page.
  • Public feedback: token before the hourly network cap, so a script without one cannot spend a shared network's cases.
  • The widget slot is fixed at the window foot (no layout shift; Cloudflare's 300 px box kept on a phone), and inside the feedback popover for MUI's focus trap.

The owner approved both defaults on 2026-11-04: the /legal "bot check" row and storage sentence, no remoteip, and the token on every /feedback POST including the plot votes, loaded on the first tap.

Reviews

  • Gemini 3.8 Flash (sound with changes): render returning no id would hang 30 s → fixed. Per-request httpx client → kept (same pattern as get_agent_client). Mock regex finding → false (sub is /sessions/<sid>/feedback there).
  • Fable (sound with changes, no bypass or fail-open found): hostname check → added; remoteip rationale → reworded; token before the feedback cap → done; success only on HTTP 200, bad-request as 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 in docs/reference/api.md.
  • Copilot (2 medium, both applied in 44cd368): a production test secret now keeps the door shut too (recognised by value, no siteverify call); the verifier no longer logs a foreign token's hostname, so "anyplot learns only whether the check passed" holds for the logs. Skipped: mapping /feedback's 503 to the bot-check line, since a 503 there 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 row 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."

/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 label cdn / 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 again and the browser check is not available right now · try again (try again reloads; nothing is lost before a session exists).

Chat, feedback form (new): the browser check did not go through; send again and the 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 localStorage item cf.turnstile.u in Cloudflare's own frame, stable across page loads.

Test plan

  • uv run pytest tests/unit tests/integration -n auto: 26,681 passed after merging origin/main (new: tests/unit/api/test_turnstile.py; bot-check, door, bundle-removal and route-guard tests in test_agent_public_router.py; /feedback bot-check tests; cloudbuild bindings, CSP breadth, 32 MiB default)
  • ruff, ruff format, mypy (api core), changelog fragment check
  • yarn lint, yarn fm:check, yarn type-check, yarn test (1,622 passed), yarn build with the production site key
  • Local API against real siteverify with Cloudflare's test secrets: api/deploy_smoke_public.sh passes in development with the always-failing secret; in production mode it fails as intended with no secret (lane 404) and with a test secret (measured as 503 bot_check_unavailable before the Copilot fix; since 44cd368 the lane answers 404 for it, pinned by unit tests); /feedback refuses without a token and before any database query; the honeypot still answers 200; the agents stub saw no session open
  • Browser (own headless playwright-core, BFF mock with MOCK_TURNSTILE, Cloudflare test keys): /agent opens 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 call
  • CSP: the new policy loads Turnstile without a violation; main's policy blocks api.js
  • Hadolint: not run locally (no Docker daemon); CI's "Build app image and smoke the origin gate" runs it and passed. The Dockerfile adds only ARG/ENV VITE_TURNSTILE_SITE_KEY, whose name matches none of DL3064's patterns.
  • After deploy: the owner opens /agent once with the real site key

https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8

MarkusNeusinger and others added 3 commits November 4, 2026 14:11
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
Copilot AI lite review requested due to automatic review settings November 4, 2026 13:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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/lane should 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: Handle 503 bot_check_unavailable as 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

Comment thread api/routers/agent_public.py Resolved
Comment thread api/turnstile.py Resolved
@codecov

codecov Bot commented Nov 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

MarkusNeusinger and others added 4 commits November 4, 2026 14:43
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
@MarkusNeusinger
MarkusNeusinger merged commit 5d71412 into main Nov 4, 2026
16 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the feat/public-chat-turnstile branch November 4, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(feedback): add Cloudflare Turnstile to feedback widget

2 participants