Skip to content

Protect OAuth email code delivery with Turnstile - #741

Merged
thomasluizon merged 1 commit into
mainfrom
thomasluizon/ticket-1506-mcp-code-bot-protection
Oct 18, 2026
Merged

thomasluizon merged 1 commit into
mainfrom
thomasluizon/ticket-1506-mcp-code-bot-protection

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

When bot protection is enabled, /oauth/send-code now requires the same Turnstile verification as /api/auth/send-code before the email handler can cache a code or enqueue delivery. With protection disabled, email sign-in and resend continue without a token.

OAuthController.cs applies RequireBotProtection and appends the optional TurnstileToken request field. OAuthLoginPage.cs conditionally renders the widget and its loader using the public BotProtection:SiteKey setting added to BotProtectionSettings.cs and appsettings.json. The secret never enters the page. OAuthPageScript.cs sends the token, blocks submission until verification completes, and resets each consumed token for retries and resends. The shared widget remains available on the code step so resending also obtains fresh verification. SecurityHeadersMiddleware.cs allows Cloudflare's script and frame on OAuth pages while keeping same-origin connections and the embedding restriction.

Controller, page, middleware, and script unit tests cover the actual declared filter, the real send-code handler's cache and email queue effects, conditional rendering, token expiry, failures, resends, and disabled behavior. Existing command validation and domain behavior are reused. OpenAPI documents were regenerated by the build and have no diff.

Closes thomasluizon/orbit-tickets#1506

Assumptions

  • The API needs its own public BotProtection:SiteKey configuration for this server-rendered page; rejected reading a frontend repository's build-time key because this ticket is scoped to the API.
  • Keep one widget available across both email and code steps; rejected hiding it after the first send because resends require a fresh token too.
  • Exercise the inline script with dependency-free Node unit tests alongside xUnit; rejected adding a JavaScript runtime package or browser tooling.

Test evidence

Before implementation, the unchanged tests passed with the defect present:

dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~OAuthControllerTests|FullyQualifiedName~OAuthLoginPageTests|FullyQualifiedName~RequireBotProtectionFilterTests'

Result: 106 passed. Existing direct controller tests did not exercise the missing route filter.

Added OAuthControllerTests.SendCode_DeclaredBotProtection_GatesEmailDelivery, which uses the route's declared filter and the real SendCodeCommandHandler with mocked infrastructure ports:

dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~SendCode_DeclaredBotProtection_GatesEmailDelivery'

Before the fix: three failed and one passed. Enabled requests with missing or rejected tokens returned 200 instead of 400; the valid-token case never called the verifier. The disabled case passed. After the fix, all four cases pass and assert cache writes and queued email jobs only for accepted requests.

The unchanged middleware tests also passed before the CSP fix:

dotnet test tests/Orbit.Infrastructure.Tests --no-build --filter 'FullyQualifiedName~SecurityHeaders'
dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~InvokeAsync_OAuthPath_AllowsTurnstileScriptsAndFrames'

The first command passed 11 unchanged tests. After strengthening the OAuth CSP test and before changing middleware, the second command failed because the policy lacked the Cloudflare script origin. It passes after the fix.

Final verification, each with exit code 0:

dotnet build Orbit.slnx
dotnet test tests/Orbit.Infrastructure.Tests --no-build --filter 'FullyQualifiedName~OAuthControllerTests|FullyQualifiedName~OAuthLoginPageTests|FullyQualifiedName~RequireBotProtectionFilterTests|FullyQualifiedName~SecurityHeadersMiddlewareTests'
node --test tests/Orbit.Infrastructure.Tests/Controllers/OAuthPageScriptTests.mjs
dotnet test tests/Orbit.Domain.Tests --no-build
dotnet test tests/Orbit.Application.Tests --no-build
dotnet test tests/Orbit.Infrastructure.Tests --no-build
dotnet test tests/Orbit.Analyzers.Tests --no-build

Build: 0 errors. Focused tests: 124 passed. Script tests: 11 passed. Full project suites: Domain 671, Application 5485, Infrastructure 3364, Analyzers 32 passed. Commit hooks passed formatting, dash, timeless, root allowlist, and suppression checks. No browser or running application was used, as required by the work order.

External interface evidence

Read the actual Cloudflare loader source, not just documentation, with:

curl -fsSL 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' -o /tmp/orbit-1506-turnstile-api.js

The inspected response's SHA-256 was 18075576bfb9f1a312df482b69d71e05b2a749dbb2afaed4d1ef8e0026701690. The source exports render and reset, reads the onload query parameter, merges render options into the widget configuration, and reads sitekey, size, language, callback, expired-callback, timeout-callback, and error-callback. The render function returns the widget identifier used by reset. Its supported values include flexible and lowercase pt-br; the page converts its locale to lowercase. Script fixtures use those confirmed callback names and identifier semantics.

The required script and frame origins also follow Cloudflare's CSP guidance.

Manual steps

Activation remains the owner's separate action and is outside this ticket. Before enabling protection:

  • In Cloudflare dashboard > Turnstile > the selected widget > Hostname management, allow api.useorbit.org. Confirm that the widget verifies successfully on the OAuth authorize page for that host.
  • In Render dashboard > the Orbit API service serving api.useorbit.org > Environment, set BotProtection__SiteKey to that widget's public key and BotProtection__SecretKey to its paired secret, then set BotProtection__Enabled=true when ready to activate. After the configuration is deployed, confirm that /oauth/authorize loads the widget, a valid token permits /oauth/send-code, and a missing token receives 400 without an email job. The checked-in setting remains disabled.

@sonarqubecloud

Copy link
Copy Markdown

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes Reviewed the complete OAuth email-code protection change, including the existing verification filter, email-delivery handler, browser script, and sibling consumer contracts.

  • Server-side enforcement: /oauth/send-code now verifies Turnstile before dispatching email delivery when protection is enabled, using an append-only optional request field.
  • Authorize-page integration: Conditional widget loading exposes only the public site key; send and resend require fresh tokens, including after failed requests.
  • Browser policy: OAuth CSP permits Cloudflare scripts and frames while retaining same-origin connections and the existing embedding restrictions.
  • Verification: Independently passed 124 focused .NET tests and all 11 JavaScript tests. Live browser and deployed-widget validation were not performed; the PR documents hostname/key configuration and activation checks.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@thomasluizon
thomasluizon merged commit 1149606 into main Oct 18, 2026
23 checks passed
@thomasluizon
thomasluizon deleted the thomasluizon/ticket-1506-mcp-code-bot-protection branch October 18, 2026 10:24
thomasluizon added a commit that referenced this pull request Oct 18, 2026
* fix: pin analyzer test Pkcs dependency to patched version (#728)

(cherry picked from commit 61b0090)

* fix: shard pull request domain mutation testing (#729)

(cherry picked from commit bcfd81e)

* Promote the API redesign with production compatibility checks (#723)

* Bring the API redesign tree onto main for #1413

* Preserve calendar connection detection for shipped clients

* Prove API and migrated schema compatibility with shipped clients

* Trim whitespace in the archived main schema proof

* Require the production health response to report the released commit

* Bind initial approval previews to stored pending operations

* fix: return the bulk skip refresh outcome and split the undo skip query

SonarCloud failed the quality gate on two reliability findings in code this pull request brings to main:
the bulk skip conflict check read a flag only a lambda wrote (S2583), and the undo skip check loaded
Tags and Goals in one query (S8733). The transaction now returns whether every habit refreshed, and the
undo skip check splits its query. A new case covers the concurrent change conflict; it failed with the
refresh outcome forced to true and passes with the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5526c9b)

* Add per-account feature flag targeting and admin controls (#724)

* feat: resolve feature flags per account

* feat: administer account feature flags through MCP and REST

* test: exclude feature flag targets from rate limit fixture

* fix: reconcile account flag tests and migration metadata with redesign

(cherry picked from commit 1b886bd)

* Add header API versioning for v1 and v2 (#733)

* Add header API versioning with v1 compatibility

* Verify versioned OpenAPI and browser header compatibility

* Preserve skip undo path contract with optional version header

(cherry picked from commit ac8b34a)

* fix: expose the config response in OpenAPI (#734)

(cherry picked from commit a9d8dd5)

* Route production redesign accounts through Cloudflare (#735)

* Add cookie-based routing for the production redesign web service

* Document web routing rollout, permissions, and rollback checks

* Fix web routing coverage and disable infra install scripts

* chore: regenerate the v2 OpenAPI document on main

orbit-api#733 added openapi_v2.json and orbit-api#734 typed the /api/config
response in parallel, so main carries a v2 document without the typed config
schema and the Build check's stale-document step fails on every merge with
main. dotnet build src/Orbit.Api regenerates it; openapi.json is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9c64770)

* Fix habit edit gating, end dates, and reminder updates (#736)

* fix: validate habit updates with the resulting time range

* fix: preserve end dates when creating root habits

* fix: gate slip alert activation and preserve unchanged reminders

* fix: compare effective reminder rules across stored formats

(cherry picked from commit 7472203)

* Reduce mutation and guard CI jobs (#737)

* Select mutation shards from changed projects

* Consolidate guards and filter artifact generation

* Skip project documentation in mutation selection

* Validate mutation base refs and restrict Git PATH

* Read mutation shard changes from standard input

(cherry picked from commit b802f9e)

* test: strengthen habit and goal ownership coverage (#739)

(cherry picked from commit 0e32628)

* test: verify checkout session user metadata (#740)

(cherry picked from commit da2f53a)

* fix: protect OAuth code delivery with Turnstile (#741)

(cherry picked from commit 1149606)

* fix: enforce chat interaction scope before loading context (#743)

(cherry picked from commit 98faf4e)

* fix: erase accountability pairs during account deletion and reset (#745)

* fix: erase accountability pairs during account deletion and reset

* test: preserve migration compatibility checks after accountability cleanup

(cherry picked from commit a9e5fb9)

* Fix goal sync dates and deadline reminder counts (#747)

* fix: compare streak sync dates in the user timezone

* fix: show actual days remaining in goal deadline reminders

* test: configure timezone for streak scheduler fixtures

(cherry picked from commit 0e63842)

* Manage staging landing routing in Terraform (#742)

(cherry picked from commit abdd738)

* Restrict Web Push endpoints to supported push services (#1503) (#751)

Web Push subscriptions accept only HTTPS endpoints on the FCM, Mozilla, Apple and WNS push hosts, and the push HTTP client follows no redirects. Replaces orbit-api#748 as one commit so no commit carries the high-entropy sample fixture.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d9f847a)

* Erase account uploads before permanent deletion (#1507) (#750)

* fix: erase uploaded objects before completing account deletion

* fix: refuse upload reads after permanent account deletion

* fix: grant uploads bucket deletion permissions and verify provider wiring

* test: align storage startup checks with erasure requirements

* fix: retire deleted Supabase upload storage

* test: configure upload erasure providers in deletion fixtures

Share the existing object-store fake across account deletion and scheduler fixtures after merging main. Preserve accountability assertions and production provider requirements for thomasluizon/orbit-tickets#1507.

(cherry picked from commit fa33a94)

* fix: limit anonymous auth and waitlist by email and source IP (ORB-108) (#749)

* fix: enforce source IP ceilings alongside anonymous email limits

* test: cover both rate limit dimensions before bot verification

* fix: isolate BFF email rate limits by verified client IP

* fix: generate and persist BFF forwarding secrets

(cherry picked from commit de0f42f)

* Limit access tokens to fifteen minutes (#738)

* fix(auth): limit access tokens to fifteen minutes

* fix(auth): align managed JWT lifetime and prove logout rejection

(cherry picked from commit ade7016)

* fix(auth): equalize account provisioning work (ORB-96) (#744)

* test(auth): expose registration dependent sign-in work

* fix(auth): perform uniform registration work before sign-in responses

* test(auth): cover expired codes and tracked upsert conflicts

* fix(auth): isolate signup retries and restore provisioning guards

* fix(auth): recover signup work after dispatch failures

* fix(auth): roll back failed signup referrals before retry

(cherry picked from commit 1bd0d26)

* fix: expose redesign controllers in both API versions

Apply the carried version declarations to the Notion and proactive controllers and regenerate the v2 contract. Refs thomasluizon/orbit-tickets#746.

* fix: honor account flags in proactive engine

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant