Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "cloudflare-turnstile",
|
||||
"description": "Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Jeremy Dawes",
|
||||
"email": "jeremy@jezweb.net"
|
||||
},
|
||||
"skills": [
|
||||
"./"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# cloudflare-turnstile
|
||||
|
||||
Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.
|
||||
432
SKILL.md
Normal file
432
SKILL.md
Normal file
@@ -0,0 +1,432 @@
|
||||
---
|
||||
name: cloudflare-turnstile
|
||||
description: |
|
||||
Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Cloudflare Turnstile
|
||||
|
||||
**Status**: Production Ready ✅
|
||||
**Last Updated**: 2025-11-24
|
||||
**Dependencies**: None (optional: @marsidev/react-turnstile for React)
|
||||
**Latest Versions**: @marsidev/react-turnstile@1.3.1, turnstile-types@1.2.3
|
||||
|
||||
**Recent Updates (2025)**:
|
||||
- **March 2025**: Upgraded Turnstile Analytics with TopN statistics (7 dimensions: hostnames, browsers, countries, user agents, ASNs, OS, source IPs), anomaly detection, enhanced bot behavior monitoring
|
||||
- **2025**: WCAG 2.1 AA compliance, Free plan (20 widgets, 7-day analytics), Enterprise features (unlimited widgets, ephemeral IDs, any hostname support, 30-day analytics, offlabel branding)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
|
||||
```bash
|
||||
# 1. Create widget: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||
# Copy sitekey (public) and secret key (private)
|
||||
|
||||
# 2. Add widget to frontend
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<form>
|
||||
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
# 3. Validate token server-side (Cloudflare Workers)
|
||||
const formData = await request.formData()
|
||||
const token = formData.get('cf-turnstile-response')
|
||||
|
||||
const verifyFormData = new FormData()
|
||||
verifyFormData.append('secret', env.TURNSTILE_SECRET_KEY)
|
||||
verifyFormData.append('response', token)
|
||||
verifyFormData.append('remoteip', request.headers.get('CF-Connecting-IP'))
|
||||
|
||||
const result = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{ method: 'POST', body: verifyFormData }
|
||||
)
|
||||
|
||||
const outcome = await result.json()
|
||||
if (!outcome.success) return new Response('Invalid', { status: 401 })
|
||||
```
|
||||
|
||||
**CRITICAL:**
|
||||
- Token expires in 5 minutes, single-use only
|
||||
- ALWAYS validate server-side (Siteverify API required)
|
||||
- Never proxy/cache api.js (must load from Cloudflare CDN)
|
||||
- Use different widgets for dev/staging/production
|
||||
|
||||
## Rendering Modes
|
||||
|
||||
**Implicit** (auto-render on page load):
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onSuccess"></div>
|
||||
```
|
||||
|
||||
**Explicit** (programmatic control for SPAs):
|
||||
```typescript
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
const widgetId = turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' })
|
||||
turnstile.reset(widgetId) // Reset widget
|
||||
turnstile.getResponse(widgetId) // Get token
|
||||
```
|
||||
|
||||
**React** (using @marsidev/react-turnstile):
|
||||
```tsx
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
<Turnstile siteKey={TURNSTILE_SITE_KEY} onSuccess={setToken} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Always Do
|
||||
|
||||
✅ **Call Siteverify API** - Server-side validation is mandatory
|
||||
✅ **Use HTTPS** - Never validate over HTTP
|
||||
✅ **Protect secret keys** - Never expose in frontend code
|
||||
✅ **Handle token expiration** - Tokens expire after 5 minutes
|
||||
✅ **Implement error callbacks** - Handle failures gracefully
|
||||
✅ **Use dummy keys for testing** - Test sitekey: `1x00000000000000000000AA`
|
||||
✅ **Set reasonable timeouts** - Don't wait indefinitely for validation
|
||||
✅ **Validate action/hostname** - Check additional fields when specified
|
||||
✅ **Rotate keys periodically** - Use dashboard or API to rotate secrets
|
||||
✅ **Monitor analytics** - Track solve rates and failures
|
||||
|
||||
### Never Do
|
||||
|
||||
❌ **Skip server validation** - Client-side only = security vulnerability
|
||||
❌ **Proxy api.js script** - Must load from Cloudflare CDN
|
||||
❌ **Reuse tokens** - Each token is single-use only
|
||||
❌ **Use GET requests** - Siteverify only accepts POST
|
||||
❌ **Expose secret key** - Keep secrets in backend environment only
|
||||
❌ **Trust client-side validation** - Tokens can be forged
|
||||
❌ **Cache api.js** - Future updates will break your integration
|
||||
❌ **Use production keys in tests** - Use dummy keys instead
|
||||
❌ **Ignore error callbacks** - Always handle failures
|
||||
|
||||
---
|
||||
|
||||
## Known Issues Prevention
|
||||
|
||||
This skill prevents **12** documented issues:
|
||||
|
||||
### Issue #1: Missing Server-Side Validation
|
||||
**Error**: Zero token validation in Turnstile Analytics dashboard
|
||||
**Source**: https://developers.cloudflare.com/turnstile/get-started/
|
||||
**Why It Happens**: Developers only implement client-side widget, skip Siteverify call
|
||||
**Prevention**: All templates include mandatory server-side validation with Siteverify API
|
||||
|
||||
### Issue #2: Token Expiration (5 Minutes)
|
||||
**Error**: `success: false` for valid tokens submitted after delay
|
||||
**Source**: https://developers.cloudflare.com/turnstile/get-started/server-side-validation
|
||||
**Why It Happens**: Tokens expire 300 seconds after generation
|
||||
**Prevention**: Templates document TTL and implement token refresh on expiration
|
||||
|
||||
### Issue #3: Secret Key Exposed in Frontend
|
||||
**Error**: Security bypass - attackers can validate their own tokens
|
||||
**Source**: https://developers.cloudflare.com/turnstile/get-started/server-side-validation
|
||||
**Why It Happens**: Secret key hardcoded in JavaScript or visible in source
|
||||
**Prevention**: All templates show backend-only validation with environment variables
|
||||
|
||||
### Issue #4: GET Request to Siteverify
|
||||
**Error**: API returns 405 Method Not Allowed
|
||||
**Source**: https://developers.cloudflare.com/turnstile/migration/recaptcha
|
||||
**Why It Happens**: reCAPTCHA supports GET, Turnstile requires POST
|
||||
**Prevention**: Templates use POST with FormData or JSON body
|
||||
|
||||
### Issue #5: Content Security Policy Blocking
|
||||
**Error**: Error 200500 - "Loading error: The iframe could not be loaded"
|
||||
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes
|
||||
**Why It Happens**: CSP blocks challenges.cloudflare.com iframe
|
||||
**Prevention**: Skill includes CSP configuration reference and check-csp.sh script
|
||||
|
||||
### Issue #6: Widget Crash (Error 300030)
|
||||
**Error**: Generic client execution error for legitimate users
|
||||
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||
**Why It Happens**: Unknown - appears to be Cloudflare-side issue (2025)
|
||||
**Prevention**: Templates implement error callbacks, retry logic, and fallback handling
|
||||
|
||||
### Issue #7: Configuration Error (Error 600010)
|
||||
**Error**: Widget fails with "configuration error"
|
||||
**Source**: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578
|
||||
**Why It Happens**: Missing or deleted hostname in widget configuration
|
||||
**Prevention**: Templates document hostname allowlist requirement and verification steps
|
||||
|
||||
### Issue #8: Safari 18 / macOS 15 "Hide IP" Issue
|
||||
**Error**: Error 300010 when Safari's "Hide IP address" is enabled
|
||||
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||
**Why It Happens**: Privacy settings interfere with challenge signals
|
||||
**Prevention**: Error handling reference documents Safari workaround (disable Hide IP)
|
||||
|
||||
### Issue #9: Brave Browser Confetti Animation Failure
|
||||
**Error**: Verification fails during success animation
|
||||
**Source**: https://github.com/brave/brave-browser/issues/45608 (April 2025)
|
||||
**Why It Happens**: Brave shields block animation scripts
|
||||
**Prevention**: Templates handle success before animation completes
|
||||
|
||||
### Issue #10: Next.js + Jest Incompatibility
|
||||
**Error**: @marsidev/react-turnstile breaks Jest tests
|
||||
**Source**: https://github.com/marsidev/react-turnstile/issues/112 (Oct 2025)
|
||||
**Why It Happens**: Module resolution issues with Jest
|
||||
**Prevention**: Testing guide includes Jest mocking patterns and dummy sitekey usage
|
||||
|
||||
### Issue #11: localhost Not in Allowlist
|
||||
**Error**: Error 110200 - "Unknown domain: Domain not allowed"
|
||||
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes
|
||||
**Why It Happens**: Production widget used in development without localhost in allowlist
|
||||
**Prevention**: Templates use dummy test keys for dev, document localhost allowlist requirement
|
||||
|
||||
### Issue #12: Token Reuse Attempt
|
||||
**Error**: `success: false` with "token already spent" error
|
||||
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/testing
|
||||
**Why It Happens**: Each token can only be validated once
|
||||
**Prevention**: Templates document single-use constraint and token refresh patterns
|
||||
|
||||
## Configuration
|
||||
|
||||
**wrangler.jsonc:**
|
||||
```jsonc
|
||||
{
|
||||
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
|
||||
"secrets": ["TURNSTILE_SECRET_KEY"] // Run: wrangler secret put TURNSTILE_SECRET_KEY
|
||||
}
|
||||
```
|
||||
|
||||
**Required CSP:**
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src 'self' https://challenges.cloudflare.com;
|
||||
">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Hono + Cloudflare Workers
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
|
||||
type Bindings = {
|
||||
TURNSTILE_SECRET_KEY: string
|
||||
TURNSTILE_SITE_KEY: string
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>()
|
||||
|
||||
app.post('/api/login', async (c) => {
|
||||
const body = await c.req.formData()
|
||||
const token = body.get('cf-turnstile-response')
|
||||
|
||||
if (!token) {
|
||||
return c.text('Missing Turnstile token', 400)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const verifyFormData = new FormData()
|
||||
verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)
|
||||
verifyFormData.append('response', token.toString())
|
||||
verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')
|
||||
|
||||
const verifyResult = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: verifyFormData,
|
||||
}
|
||||
)
|
||||
|
||||
const outcome = await verifyResult.json<{ success: boolean }>()
|
||||
|
||||
if (!outcome.success) {
|
||||
return c.text('Invalid Turnstile token', 401)
|
||||
}
|
||||
|
||||
// Process login
|
||||
return c.json({ message: 'Login successful' })
|
||||
})
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
**When to use**: API routes in Cloudflare Workers with Hono framework
|
||||
|
||||
### Pattern 2: React + Next.js App Router
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ContactForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!token) {
|
||||
setError('Please complete the challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
formData.append('cf-turnstile-response', token)
|
||||
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setError('Submission failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="email" type="email" required />
|
||||
<textarea name="message" required />
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={() => setError('Challenge failed')}
|
||||
onExpire={() => setToken(undefined)}
|
||||
/>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={!token}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Client-side forms in Next.js with React hooks
|
||||
|
||||
---
|
||||
|
||||
## Testing Keys
|
||||
|
||||
**Dummy Sitekeys (client):**
|
||||
- Always pass: `1x00000000000000000000AA`
|
||||
- Always block: `2x00000000000000000000AB`
|
||||
- Force interactive: `3x00000000000000000000FF`
|
||||
|
||||
**Dummy Secret Keys (server):**
|
||||
- Always pass: `1x0000000000000000000000000000000AA`
|
||||
- Always fail: `2x0000000000000000000000000000000AA`
|
||||
- Token already spent: `3x0000000000000000000000000000000AA`
|
||||
|
||||
---
|
||||
|
||||
## Bundled Resources
|
||||
|
||||
**Scripts:** `check-csp.sh` - Verify CSP allows Turnstile
|
||||
|
||||
**References:**
|
||||
- `widget-configs.md` - All configuration options
|
||||
- `error-codes.md` - Error code troubleshooting (100*/200*/300*/400*/600*)
|
||||
- `testing-guide.md` - Testing strategies, dummy keys
|
||||
- `react-integration.md` - React/Next.js patterns
|
||||
|
||||
**Templates:** Complete examples for Hono, React, implicit/explicit rendering, validation
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features
|
||||
|
||||
**Pre-Clearance (SPAs):** Issue cookie that persists across page navigations
|
||||
```typescript
|
||||
turnstile.render('#container', {
|
||||
sitekey: SITE_KEY,
|
||||
callback: async (token) => {
|
||||
await fetch('/api/pre-clearance', { method: 'POST', body: JSON.stringify({ token }) })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Custom Actions & Data:** Track challenge types, pass custom data (max 255 chars)
|
||||
```typescript
|
||||
turnstile.render('#container', {
|
||||
action: 'login', // Track in analytics
|
||||
cdata: JSON.stringify({ userId: '123' }), // Custom payload
|
||||
})
|
||||
```
|
||||
|
||||
**Error Handling:** Use `retry: 'auto'` and `error-callback` for resilience
|
||||
```typescript
|
||||
turnstile.render('#container', {
|
||||
retry: 'auto',
|
||||
'retry-interval': 8000, // ms between retries
|
||||
'error-callback': (error) => { /* handle or show fallback */ }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Required:** None (loads from CDN)
|
||||
**React:** @marsidev/react-turnstile@1.3.1 (Cloudflare-recommended), turnstile-types@1.2.3
|
||||
**Other:** vue-turnstile, ngx-turnstile, svelte-turnstile, @nuxtjs/turnstile
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- https://developers.cloudflare.com/turnstile/
|
||||
- Use `mcp__cloudflare-docs__search_cloudflare_documentation` tool
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Error 110200 - "Unknown domain"
|
||||
**Solution**: Add your domain (including localhost for dev) to widget's allowed domains in Cloudflare Dashboard. For local dev, use dummy test sitekey `1x00000000000000000000AA` instead.
|
||||
|
||||
### Problem: Error 300030 - Widget crashes for legitimate users
|
||||
**Solution**: Implement error callback with retry logic. This is a known Cloudflare-side issue (2025). Fallback to alternative verification if retries fail.
|
||||
|
||||
### Problem: Tokens always return `success: false`
|
||||
**Solution**:
|
||||
1. Check token hasn't expired (5 min TTL)
|
||||
2. Verify secret key is correct
|
||||
3. Ensure token hasn't been validated before (single-use)
|
||||
4. Check hostname matches widget configuration
|
||||
|
||||
### Problem: CSP blocking iframe (Error 200500)
|
||||
**Solution**: Add CSP directives:
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
frame-src https://challenges.cloudflare.com;
|
||||
script-src https://challenges.cloudflare.com;
|
||||
">
|
||||
```
|
||||
|
||||
### Problem: Safari 18 "Hide IP" causing Error 300010
|
||||
**Solution**: Document in error message that users should disable Safari's "Hide IP address" setting (Safari → Settings → Privacy → Hide IP address → Off)
|
||||
|
||||
### Problem: Next.js + Jest tests failing with @marsidev/react-turnstile
|
||||
**Solution**: Mock the Turnstile component in Jest setup:
|
||||
```typescript
|
||||
// jest.setup.ts
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: () => <div data-testid="turnstile-mock" />,
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Errors Prevented**: 12 documented issues (Safari 18 Hide IP, Brave confetti, Next.js Jest, CSP blocking, token reuse, expiration, hostname allowlist, widget crash 300030, config error 600010, missing validation, GET request, secret exposure)
|
||||
14
assets/example-template.txt
Normal file
14
assets/example-template.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
[TODO: Example Template File]
|
||||
|
||||
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
|
||||
|
||||
[TODO: Examples:]
|
||||
- Templates (.html, .tsx, .md)
|
||||
- Images (.png, .svg)
|
||||
- Fonts (.ttf, .woff)
|
||||
- Boilerplate code
|
||||
- Configuration file templates
|
||||
|
||||
[TODO: Delete this file and add your actual assets]
|
||||
|
||||
These files are NOT loaded into context. They are copied or used directly in the final output.
|
||||
105
plugin.lock.json
Normal file
105
plugin.lock.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:jezweb/claude-skills:skills/cloudflare-turnstile",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "395f7a3fbf640673b3e03ed800673c320bac4327",
|
||||
"treeHash": "0d0340805a6c2ea84ae4fa5a4f1a52b687917fbaeef4731605eec19a822cdc3a",
|
||||
"generatedAt": "2025-11-28T10:18:58.793082Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "cloudflare-turnstile",
|
||||
"description": "Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "4be7157860d8e65e17bb70554607aeb8c8bc1b8172764d97df7edc3514f83724"
|
||||
},
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"sha256": "9644cf2b04733ca4ef638b11f1dcaff6e94db6a69f7fad9dcf6f996b4f25df84"
|
||||
},
|
||||
{
|
||||
"path": "references/example-reference.md",
|
||||
"sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8"
|
||||
},
|
||||
{
|
||||
"path": "references/widget-configs.md",
|
||||
"sha256": "43d089076218ff08cc27716f09eaaed361bb7b5d9a99bff1467dceaf9271c016"
|
||||
},
|
||||
{
|
||||
"path": "references/react-integration.md",
|
||||
"sha256": "d2e21fdf4e85f5e269bcfba4c0b365b5273794d136af3ff030155a00551684fb"
|
||||
},
|
||||
{
|
||||
"path": "references/error-codes.md",
|
||||
"sha256": "fdf8c6a1ca3bf6b5eb3b9922939f2f6523bc3485e19fc5e58f60339971213365"
|
||||
},
|
||||
{
|
||||
"path": "references/testing-guide.md",
|
||||
"sha256": "3a0a1ec5258c712d8885d595f726514e94cf94ffab50745ff50be73aa404c4ce"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check-csp.sh",
|
||||
"sha256": "7560516e85807369b9136653e909bf6c80369a4f136cd38964a9eda3f225639e"
|
||||
},
|
||||
{
|
||||
"path": "scripts/example-script.sh",
|
||||
"sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "c4d49a11df5616f13b7245275209495dc580d4d1fa09209001608b7f7534e128"
|
||||
},
|
||||
{
|
||||
"path": "templates/turnstile-test-config.ts",
|
||||
"sha256": "529ec05cba26f8c1c2076700977d0678bf7ffcc95be885d530e9548aac2012c9"
|
||||
},
|
||||
{
|
||||
"path": "templates/turnstile-widget-implicit.html",
|
||||
"sha256": "b59a17c9565df0b5d576f794b4afb144403763d9e4e025fe5e85082c7792113d"
|
||||
},
|
||||
{
|
||||
"path": "templates/turnstile-react-component.tsx",
|
||||
"sha256": "0ec5ab1d61fa1bb3bee9dd8c2ee35a47e1445865edabb18010689cd576c7e13c"
|
||||
},
|
||||
{
|
||||
"path": "templates/turnstile-hono-route.ts",
|
||||
"sha256": "0482db70722bf6f006dbd82e6dacef8c8a0f46250ff89cb2e6c803f9ab6c907f"
|
||||
},
|
||||
{
|
||||
"path": "templates/turnstile-widget-explicit.ts",
|
||||
"sha256": "acda3dcf63ff3b053517d74204a2074bb979e889da5a6c1c8b07af6337b9228c"
|
||||
},
|
||||
{
|
||||
"path": "templates/turnstile-server-validation.ts",
|
||||
"sha256": "17f9b65d7e7b6c7f47e916f84f39c7330c9962dde24eb76a0c656131bfe8d5cc"
|
||||
},
|
||||
{
|
||||
"path": "templates/wrangler-turnstile-config.jsonc",
|
||||
"sha256": "bf20c5cd1721dfaf37187101d3deaa25f291349c9b59eb409247ee618c199d3b"
|
||||
},
|
||||
{
|
||||
"path": "assets/example-template.txt",
|
||||
"sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a"
|
||||
}
|
||||
],
|
||||
"dirSha256": "0d0340805a6c2ea84ae4fa5a4f1a52b687917fbaeef4731605eec19a822cdc3a"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
559
references/error-codes.md
Normal file
559
references/error-codes.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# Turnstile Error Codes Reference
|
||||
|
||||
**Complete error code reference with troubleshooting**
|
||||
|
||||
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
|
||||
|
||||
---
|
||||
|
||||
## Error Code Families
|
||||
|
||||
Error codes use the format `XXXYYY` where:
|
||||
- `XXX` = Error family (indicates general category)
|
||||
- `YYY` = Specific error (internal use, often marked `***` in docs)
|
||||
|
||||
**Note**: When `***` appears, the last 3 digits can be ignored.
|
||||
|
||||
---
|
||||
|
||||
## 100*** - Initialization Problems
|
||||
|
||||
**Error**: Problem initializing Turnstile before challenge could start
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Usually caused by:
|
||||
- Old instance of solved challenge still present
|
||||
- Page state corruption
|
||||
- Cache issues
|
||||
|
||||
**Solution**:
|
||||
1. Reload the page
|
||||
2. Clear browser cache
|
||||
3. Reset Turnstile widget programmatically
|
||||
4. On continuous failures → likely automated device
|
||||
|
||||
---
|
||||
|
||||
## 102***, 103***, 104***, 106*** - Invalid Parameters
|
||||
|
||||
**Error**: Visitor sent invalid parameter as part of challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Malformed request data
|
||||
- Corrupted challenge parameters
|
||||
- Browser/extension interference
|
||||
|
||||
**Solution**:
|
||||
1. Retry the challenge automatically
|
||||
2. On continuous failures → likely bot
|
||||
3. Implement `error-callback` with retry logic
|
||||
4. Verify visitor authenticity by other means
|
||||
|
||||
---
|
||||
|
||||
## 105*** - API Compatibility
|
||||
|
||||
**Error**: Turnstile invoked in deprecated or invalid way
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Using outdated API methods
|
||||
- Invalid widget configuration
|
||||
- Script version mismatch
|
||||
|
||||
**Solution**:
|
||||
1. Check official Turnstile documentation
|
||||
2. Refresh page to get latest script version
|
||||
3. Review widget initialization code
|
||||
4. Ensure `api.js` loads from Cloudflare CDN
|
||||
|
||||
---
|
||||
|
||||
## 110100, 110110 - Invalid Sitekey
|
||||
|
||||
**Error**: Turnstile invoked with invalid or inactive sitekey
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Sitekey doesn't exist
|
||||
- Sitekey was deleted
|
||||
- Typo in sitekey
|
||||
- Using wrong sitekey for environment
|
||||
|
||||
**Solution**:
|
||||
1. Verify sitekey in Cloudflare Dashboard
|
||||
2. Check sitekey is still active
|
||||
3. Ensure no typos in configuration
|
||||
4. Use correct sitekey for environment (dev/prod)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
const SITE_KEY = '1x00000000000000000000AA' // Test key in production
|
||||
|
||||
// ✅ Correct
|
||||
const SITE_KEY = process.env.TURNSTILE_SITE_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 110200 - Unknown Domain
|
||||
|
||||
**Error**: Domain not allowed for this widget
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Current hostname not in widget's allowed domains list
|
||||
- Using production widget on localhost
|
||||
- Subdomain not added to allowlist
|
||||
|
||||
**Solution**:
|
||||
1. Add domain to allowed list in Cloudflare Dashboard
|
||||
2. For localhost: add `localhost` or use test sitekey `1x00000000000000000000AA`
|
||||
3. Check subdomain matches exactly (www.example.com ≠ example.com)
|
||||
|
||||
**Example Allowed Domains**:
|
||||
```
|
||||
example.com
|
||||
www.example.com
|
||||
localhost # For development
|
||||
127.0.0.1 # For development
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 110420 - Invalid Action
|
||||
|
||||
**Error**: Unsupported or incorrectly formatted action submitted
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Action contains invalid characters
|
||||
- Action exceeds 32 character limit
|
||||
- Non-alphanumeric characters (except `-` and `_`)
|
||||
|
||||
**Solution**:
|
||||
1. Use only `a-z`, `A-Z`, `0-9`, `-`, `_`
|
||||
2. Keep action ≤ 32 characters
|
||||
3. Example valid actions: `login`, `signup`, `contact-form`
|
||||
|
||||
**Reference**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations
|
||||
|
||||
---
|
||||
|
||||
## 110430 - Invalid cData
|
||||
|
||||
**Error**: Custom data (cData) format invalid
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- cData contains invalid characters
|
||||
- cData exceeds 255 character limit
|
||||
|
||||
**Solution**:
|
||||
1. Keep cData ≤ 255 characters
|
||||
2. Use JSON.stringify() for objects
|
||||
3. Validate data before passing to Turnstile
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// ❌ Wrong - too long
|
||||
const cdata = JSON.stringify({ /* 300+ chars */ })
|
||||
|
||||
// ✅ Correct
|
||||
const cdata = JSON.stringify({ userId: '123', sessionId: 'abc' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 110500 - Unsupported Browser
|
||||
|
||||
**Error**: Visitor using unsupported browser
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Internet Explorer (not supported)
|
||||
- Very outdated browser version
|
||||
- Browser without required APIs
|
||||
|
||||
**Solution**:
|
||||
1. Encourage visitor to upgrade browser
|
||||
2. Provide alternative verification method
|
||||
3. Display browser upgrade message
|
||||
|
||||
**Supported Browsers**: https://developers.cloudflare.com/cloudflare-challenges/reference/supported-browsers/
|
||||
|
||||
---
|
||||
|
||||
## 110510 - Inconsistent User-Agent
|
||||
|
||||
**Error**: Visitor provided inconsistent user-agent during challenge
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Browser extensions spoofing user-agent
|
||||
- Privacy tools modifying headers
|
||||
- Browser settings
|
||||
|
||||
**Solution**:
|
||||
1. Ask visitor to disable user-agent spoofing extensions
|
||||
2. Disable privacy tools temporarily
|
||||
3. Try different browser
|
||||
|
||||
---
|
||||
|
||||
## 11060* - Challenge Timed Out
|
||||
|
||||
**Error**: Visitor took too long to solve challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Slow network connection
|
||||
- System clock set incorrectly
|
||||
- Visitor distracted/inactive
|
||||
|
||||
**Solution**:
|
||||
1. Retry the challenge
|
||||
2. Check system clock is correct
|
||||
3. Improve network connection
|
||||
|
||||
---
|
||||
|
||||
## 11062* - Interactive Challenge Timeout
|
||||
|
||||
**Error**: Visitor didn't interact with checkbox (visible mode only)
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Challenge became outdated while waiting for interaction
|
||||
- User abandoned form
|
||||
- Long delays between rendering and submission
|
||||
|
||||
**Solution**:
|
||||
1. Reset widget programmatically
|
||||
2. Re-initialize widget
|
||||
3. Prompt user to interact
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'timeout-callback': () => {
|
||||
turnstile.reset(widgetId)
|
||||
alert('Please complete the verification')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 120*** - Internal Cloudflare Errors
|
||||
|
||||
**Error**: Internal debugging errors (Cloudflare employees only)
|
||||
|
||||
**Retry**: N/A
|
||||
|
||||
**Solution**: Only encountered by Cloudflare Support during debugging.
|
||||
|
||||
---
|
||||
|
||||
## 200010 - Invalid Caching
|
||||
|
||||
**Error**: Some portion of Turnstile was accidentally cached
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Browser cached Turnstile resources incorrectly
|
||||
- CDN/proxy caching `api.js` script
|
||||
|
||||
**Solution**:
|
||||
1. Clear browser cache
|
||||
2. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
|
||||
3. Ensure `api.js` is not proxied or cached
|
||||
|
||||
**CRITICAL**: Never proxy or cache `https://challenges.cloudflare.com/turnstile/v0/api.js`
|
||||
|
||||
---
|
||||
|
||||
## 200100 - Time Problem
|
||||
|
||||
**Error**: Visitor's system clock is incorrect
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- System time is wrong
|
||||
- Timezone misconfigured
|
||||
- Date/time not synchronized
|
||||
|
||||
**Solution**:
|
||||
1. Set system clock to correct time
|
||||
2. Enable automatic time synchronization
|
||||
3. Check timezone settings
|
||||
|
||||
---
|
||||
|
||||
## 200500 - Loading Error
|
||||
|
||||
**Error**: iframe under challenges.cloudflare.com could not be loaded
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- **Content Security Policy (CSP) blocking iframe**
|
||||
- Browser security settings blocking 3rd-party iframes
|
||||
- Network firewall blocking challenges.cloudflare.com
|
||||
|
||||
**Solution**:
|
||||
1. **Add CSP directives**:
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src 'self' https://challenges.cloudflare.com;
|
||||
connect-src 'self' https://challenges.cloudflare.com;
|
||||
">
|
||||
```
|
||||
2. Reduce browser security preferences
|
||||
3. Check firewall/network settings
|
||||
|
||||
**Most Common Cause**: CSP blocking. See check-csp.sh script.
|
||||
|
||||
---
|
||||
|
||||
## 300*** - Generic Client Execution Error
|
||||
|
||||
**Error**: Unspecified error occurred while visitor solved challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Browser extension interference
|
||||
- JavaScript errors on page
|
||||
- Memory issues
|
||||
- Network interruption
|
||||
|
||||
**Solution**:
|
||||
1. Retry automatically
|
||||
2. On continuous failures → potentially automated visitor
|
||||
3. Disable browser extensions
|
||||
4. Try incognito/private mode
|
||||
|
||||
**Known Issue (2025)**: Safari 18 + macOS 15 with "Hide IP" enabled causes Error 300010.
|
||||
|
||||
**Safari Fix**: Settings → Privacy → Hide IP address → Off
|
||||
|
||||
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||
|
||||
---
|
||||
|
||||
## 300030 - Widget Crash
|
||||
|
||||
**Error**: Widget crashed for legitimate users
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**: Unknown - Cloudflare-side issue (2025)
|
||||
|
||||
**Solution**:
|
||||
1. Implement robust error handling
|
||||
2. Retry with exponential backoff
|
||||
3. Provide fallback verification method
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
let retryCount = 0
|
||||
const maxRetries = 3
|
||||
|
||||
turnstile.render('#container', {
|
||||
sitekey: SITE_KEY,
|
||||
'error-callback': (error) => {
|
||||
if (error.includes('300030') && retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(() => {
|
||||
turnstile.reset(widgetId)
|
||||
}, 2000 * retryCount) // Exponential backoff
|
||||
} else {
|
||||
showFallbackVerification()
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||
|
||||
---
|
||||
|
||||
## 400020 - Invalid Sitekey (Server)
|
||||
|
||||
**Error**: Sitekey is invalid or does not exist
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Same as 110100/110110 but caught server-side
|
||||
|
||||
**Solution**: Verify sitekey exists and is active
|
||||
|
||||
---
|
||||
|
||||
## 400030 - Invalid Size
|
||||
|
||||
**Error**: Provided size option is not valid
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Using invalid `size` parameter
|
||||
|
||||
**Valid Options**: `normal`, `compact`, `flexible`
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
{ size: 'large' }
|
||||
|
||||
// ✅ Correct
|
||||
{ size: 'compact' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 400040 - Invalid Theme
|
||||
|
||||
**Error**: Provided theme is not valid
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Using invalid `theme` parameter
|
||||
|
||||
**Valid Options**: `light`, `dark`, `auto`
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
{ theme: 'custom' }
|
||||
|
||||
// ✅ Correct
|
||||
{ theme: 'dark' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 401 - Unauthorized (Expected)
|
||||
|
||||
**Error**: 401 error in browser console during challenge
|
||||
|
||||
**Retry**: N/A
|
||||
|
||||
**Cause**: Turnstile requesting Private Access Token (not supported by all devices/browsers)
|
||||
|
||||
**Solution**: **Ignore this error** - it's expected behavior
|
||||
|
||||
**Note**: If widget is successfully resolving and generating tokens, no action required.
|
||||
|
||||
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
|
||||
|
||||
---
|
||||
|
||||
## 600*** - Challenge Execution Failure
|
||||
|
||||
**Error**: Visitor failed to solve Turnstile challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Suspected bot behavior
|
||||
- Challenge signals indicate automation
|
||||
- Failing test sitekey (intentional)
|
||||
|
||||
**Solution**:
|
||||
1. Retry automatically
|
||||
2. On continuous failures → likely bot
|
||||
3. Verify visitor by other means
|
||||
4. Consider alternative verification
|
||||
|
||||
**Testing**: Test sitekey `2x00000000000000000000AB` always fails with this error.
|
||||
|
||||
---
|
||||
|
||||
## 600010 - Configuration Error
|
||||
|
||||
**Error**: Widget configuration error
|
||||
|
||||
**Retry**: Depends
|
||||
|
||||
**Cause**:
|
||||
- **Missing hostname in allowlist** (most common)
|
||||
- Hostname was deleted from configuration
|
||||
- Widget misconfigured
|
||||
|
||||
**Solution**:
|
||||
1. Check Cloudflare Dashboard → Turnstile → Widget Settings
|
||||
2. Verify hostname in allowed domains
|
||||
3. Re-add hostname if missing
|
||||
|
||||
**Known Issue**: Hostnames sometimes disappear from dashboard configuration
|
||||
|
||||
**Source**: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578
|
||||
|
||||
---
|
||||
|
||||
## Browser-Specific Issues
|
||||
|
||||
### Brave Browser - Confetti Animation Failure (2025)
|
||||
|
||||
**Error**: Verification fails during success animation
|
||||
|
||||
**Cause**: Brave shields block animation scripts
|
||||
|
||||
**Solution**: Handle success callback before animation completes
|
||||
|
||||
**Source**: https://github.com/brave/brave-browser/issues/45608
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
When encountering errors:
|
||||
|
||||
1. **Check Error Code Family**
|
||||
- 100*: Initialization → Reload page
|
||||
- 110*: Configuration → Check sitekey, domain allowlist
|
||||
- 200*: Client issues → Check cache, CSP, system clock
|
||||
- 300*: Execution → Retry, check browser compatibility
|
||||
- 400*: Invalid input → Fix configuration
|
||||
- 600*: Challenge failure → Check for bot-like behavior
|
||||
|
||||
2. **Common Fixes**
|
||||
- Clear browser cache
|
||||
- Disable browser extensions
|
||||
- Try incognito/private mode
|
||||
- Check CSP headers
|
||||
- Verify system clock
|
||||
- Use test sitekey for development
|
||||
|
||||
3. **Network/Firewall**
|
||||
- Ensure `challenges.cloudflare.com` is accessible
|
||||
- Check for VPN/proxy interference
|
||||
- Verify no firewall blocking
|
||||
|
||||
4. **Code Review**
|
||||
- Server-side validation implemented?
|
||||
- Token expiration handled?
|
||||
- Error callbacks configured?
|
||||
- Using latest `api.js` from CDN?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Most Common Errors**: 110200 (domain), 200500 (CSP), 300030 (crash), 600010 (config)
|
||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# [TODO: Reference Document Name]
|
||||
|
||||
[TODO: This file contains reference documentation that Claude can load when needed.]
|
||||
|
||||
[TODO: Delete this file if you don't have reference documentation to provide.]
|
||||
|
||||
## Purpose
|
||||
|
||||
[TODO: Explain what information this document contains]
|
||||
|
||||
## When Claude Should Use This
|
||||
|
||||
[TODO: Describe specific scenarios where Claude should load this reference]
|
||||
|
||||
## Content
|
||||
|
||||
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
|
||||
|
||||
---
|
||||
|
||||
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
|
||||
- It determines the information is needed
|
||||
- You explicitly ask Claude to reference it
|
||||
- The SKILL.md instructions direct Claude to read it
|
||||
|
||||
Keep this file under 10k words for best performance.
|
||||
550
references/react-integration.md
Normal file
550
references/react-integration.md
Normal file
@@ -0,0 +1,550 @@
|
||||
# React Integration Guide
|
||||
|
||||
**Best practices for integrating Turnstile with React, Next.js, and modern React patterns**
|
||||
|
||||
**Recommended Package**: @marsidev/react-turnstile (Cloudflare-verified)
|
||||
|
||||
---
|
||||
|
||||
## Package Installation
|
||||
|
||||
```bash
|
||||
npm install @marsidev/react-turnstile
|
||||
# or
|
||||
pnpm add @marsidev/react-turnstile
|
||||
# or
|
||||
yarn add @marsidev/react-turnstile
|
||||
```
|
||||
|
||||
**Current Version**: 1.3.1 (September 2025)
|
||||
**React Compatibility**: React 18+, Next.js 13+, 14+, 15+
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ContactForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
return (
|
||||
<form>
|
||||
<input name="email" type="email" required />
|
||||
<textarea name="message" required />
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
/>
|
||||
|
||||
<button disabled={!token}>Submit</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Props Reference
|
||||
|
||||
### Required Props
|
||||
|
||||
#### `siteKey`
|
||||
**Type**: `string`
|
||||
**Description**: Your Turnstile sitekey
|
||||
|
||||
```tsx
|
||||
<Turnstile siteKey="YOUR_SITE_KEY" />
|
||||
```
|
||||
|
||||
### Optional Props
|
||||
|
||||
#### `onSuccess`
|
||||
**Type**: `(token: string) => void`
|
||||
**Description**: Called when challenge succeeds
|
||||
|
||||
```tsx
|
||||
<Turnstile onSuccess={(token) => console.log(token)} />
|
||||
```
|
||||
|
||||
#### `onError`
|
||||
**Type**: `(error: string) => void`
|
||||
**Description**: Called when challenge fails
|
||||
|
||||
```tsx
|
||||
<Turnstile onError={(error) => console.error(error)} />
|
||||
```
|
||||
|
||||
#### `onExpire`
|
||||
**Type**: `() => void`
|
||||
**Description**: Called when token expires (5 min)
|
||||
|
||||
```tsx
|
||||
<Turnstile onExpire={() => setToken(undefined)} />
|
||||
```
|
||||
|
||||
#### `onAbort`
|
||||
**Type**: `() => void`
|
||||
**Description**: Called when challenge is aborted
|
||||
|
||||
#### `options`
|
||||
**Type**: `TurnstileOptions`
|
||||
**Description**: Widget configuration
|
||||
|
||||
```tsx
|
||||
<Turnstile
|
||||
siteKey="..."
|
||||
options={{
|
||||
theme: 'dark',
|
||||
size: 'compact',
|
||||
action: 'login',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using Refs
|
||||
|
||||
Access widget instance for manual control:
|
||||
|
||||
```tsx
|
||||
import { useRef } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
|
||||
export function AdvancedForm() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
function handleReset() {
|
||||
turnstileRef.current?.reset()
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
turnstileRef.current?.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Turnstile ref={turnstileRef} siteKey="..." />
|
||||
<button onClick={handleReset}>Reset</button>
|
||||
<button onClick={handleRemove}>Remove</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next.js App Router
|
||||
|
||||
### Client Component
|
||||
|
||||
```tsx
|
||||
// app/contact/page.tsx
|
||||
'use client'
|
||||
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ContactPage() {
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.get('email'),
|
||||
message: formData.get('message'),
|
||||
'cf-turnstile-response': token,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert('Success!')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="email" type="email" required />
|
||||
<textarea name="message" required />
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onExpire={() => setToken(undefined)}
|
||||
/>
|
||||
|
||||
<button disabled={!token}>Submit</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action (Next.js 14+)
|
||||
|
||||
```tsx
|
||||
'use server'
|
||||
|
||||
import { validateTurnstile } from '@/lib/turnstile'
|
||||
|
||||
export async function submitContact(formData: FormData) {
|
||||
const token = formData.get('cf-turnstile-response')?.toString()
|
||||
|
||||
if (!token) {
|
||||
return { error: 'Missing verification' }
|
||||
}
|
||||
|
||||
const result = await validateTurnstile(token, process.env.TURNSTILE_SECRET_KEY!)
|
||||
|
||||
if (!result.success) {
|
||||
return { error: 'Verification failed' }
|
||||
}
|
||||
|
||||
// Process form
|
||||
return { success: true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next.js Pages Router
|
||||
|
||||
### Page Component
|
||||
|
||||
```tsx
|
||||
// pages/contact.tsx
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ContactPage() {
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
return (
|
||||
<form>
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### API Route
|
||||
|
||||
```typescript
|
||||
// pages/api/contact.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { validateTurnstile } from '@/lib/turnstile'
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const token = req.body['cf-turnstile-response']
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Missing token' })
|
||||
}
|
||||
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
process.env.TURNSTILE_SECRET_KEY!,
|
||||
{
|
||||
remoteip: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(401).json({ error: 'Invalid token' })
|
||||
}
|
||||
|
||||
// Process form
|
||||
res.status(200).json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Hook Pattern
|
||||
|
||||
```tsx
|
||||
// hooks/useTurnstile.ts
|
||||
import { useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
|
||||
export function useTurnstile(siteKey: string) {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const reset = () => {
|
||||
turnstileRef.current?.reset()
|
||||
setToken(undefined)
|
||||
setIsReady(false)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
const TurnstileWidget = () => (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={siteKey}
|
||||
onSuccess={(token) => {
|
||||
setToken(token)
|
||||
setIsReady(true)
|
||||
setError(undefined)
|
||||
}}
|
||||
onError={(err) => {
|
||||
setError(err)
|
||||
setIsReady(false)
|
||||
}}
|
||||
onExpire={() => {
|
||||
setToken(undefined)
|
||||
setIsReady(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
token,
|
||||
isReady,
|
||||
error,
|
||||
reset,
|
||||
TurnstileWidget,
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export function MyForm() {
|
||||
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile(
|
||||
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
)
|
||||
|
||||
return (
|
||||
<form>
|
||||
<TurnstileWidget />
|
||||
{error && <div>Error: {error}</div>}
|
||||
<button disabled={!isReady}>Submit</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jest Testing
|
||||
|
||||
### Mock Setup
|
||||
|
||||
```typescript
|
||||
// jest.setup.ts
|
||||
import React from 'react'
|
||||
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [onSuccess])
|
||||
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
### Component Test
|
||||
|
||||
```tsx
|
||||
// ContactForm.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ContactForm } from './ContactForm'
|
||||
|
||||
test('submits form with Turnstile', async () => {
|
||||
render(<ContactForm />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
|
||||
// Turnstile auto-solves (mocked)
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
expect(await screen.findByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment-Aware Sitekey
|
||||
|
||||
```tsx
|
||||
// lib/turnstile.ts
|
||||
export function useTurnstileSiteKey() {
|
||||
// Test/Development: Use dummy key
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return '1x00000000000000000000AA'
|
||||
}
|
||||
|
||||
// Production: Use real key
|
||||
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
}
|
||||
|
||||
// Usage
|
||||
import { useTurnstileSiteKey } from '@/lib/turnstile'
|
||||
|
||||
export function MyForm() {
|
||||
const siteKey = useTurnstileSiteKey()
|
||||
|
||||
return <Turnstile siteKey={siteKey} />
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Workarounds
|
||||
|
||||
### Issue #112: Next.js + Jest Compatibility (Oct 2025)
|
||||
|
||||
**Problem**: @marsidev/react-turnstile breaks Jest tests
|
||||
|
||||
**Source**: https://github.com/marsidev/react-turnstile/issues/112
|
||||
|
||||
**Workaround**: Mock the component (see Jest Testing section above)
|
||||
|
||||
### Issue #113: Blocked Script Execution (Oct 2025)
|
||||
|
||||
**Problem**: Script execution blocked in some environments
|
||||
|
||||
**Source**: https://github.com/marsidev/react-turnstile/issues/113
|
||||
|
||||
**Workaround**:
|
||||
1. Check CSP headers allow `challenges.cloudflare.com`
|
||||
2. Ensure `api.js` loads from CDN (not proxied)
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
TurnstileInstance,
|
||||
TurnstileProps,
|
||||
TurnstileOptions,
|
||||
} from '@marsidev/react-turnstile'
|
||||
|
||||
// Widget instance methods
|
||||
interface TurnstileInstance {
|
||||
reset(): void
|
||||
remove(): void
|
||||
execute(): void
|
||||
getResponse(): string | undefined
|
||||
isExpired(): boolean
|
||||
}
|
||||
|
||||
// Component props
|
||||
interface TurnstileProps {
|
||||
siteKey: string
|
||||
onSuccess?: (token: string) => void
|
||||
onError?: (error: string) => void
|
||||
onExpire?: () => void
|
||||
onAbort?: () => void
|
||||
options?: TurnstileOptions
|
||||
scriptOptions?: {
|
||||
nonce?: string
|
||||
defer?: boolean
|
||||
async?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Widget options
|
||||
interface TurnstileOptions {
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
action?: string
|
||||
cdata?: string
|
||||
execution?: 'render' | 'execute'
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
retry?: 'auto' | 'never'
|
||||
'retry-interval'?: number
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
```tsx
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const Turnstile = lazy(() =>
|
||||
import('@marsidev/react-turnstile').then(mod => ({ default: mod.Turnstile }))
|
||||
)
|
||||
|
||||
export function LazyTurnstileForm() {
|
||||
return (
|
||||
<form>
|
||||
<Suspense fallback={<div>Loading verification...</div>}>
|
||||
<Turnstile siteKey="..." />
|
||||
</Suspense>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Only render Turnstile when needed:
|
||||
|
||||
```tsx
|
||||
export function ConditionalForm() {
|
||||
const [showTurnstile, setShowTurnstile] = useState(false)
|
||||
|
||||
return (
|
||||
<form>
|
||||
<input onChange={() => setShowTurnstile(true)} />
|
||||
|
||||
{showTurnstile && <Turnstile siteKey="..." />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
✅ **Use environment variables** for sitekeys
|
||||
✅ **Mock in tests** using Jest setup file
|
||||
✅ **Handle expiration** with `onExpire` callback
|
||||
✅ **Disable submit until ready** based on token state
|
||||
✅ **Reset after submission** for multi-use forms
|
||||
✅ **Use TypeScript** for type safety
|
||||
✅ **Lazy load** if not immediately needed
|
||||
|
||||
❌ **Don't hardcode sitekeys** in components
|
||||
❌ **Don't skip error handling** (`onError`)
|
||||
❌ **Don't forget server validation** (critical!)
|
||||
❌ **Don't use production keys in tests**
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Package Version**: @marsidev/react-turnstile@1.3.1
|
||||
**Cloudflare Status**: ✅ Officially Recommended
|
||||
430
references/testing-guide.md
Normal file
430
references/testing-guide.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Turnstile Testing Guide
|
||||
|
||||
**Complete testing strategies for E2E, unit, and integration tests**
|
||||
|
||||
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Dummy Credentials
|
||||
|
||||
### Sitekeys (Client-Side)
|
||||
```typescript
|
||||
const TEST_SITEKEYS = {
|
||||
ALWAYS_PASS: '1x00000000000000000000AA', // Visible, always passes
|
||||
ALWAYS_BLOCK: '2x00000000000000000000AB', // Visible, always blocks
|
||||
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB', // Invisible, always passes
|
||||
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',// Invisible, always blocks
|
||||
FORCE_INTERACTIVE: '3x00000000000000000000FF', // Visible, forces checkbox
|
||||
}
|
||||
```
|
||||
|
||||
### Secret Keys (Server-Side)
|
||||
```typescript
|
||||
const TEST_SECRET_KEYS = {
|
||||
ALWAYS_PASS: '1x0000000000000000000000000000000AA', // success: true
|
||||
ALWAYS_FAIL: '2x0000000000000000000000000000000AA', // success: false
|
||||
TOKEN_SPENT: '3x0000000000000000000000000000000AA', // "already spent" error
|
||||
}
|
||||
```
|
||||
|
||||
### Dummy Token
|
||||
```typescript
|
||||
const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
||||
```
|
||||
|
||||
**CRITICAL**:
|
||||
- Dummy sitekeys generate `XXXX.DUMMY.TOKEN.XXXX`
|
||||
- Dummy secret keys ONLY accept dummy token
|
||||
- Production secret keys REJECT dummy token
|
||||
- Real tokens FAIL with dummy secret keys
|
||||
|
||||
---
|
||||
|
||||
## Environment Detection Patterns
|
||||
|
||||
### Pattern 1: Request Headers
|
||||
```typescript
|
||||
function isTestEnvironment(request: Request): boolean {
|
||||
return request.headers.get('x-test-environment') === 'true'
|
||||
}
|
||||
|
||||
// Usage in Cloudflare Worker
|
||||
if (isTestEnvironment(request)) {
|
||||
secretKey = TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: IP Address
|
||||
```typescript
|
||||
function isTestEnvironment(request: Request): boolean {
|
||||
const ip = request.headers.get('CF-Connecting-IP') || ''
|
||||
const testIPs = ['127.0.0.1', '::1', 'localhost']
|
||||
return testIPs.includes(ip)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Query Parameter
|
||||
```typescript
|
||||
function isTestEnvironment(request: Request): boolean {
|
||||
const url = new URL(request.url)
|
||||
return url.searchParams.get('test') === 'true'
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Environment Variable
|
||||
```typescript
|
||||
const sitekey = process.env.NODE_ENV === 'test'
|
||||
? TEST_SITEKEYS.ALWAYS_PASS
|
||||
: process.env.TURNSTILE_SITE_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Playwright Testing
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
extraHTTPHeaders: {
|
||||
'x-test-environment': 'true', // Auto-use test credentials
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Basic Test
|
||||
```typescript
|
||||
// tests/contact-form.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('submits contact form with Turnstile', async ({ page }) => {
|
||||
await page.goto('/contact')
|
||||
|
||||
// Fill form
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.fill('textarea[name="message"]', 'Test message')
|
||||
|
||||
// Turnstile auto-solves with dummy token in test mode
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Verify success
|
||||
await expect(page.locator('.success-message')).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced: Multiple Scenarios
|
||||
```typescript
|
||||
test('handles Turnstile failure gracefully', async ({ page, context }) => {
|
||||
// Override to use "always fail" sitekey
|
||||
await context.route('**/api.js', route => {
|
||||
const FAIL_SITEKEY = '2x00000000000000000000AB'
|
||||
// Inject failing sitekey
|
||||
route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/contact')
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.error-message')).toContainText('verification failed')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cypress Testing
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
// cypress.config.ts
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5173',
|
||||
setupNodeEvents(on, config) {
|
||||
// Set test header on all requests
|
||||
on('before:browser:launch', (browser, launchOptions) => {
|
||||
launchOptions.args.push('--disable-web-security')
|
||||
return launchOptions
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Test Example
|
||||
```typescript
|
||||
// cypress/e2e/turnstile.cy.ts
|
||||
describe('Turnstile Form', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('**/*', (req) => {
|
||||
req.headers['x-test-environment'] = 'true'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form successfully', () => {
|
||||
cy.visit('/contact')
|
||||
|
||||
cy.get('input[name="email"]').type('test@example.com')
|
||||
cy.get('textarea[name="message"]').type('Test message')
|
||||
|
||||
// Turnstile auto-solves in test mode
|
||||
cy.get('button[type="submit"]').click()
|
||||
|
||||
cy.contains('Success').should('be.visible')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jest / Vitest (React)
|
||||
|
||||
### Mock @marsidev/react-turnstile
|
||||
```typescript
|
||||
// jest.setup.ts or vitest.setup.ts
|
||||
import React from 'react'
|
||||
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
// Auto-solve with dummy token
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [onSuccess])
|
||||
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
### Component Test
|
||||
```typescript
|
||||
// ContactForm.test.tsx
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ContactForm } from './ContactForm'
|
||||
|
||||
test('submits form with Turnstile', async () => {
|
||||
render(<ContactForm />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), {
|
||||
target: { value: 'test@example.com' },
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Message'), {
|
||||
target: { value: 'Test message' },
|
||||
})
|
||||
|
||||
// Turnstile auto-solves (mocked)
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Testing (Vitest)
|
||||
|
||||
### Validate Dummy Token
|
||||
```typescript
|
||||
// server.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateTurnstile } from './turnstile-server-validation'
|
||||
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Turnstile Validation', () => {
|
||||
it('validates dummy token with test secret', async () => {
|
||||
const result = await validateTurnstile(
|
||||
DUMMY_TOKEN,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects real token with test secret', async () => {
|
||||
const realToken = 'real-production-token'
|
||||
|
||||
const result = await validateTurnstile(
|
||||
realToken,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('handles always-fail secret key', async () => {
|
||||
const result = await validateTurnstile(
|
||||
DUMMY_TOKEN,
|
||||
TEST_SECRET_KEYS.ALWAYS_FAIL
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Workers Testing (Miniflare)
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'miniflare',
|
||||
environmentOptions: {
|
||||
bindings: {
|
||||
TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA',
|
||||
TURNSTILE_SITE_KEY: '1x00000000000000000000AA',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Worker Test
|
||||
```typescript
|
||||
// worker.test.ts
|
||||
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import worker from '../src/index'
|
||||
import { DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Worker with Turnstile', () => {
|
||||
it('validates test token successfully', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('email', 'test@example.com')
|
||||
formData.append('cf-turnstile-response', DUMMY_TOKEN)
|
||||
|
||||
const request = new Request('http://localhost/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'x-test-environment': 'true',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Configuration
|
||||
|
||||
### GitHub Actions
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
|
||||
NODE_ENV: test
|
||||
run: npm test
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
test:
|
||||
image: node:20
|
||||
script:
|
||||
- npm ci
|
||||
- npm test
|
||||
variables:
|
||||
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
|
||||
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
|
||||
NODE_ENV: "test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
✅ **Always use dummy keys** - Never use production credentials in tests
|
||||
✅ **Test both success and failure** - Use both pass and fail test keys
|
||||
✅ **Mock in unit tests** - Mock Turnstile component for fast unit tests
|
||||
✅ **E2E with real widget** - Use test sitekeys in E2E tests
|
||||
✅ **Separate environments** - Different config for test/dev/staging/prod
|
||||
✅ **Test expiration** - Verify token expiration handling
|
||||
✅ **Test error states** - Validate error callback behavior
|
||||
|
||||
❌ **Never commit production keys** - Use environment variables
|
||||
❌ **Don't skip server validation tests** - Critical security component
|
||||
❌ **Don't test with production sitekeys** - Can trigger rate limits
|
||||
❌ **Don't hardcode test keys** - Use constants/config files
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
```typescript
|
||||
turnstile.render('#container', {
|
||||
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
|
||||
callback: (token) => console.log('[TEST] Token:', token),
|
||||
'error-callback': (error) => console.error('[TEST] Error:', error),
|
||||
'expired-callback': () => console.warn('[TEST] Token expired'),
|
||||
})
|
||||
```
|
||||
|
||||
### Check Network Requests
|
||||
```typescript
|
||||
// Playwright
|
||||
await page.route('**/siteverify', route => {
|
||||
console.log('Siteverify called with:', route.request().postData())
|
||||
route.continue()
|
||||
})
|
||||
```
|
||||
|
||||
### Verify Environment Detection
|
||||
```typescript
|
||||
test('uses test credentials in test env', async ({ page }) => {
|
||||
const response = await page.evaluate(() => {
|
||||
return document.querySelector('.cf-turnstile')?.getAttribute('data-sitekey')
|
||||
})
|
||||
|
||||
expect(response).toBe('1x00000000000000000000AA')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Test Framework Support**: Playwright, Cypress, Jest, Vitest, Miniflare
|
||||
412
references/widget-configs.md
Normal file
412
references/widget-configs.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Turnstile Widget Configuration Reference
|
||||
|
||||
**Complete reference for all widget configuration options**
|
||||
|
||||
**Official Docs**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations
|
||||
|
||||
---
|
||||
|
||||
## Widget Modes
|
||||
|
||||
### Managed (Recommended)
|
||||
Functions like a Managed Challenge Page. Selects a challenge based on visitor signals.
|
||||
- Shows interactive checkbox only if bot is suspected
|
||||
- Best balance of security and UX
|
||||
- Use for most production deployments
|
||||
|
||||
### Non-Interactive
|
||||
Widget is displayed but visitor never needs to interact.
|
||||
- No checkbox required
|
||||
- Runs challenge in background
|
||||
- Use for minimal user friction
|
||||
|
||||
### Invisible
|
||||
Widget is completely hidden from visitor.
|
||||
- No visual presence
|
||||
- Challenge runs invisibly
|
||||
- Use for seamless UX, API protection
|
||||
|
||||
---
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
### Core Parameters
|
||||
|
||||
#### `sitekey` (Required)
|
||||
- **Type**: `string`
|
||||
- **Description**: Your widget's public sitekey from Cloudflare Dashboard
|
||||
- **Example**: `data-sitekey="YOUR_SITE_KEY"` or `{ sitekey: 'YOUR_SITE_KEY' }`
|
||||
|
||||
#### `action`
|
||||
- **Type**: `string`
|
||||
- **Max Length**: 32 characters
|
||||
- **Valid Characters**: `a-z`, `A-Z`, `0-9`, `-`, `_`
|
||||
- **Description**: Custom action name tracked in analytics
|
||||
- **Example**: `action: 'login'`, `action: 'signup'`
|
||||
|
||||
#### `cdata`
|
||||
- **Type**: `string`
|
||||
- **Max Length**: 255 characters
|
||||
- **Description**: Custom data passed through to server validation
|
||||
- **Example**: `cdata: JSON.stringify({ userId: '123' })`
|
||||
|
||||
---
|
||||
|
||||
## Appearance
|
||||
|
||||
### `theme`
|
||||
Controls widget color scheme.
|
||||
|
||||
- **`auto`** (default) - Matches system preference
|
||||
- **`light`** - Light mode
|
||||
- **`dark`** - Dark mode
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-theme="dark"></div>
|
||||
```
|
||||
```typescript
|
||||
{ theme: 'dark' }
|
||||
```
|
||||
|
||||
### `appearance`
|
||||
Controls when widget becomes visible.
|
||||
|
||||
- **`always`** (default) - Visible from page load
|
||||
- **`execute`** - Visible only after challenge begins
|
||||
- **`interaction-only`** - Visible only when user interaction required
|
||||
|
||||
**Note**: Only affects managed/non-interactive modes. Invisible widgets never show.
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-appearance="interaction-only"></div>
|
||||
```
|
||||
```typescript
|
||||
{ appearance: 'interaction-only' }
|
||||
```
|
||||
|
||||
### `size`
|
||||
Controls widget dimensions.
|
||||
|
||||
- **`normal`** (default) - 300px × 65px
|
||||
- **`compact`** - 150px × 140px
|
||||
- **`flexible`** - 100% width, adapts to container
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-size="compact"></div>
|
||||
```
|
||||
```typescript
|
||||
{ size: 'flexible' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution
|
||||
|
||||
### `execution`
|
||||
Controls when challenge runs and token is generated.
|
||||
|
||||
- **`render`** (default) - Runs automatically after rendering
|
||||
- **`execute`** - Runs only when `turnstile.execute()` is called
|
||||
|
||||
**Use Cases**:
|
||||
- `render`: Standard forms, immediate protection
|
||||
- `execute`: Multi-step forms, conditional verification, performance optimization
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const widgetId = turnstile.render('#container', {
|
||||
sitekey: SITE_KEY,
|
||||
execution: 'execute', // Manual trigger
|
||||
})
|
||||
|
||||
// Later, when needed:
|
||||
turnstile.execute(widgetId)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callbacks
|
||||
|
||||
### `callback`
|
||||
Called when challenge succeeds.
|
||||
|
||||
**Signature**: `(token: string) => void`
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-callback="onSuccess"></div>
|
||||
<script>
|
||||
function onSuccess(token) {
|
||||
console.log('Token:', token)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```typescript
|
||||
{
|
||||
callback: (token) => {
|
||||
console.log('Success:', token)
|
||||
document.getElementById('submit-btn').disabled = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `error-callback`
|
||||
Called when challenge fails or errors occur.
|
||||
|
||||
**Signature**: `(errorCode: string) => void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'error-callback': (error) => {
|
||||
console.error('Turnstile error:', error)
|
||||
showErrorMessage('Verification failed')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `expired-callback`
|
||||
Called when token expires (after 5 minutes).
|
||||
|
||||
**Signature**: `() => void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'expired-callback': () => {
|
||||
console.warn('Token expired')
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `timeout-callback`
|
||||
Called when interactive challenge times out (user didn't interact).
|
||||
|
||||
**Signature**: `() => void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'timeout-callback': () => {
|
||||
console.warn('Challenge timed out')
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Retry Behavior
|
||||
|
||||
### `retry`
|
||||
Controls automatic retry on errors.
|
||||
|
||||
- **`auto`** (default) - Automatically retries on transient errors
|
||||
- **`never`** - No automatic retry, manual control via `turnstile.reset()`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
retry: 'never', // Manual control
|
||||
'error-callback': (error) => {
|
||||
if (shouldRetry(error)) {
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `retry-interval`
|
||||
Milliseconds between automatic retries.
|
||||
|
||||
- **Default**: 8000ms (8 seconds)
|
||||
- **Min**: 0
|
||||
- **Max**: No limit
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
retry: 'auto',
|
||||
'retry-interval': 5000, // 5 seconds
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Configuration Example
|
||||
|
||||
### Implicit Rendering (HTML)
|
||||
|
||||
```html
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="YOUR_SITE_KEY"
|
||||
data-callback="onSuccess"
|
||||
data-error-callback="onError"
|
||||
data-expired-callback="onExpired"
|
||||
data-timeout-callback="onTimeout"
|
||||
data-theme="auto"
|
||||
data-size="normal"
|
||||
data-appearance="always"
|
||||
data-retry="auto"
|
||||
data-retry-interval="8000"
|
||||
data-action="login"
|
||||
data-cdata='{"userId":"123"}'>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Explicit Rendering (TypeScript)
|
||||
|
||||
```typescript
|
||||
const widgetId = turnstile.render('#container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: (token) => console.log('Success:', token),
|
||||
'error-callback': (error) => console.error('Error:', error),
|
||||
'expired-callback': () => turnstile.reset(widgetId),
|
||||
'timeout-callback': () => console.warn('Timeout'),
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
execution: 'render',
|
||||
appearance: 'always',
|
||||
retry: 'auto',
|
||||
'retry-interval': 8000,
|
||||
action: 'login',
|
||||
cdata: JSON.stringify({ userId: '123' }),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Methods
|
||||
|
||||
### `turnstile.render()`
|
||||
Renders a widget programmatically.
|
||||
|
||||
**Signature**:
|
||||
```typescript
|
||||
render(
|
||||
container: string | HTMLElement,
|
||||
options: TurnstileOptions
|
||||
): string // Returns widgetId
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const widgetId = turnstile.render('#my-container', {
|
||||
sitekey: SITE_KEY,
|
||||
callback: handleSuccess,
|
||||
})
|
||||
```
|
||||
|
||||
### `turnstile.reset()`
|
||||
Resets widget to initial state, clears current token.
|
||||
|
||||
**Signature**: `reset(widgetId: string): void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
turnstile.reset(widgetId)
|
||||
```
|
||||
|
||||
### `turnstile.remove()`
|
||||
Completely removes widget from DOM.
|
||||
|
||||
**Signature**: `remove(widgetId: string): void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
turnstile.remove(widgetId)
|
||||
```
|
||||
|
||||
### `turnstile.execute()`
|
||||
Manually triggers challenge (execution: 'execute' mode only).
|
||||
|
||||
**Signature**: `execute(widgetId: string): void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
document.querySelector('#submit').addEventListener('click', () => {
|
||||
turnstile.execute(widgetId)
|
||||
})
|
||||
```
|
||||
|
||||
### `turnstile.getResponse()`
|
||||
Gets current token value.
|
||||
|
||||
**Signature**: `getResponse(widgetId: string): string | undefined`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const token = turnstile.getResponse(widgetId)
|
||||
if (token) {
|
||||
submitForm(token)
|
||||
}
|
||||
```
|
||||
|
||||
### `turnstile.isExpired()`
|
||||
Checks if token has expired.
|
||||
|
||||
**Signature**: `isExpired(widgetId: string): boolean`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
if (turnstile.isExpired(widgetId)) {
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from reCAPTCHA
|
||||
|
||||
Turnstile can be a drop-in replacement for reCAPTCHA v2.
|
||||
|
||||
### Compatibility Mode
|
||||
|
||||
Add `?compat=recaptcha` to script URL:
|
||||
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Implicit rendering for reCAPTCHA
|
||||
- `g-recaptcha-response` input name
|
||||
- Registers API as `grecaptcha`
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<!-- Old reCAPTCHA code -->
|
||||
<div class="g-recaptcha" data-sitekey="OLD_RECAPTCHA_KEY"></div>
|
||||
|
||||
<!-- New Turnstile code (compatibility mode) -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||
<div class="g-recaptcha" data-sitekey="NEW_TURNSTILE_KEY"></div>
|
||||
```
|
||||
|
||||
**Note**: Change script URL and sitekey, everything else stays the same.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
✅ **Always validate server-side** - Client widget is not sufficient
|
||||
✅ **Handle expiration** - Implement `expired-callback` to reset widget
|
||||
✅ **Handle errors** - Use `error-callback` for user-friendly messages
|
||||
✅ **Use actions** - Track different form types in analytics
|
||||
✅ **Test with dummy keys** - Use `1x00000000000000000000AA` for development
|
||||
✅ **Separate environments** - Different widgets for dev/staging/production
|
||||
|
||||
❌ **Never proxy api.js** - Must load from Cloudflare CDN
|
||||
❌ **Never reuse tokens** - Each token is single-use
|
||||
❌ **Never expose secret key** - Keep in backend only
|
||||
❌ **Never skip expiration handling** - Tokens expire after 5 minutes
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**API Version**: v0 (stable)
|
||||
132
scripts/check-csp.sh
Executable file
132
scripts/check-csp.sh
Executable file
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Turnstile CSP Checker
|
||||
#
|
||||
# Verifies that Content Security Policy headers allow Turnstile to function
|
||||
#
|
||||
# Usage:
|
||||
# ./check-csp.sh https://example.com
|
||||
# ./check-csp.sh https://example.com/contact
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if URL is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${RED}Error: Please provide a URL${NC}"
|
||||
echo "Usage: $0 <url>"
|
||||
echo "Example: $0 https://example.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
URL="$1"
|
||||
|
||||
echo -e "${GREEN}Checking CSP for Turnstile compatibility...${NC}"
|
||||
echo "URL: $URL"
|
||||
echo ""
|
||||
|
||||
# Fetch headers
|
||||
HEADERS=$(curl -sI "$URL")
|
||||
|
||||
# Extract CSP header
|
||||
CSP=$(echo "$HEADERS" | grep -i "content-security-policy:" | sed 's/content-security-policy: //I')
|
||||
|
||||
if [ -z "$CSP" ]; then
|
||||
echo -e "${YELLOW}No Content-Security-Policy header found${NC}"
|
||||
echo "✅ Turnstile should work (no CSP restrictions)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CSP Header found:"
|
||||
echo "$CSP"
|
||||
echo ""
|
||||
|
||||
# Initialize pass/fail counters
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Check script-src
|
||||
echo -e "${GREEN}Checking script-src...${NC}"
|
||||
if echo "$CSP" | grep -q "script-src"; then
|
||||
if echo "$CSP" | grep -E "script-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
|
||||
echo "✅ script-src allows https://challenges.cloudflare.com"
|
||||
((PASS++))
|
||||
elif echo "$CSP" | grep -E "script-src[^;]*\*" > /dev/null; then
|
||||
echo "✅ script-src allows * (wildcard)"
|
||||
((PASS++))
|
||||
else
|
||||
echo -e "${RED}❌ script-src does NOT allow https://challenges.cloudflare.com${NC}"
|
||||
echo " Add: script-src https://challenges.cloudflare.com;"
|
||||
((FAIL++))
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No script-src directive found (defaults may apply)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check frame-src
|
||||
echo -e "${GREEN}Checking frame-src...${NC}"
|
||||
if echo "$CSP" | grep -q "frame-src"; then
|
||||
if echo "$CSP" | grep -E "frame-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
|
||||
echo "✅ frame-src allows https://challenges.cloudflare.com"
|
||||
((PASS++))
|
||||
elif echo "$CSP" | grep -E "frame-src[^;]*\*" > /dev/null; then
|
||||
echo "✅ frame-src allows * (wildcard)"
|
||||
((PASS++))
|
||||
else
|
||||
echo -e "${RED}❌ frame-src does NOT allow https://challenges.cloudflare.com${NC}"
|
||||
echo " Add: frame-src https://challenges.cloudflare.com;"
|
||||
((FAIL++))
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No frame-src directive found (defaults may apply)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check connect-src
|
||||
echo -e "${GREEN}Checking connect-src...${NC}"
|
||||
if echo "$CSP" | grep -q "connect-src"; then
|
||||
if echo "$CSP" | grep -E "connect-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
|
||||
echo "✅ connect-src allows https://challenges.cloudflare.com"
|
||||
((PASS++))
|
||||
elif echo "$CSP" | grep -E "connect-src[^;]*\*" > /dev/null; then
|
||||
echo "✅ connect-src allows * (wildcard)"
|
||||
((PASS++))
|
||||
else
|
||||
echo -e "${RED}❌ connect-src does NOT allow https://challenges.cloudflare.com${NC}"
|
||||
echo " Add: connect-src https://challenges.cloudflare.com;"
|
||||
((FAIL++))
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No connect-src directive found (defaults may apply)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Final verdict
|
||||
echo "========================================"
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ CSP is compatible with Turnstile!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ CSP may block Turnstile${NC}"
|
||||
echo ""
|
||||
echo "Recommended CSP directives:"
|
||||
echo ""
|
||||
echo "<meta http-equiv=\"Content-Security-Policy\" content=\""
|
||||
echo " script-src 'self' https://challenges.cloudflare.com;"
|
||||
echo " frame-src 'self' https://challenges.cloudflare.com;"
|
||||
echo " connect-src 'self' https://challenges.cloudflare.com;"
|
||||
echo "\">"
|
||||
echo ""
|
||||
echo "Or in HTTP header:"
|
||||
echo ""
|
||||
echo "Content-Security-Policy: script-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
15
scripts/example-script.sh
Executable file
15
scripts/example-script.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# [TODO: Script Name]
|
||||
# [TODO: Brief description of what this script does]
|
||||
|
||||
# Example script structure - delete if not needed
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# [TODO: Add your script logic here]
|
||||
|
||||
echo "Example script - replace or delete this file"
|
||||
|
||||
# Usage:
|
||||
# ./scripts/example-script.sh [args]
|
||||
248
templates/turnstile-hono-route.ts
Normal file
248
templates/turnstile-hono-route.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Turnstile + Hono Route Handlers
|
||||
*
|
||||
* Complete examples for integrating Turnstile validation
|
||||
* with Hono API routes in Cloudflare Workers
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { validateTurnstile, type TurnstileResponse } from './turnstile-server-validation'
|
||||
|
||||
/**
|
||||
* Environment Bindings
|
||||
*/
|
||||
type Bindings = {
|
||||
TURNSTILE_SECRET_KEY: string
|
||||
TURNSTILE_SITE_KEY: string
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>()
|
||||
|
||||
/**
|
||||
* Example 1: Simple Contact Form
|
||||
*/
|
||||
app.post('/api/contact', async (c) => {
|
||||
try {
|
||||
const body = await c.req.formData()
|
||||
const token = body.get('cf-turnstile-response')
|
||||
|
||||
if (!token) {
|
||||
return c.text('Missing Turnstile token', 400)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const verifyFormData = new FormData()
|
||||
verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)
|
||||
verifyFormData.append('response', token.toString())
|
||||
verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')
|
||||
|
||||
const verifyResult = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: verifyFormData,
|
||||
}
|
||||
)
|
||||
|
||||
const outcome = await verifyResult.json<{ success: boolean }>()
|
||||
|
||||
if (!outcome.success) {
|
||||
return c.text('Invalid Turnstile token', 401)
|
||||
}
|
||||
|
||||
// Process contact form
|
||||
const email = body.get('email')?.toString()
|
||||
const message = body.get('message')?.toString()
|
||||
|
||||
console.log('Contact form submitted:', { email, message })
|
||||
|
||||
// Your business logic here (send email, save to DB, etc.)
|
||||
|
||||
return c.json({ message: 'Contact form submitted successfully' })
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error)
|
||||
return c.text('Internal server error', 500)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 2: Login with Turnstile
|
||||
*/
|
||||
app.post('/api/auth/login', async (c) => {
|
||||
try {
|
||||
const { username, password, 'cf-turnstile-response': token } = await c.req.json()
|
||||
|
||||
// Validate Turnstile token first
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
c.env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: c.req.header('CF-Connecting-IP'),
|
||||
expectedAction: 'login',
|
||||
expectedHostname: new URL(c.req.url).hostname,
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Invalid Turnstile token',
|
||||
codes: result['error-codes'],
|
||||
},
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials (example - use proper auth in production)
|
||||
if (!username || !password) {
|
||||
return c.json({ error: 'Missing credentials' }, 400)
|
||||
}
|
||||
|
||||
// Check credentials against database
|
||||
// const user = await db.query('SELECT * FROM users WHERE username = ?', [username])
|
||||
|
||||
// Create session token
|
||||
// const sessionToken = await createSession(user.id)
|
||||
|
||||
return c.json({
|
||||
message: 'Login successful',
|
||||
// token: sessionToken,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return c.json({ error: 'Login failed' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 3: Signup with Turnstile + Rate Limiting
|
||||
*/
|
||||
app.post('/api/auth/signup', async (c) => {
|
||||
try {
|
||||
const { email, password, 'cf-turnstile-response': token } = await c.req.json()
|
||||
|
||||
// Validate Turnstile
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
c.env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: c.req.header('CF-Connecting-IP'),
|
||||
expectedAction: 'signup',
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bot detection failed' }, 401)
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return c.json({ error: 'Missing required fields' }, 400)
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
// const existingUser = await db.query('SELECT id FROM users WHERE email = ?', [email])
|
||||
// if (existingUser) {
|
||||
// return c.json({ error: 'User already exists' }, 409)
|
||||
// }
|
||||
|
||||
// Create user
|
||||
// const hashedPassword = await hashPassword(password)
|
||||
// await db.query('INSERT INTO users (email, password) VALUES (?, ?)', [email, hashedPassword])
|
||||
|
||||
return c.json({
|
||||
message: 'Signup successful',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error)
|
||||
return c.json({ error: 'Signup failed' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 4: Middleware for Turnstile Validation
|
||||
*/
|
||||
async function turnstileMiddleware(c: any, next: () => Promise<void>) {
|
||||
const contentType = c.req.header('Content-Type')
|
||||
|
||||
let token: string | null = null
|
||||
|
||||
// Get token from FormData or JSON
|
||||
if (contentType?.includes('multipart/form-data') || contentType?.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await c.req.formData()
|
||||
token = formData.get('cf-turnstile-response')?.toString() || null
|
||||
} else if (contentType?.includes('application/json')) {
|
||||
const body = await c.req.json()
|
||||
token = body['cf-turnstile-response'] || null
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: 'Missing Turnstile token' }, 400)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
c.env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: c.req.header('CF-Connecting-IP'),
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({
|
||||
error: 'Turnstile validation failed',
|
||||
codes: result['error-codes'],
|
||||
}, 401)
|
||||
}
|
||||
|
||||
// Store result in context for route handler
|
||||
c.set('turnstileResult', result)
|
||||
|
||||
await next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 5: Using Middleware
|
||||
*/
|
||||
app.post('/api/protected/action', turnstileMiddleware, async (c) => {
|
||||
const turnstileResult = c.get('turnstileResult') as TurnstileResponse
|
||||
|
||||
console.log('Turnstile validated:', turnstileResult)
|
||||
|
||||
// Your protected action here
|
||||
return c.json({ message: 'Action completed successfully' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 6: Get Sitekey Endpoint (for frontend)
|
||||
*/
|
||||
app.get('/api/turnstile/sitekey', (c) => {
|
||||
return c.json({
|
||||
sitekey: c.env.TURNSTILE_SITE_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 7: Health Check (without Turnstile)
|
||||
*/
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 8: CORS for Turnstile
|
||||
*/
|
||||
import { cors } from 'hono/cors'
|
||||
|
||||
app.use('/api/*', cors({
|
||||
origin: ['https://yourdomain.com', 'http://localhost:5173'],
|
||||
allowMethods: ['POST', 'GET', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type'],
|
||||
credentials: true,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Export
|
||||
*/
|
||||
export default app
|
||||
353
templates/turnstile-react-component.tsx
Normal file
353
templates/turnstile-react-component.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Turnstile React Component
|
||||
*
|
||||
* Uses @marsidev/react-turnstile (Cloudflare recommended)
|
||||
* npm install @marsidev/react-turnstile
|
||||
*/
|
||||
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Basic Example: Contact Form with Turnstile
|
||||
*/
|
||||
export function ContactForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!token) {
|
||||
setError('Please complete the verification')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(undefined)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
formData.append('cf-turnstile-response', token)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
setError(`Submission failed: ${errorText}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
alert('Message sent successfully!')
|
||||
e.currentTarget.reset()
|
||||
setToken(undefined)
|
||||
} catch (err) {
|
||||
setError(`Network error: ${err.message}`)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={() => setError('Verification failed')}
|
||||
onExpire={() => setToken(undefined)}
|
||||
/>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={!token || isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: With Ref for Manual Control
|
||||
*/
|
||||
export function AdvancedTurnstileForm() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
function handleReset() {
|
||||
// Reset the Turnstile widget
|
||||
turnstileRef.current?.reset()
|
||||
setToken(undefined)
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
// Completely remove the widget
|
||||
turnstileRef.current?.remove()
|
||||
setToken(undefined)
|
||||
}
|
||||
|
||||
function handleExecute() {
|
||||
// Manually trigger challenge (execution: 'execute' mode only)
|
||||
turnstileRef.current?.execute()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={(error) => console.error('Turnstile error:', error)}
|
||||
options={{
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
execution: 'render', // or 'execute' for manual trigger
|
||||
action: 'login',
|
||||
retry: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button onClick={handleReset}>Reset Widget</button>
|
||||
<button onClick={handleRemove}>Remove Widget</button>
|
||||
<button onClick={handleExecute}>Execute Challenge</button>
|
||||
</div>
|
||||
|
||||
{token && <div>Token: {token}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js App Router Example (Client Component)
|
||||
*/
|
||||
'use client'
|
||||
|
||||
export function LoginForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [formError, setFormError] = useState<string>()
|
||||
|
||||
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!token) {
|
||||
setFormError('Please complete the challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
'cf-turnstile-response': token,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
setFormError(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect on success
|
||||
window.location.href = '/dashboard'
|
||||
} catch (err) {
|
||||
setFormError(`Login failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={() => setFormError('Verification failed')}
|
||||
onExpire={() => setToken(undefined)}
|
||||
options={{
|
||||
theme: 'auto',
|
||||
action: 'login',
|
||||
}}
|
||||
/>
|
||||
|
||||
{formError && <div className="error">{formError}</div>}
|
||||
|
||||
<button type="submit" disabled={!token}>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Testing Example: Mock for Jest
|
||||
*
|
||||
* Add to jest.setup.ts:
|
||||
*/
|
||||
/*
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
// Auto-solve with dummy token
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [])
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment-Aware Sitekey
|
||||
*
|
||||
* Use dummy keys for development/testing
|
||||
*/
|
||||
export function useT turnstileSiteKey() {
|
||||
// Development/Test: Use dummy sitekey
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return '1x00000000000000000000AA' // Always passes
|
||||
}
|
||||
|
||||
// Production: Use real sitekey
|
||||
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
}
|
||||
|
||||
/**
|
||||
* Example with Environment-Aware Sitekey
|
||||
*/
|
||||
export function SmartTurnstileForm() {
|
||||
const siteKey = useTurnstileSiteKey()
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* Form fields here */}
|
||||
|
||||
<Turnstile
|
||||
siteKey={siteKey}
|
||||
onSuccess={setToken}
|
||||
onError={(error) => console.error(error)}
|
||||
/>
|
||||
|
||||
<button type="submit" disabled={!token}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Hook: useTurnstile
|
||||
*/
|
||||
export function useTurnstile() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const reset = () => {
|
||||
turnstileRef.current?.reset()
|
||||
setToken(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
const TurnstileWidget = () => (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={useTurnstileSiteKey()}
|
||||
onSuccess={(token) => {
|
||||
setToken(token)
|
||||
setIsReady(true)
|
||||
setError(undefined)
|
||||
}}
|
||||
onError={(err) => {
|
||||
setError(err)
|
||||
setIsReady(false)
|
||||
}}
|
||||
onExpire={() => {
|
||||
setToken(undefined)
|
||||
setIsReady(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
token,
|
||||
isReady,
|
||||
error,
|
||||
reset,
|
||||
TurnstileWidget,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage of useTurnstile Hook
|
||||
*/
|
||||
export function FormWithHook() {
|
||||
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
// Use token for submission
|
||||
console.log('Token:', token)
|
||||
// Reset after submission
|
||||
reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Form fields */}
|
||||
|
||||
<TurnstileWidget />
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={!isReady}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
240
templates/turnstile-server-validation.ts
Normal file
240
templates/turnstile-server-validation.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Turnstile Server-Side Validation
|
||||
*
|
||||
* CRITICAL: Server-side validation is MANDATORY.
|
||||
* Client-side widget alone does NOT provide security.
|
||||
*
|
||||
* Tokens:
|
||||
* - Expire after 5 minutes (300 seconds)
|
||||
* - Are single-use only
|
||||
* - Can be forged by attackers (must validate on server)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Siteverify API Response
|
||||
*/
|
||||
export interface TurnstileResponse {
|
||||
success: boolean
|
||||
challenge_ts?: string // ISO 8601 timestamp
|
||||
hostname?: string // Hostname where challenge was solved
|
||||
'error-codes'?: string[]
|
||||
action?: string // Custom action if specified
|
||||
cdata?: string // Custom data if specified
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Options
|
||||
*/
|
||||
export interface ValidationOptions {
|
||||
remoteip?: string
|
||||
idempotency_key?: string
|
||||
expectedAction?: string
|
||||
expectedHostname?: string
|
||||
timeout?: number // milliseconds (default: 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Turnstile Token
|
||||
*
|
||||
* @param token - The token from cf-turnstile-response
|
||||
* @param secretKey - Your Turnstile secret key (from environment variable)
|
||||
* @param options - Optional validation parameters
|
||||
* @returns Promise<TurnstileResponse>
|
||||
*/
|
||||
export async function validateTurnstile(
|
||||
token: string,
|
||||
secretKey: string,
|
||||
options?: ValidationOptions
|
||||
): Promise<TurnstileResponse> {
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['missing-input-response'],
|
||||
}
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['missing-input-secret'],
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare request body
|
||||
const formData = new FormData()
|
||||
formData.append('secret', secretKey)
|
||||
formData.append('response', token)
|
||||
|
||||
if (options?.remoteip) {
|
||||
formData.append('remoteip', options.remoteip)
|
||||
}
|
||||
|
||||
if (options?.idempotency_key) {
|
||||
formData.append('idempotency_key', options.idempotency_key)
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), options?.timeout || 5000)
|
||||
|
||||
try {
|
||||
// Call Siteverify API
|
||||
const response = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
|
||||
const result = await response.json<TurnstileResponse>()
|
||||
|
||||
// Additional validation checks
|
||||
if (result.success) {
|
||||
// Validate action if specified
|
||||
if (options?.expectedAction && result.action !== options.expectedAction) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['action-mismatch'],
|
||||
}
|
||||
}
|
||||
|
||||
// Validate hostname if specified
|
||||
if (options?.expectedHostname && result.hostname !== options.expectedHostname) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['hostname-mismatch'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['timeout'],
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Turnstile validation error:', error)
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['internal-error'],
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers Example
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const token = formData.get('cf-turnstile-response')
|
||||
|
||||
if (!token) {
|
||||
return new Response('Missing Turnstile token', { status: 400 })
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const result = await validateTurnstile(
|
||||
token.toString(),
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: request.headers.get('CF-Connecting-IP') || undefined,
|
||||
expectedHostname: new URL(request.url).hostname,
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Turnstile validation failed:', result['error-codes'])
|
||||
return new Response('Invalid Turnstile token', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Token is valid - process the form
|
||||
const email = formData.get('email')
|
||||
const message = formData.get('message')
|
||||
|
||||
// Your business logic here
|
||||
console.log('Form submitted:', { email, message })
|
||||
|
||||
return new Response('Success!', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Request handling error:', error)
|
||||
return new Response('Internal server error', { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Validation with Retry Logic
|
||||
*/
|
||||
export async function validateWithRetry(
|
||||
token: string,
|
||||
secretKey: string,
|
||||
options?: ValidationOptions,
|
||||
maxRetries: number = 3
|
||||
): Promise<TurnstileResponse> {
|
||||
let lastError: TurnstileResponse | null = null
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const result = await validateTurnstile(token, secretKey, options)
|
||||
|
||||
if (result.success) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Don't retry on permanent errors
|
||||
const permanentErrors = [
|
||||
'missing-input-secret',
|
||||
'invalid-input-secret',
|
||||
'missing-input-response',
|
||||
'invalid-input-response',
|
||||
'action-mismatch',
|
||||
'hostname-mismatch',
|
||||
]
|
||||
|
||||
if (
|
||||
result['error-codes']?.some((code) => permanentErrors.includes(code))
|
||||
) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Retry on transient errors
|
||||
lastError = result
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)))
|
||||
}
|
||||
}
|
||||
|
||||
return lastError || { success: false, 'error-codes': ['max-retries-exceeded'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type Definitions for Cloudflare Workers
|
||||
*/
|
||||
export interface Env {
|
||||
TURNSTILE_SECRET_KEY: string
|
||||
TURNSTILE_SITE_KEY: string
|
||||
// Add other environment variables here
|
||||
}
|
||||
311
templates/turnstile-test-config.ts
Normal file
311
templates/turnstile-test-config.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Turnstile Testing Configuration
|
||||
*
|
||||
* Use dummy sitekeys and secret keys for automated testing
|
||||
* to avoid hitting rate limits and ensure predictable behavior.
|
||||
*
|
||||
* Official Docs: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dummy Sitekeys (Client-Side)
|
||||
*
|
||||
* These can be used from any domain, including localhost.
|
||||
* Production secret keys will reject dummy tokens.
|
||||
*/
|
||||
export const TEST_SITEKEYS = {
|
||||
/** Always passes - visible widget */
|
||||
ALWAYS_PASS: '1x00000000000000000000AA',
|
||||
|
||||
/** Always blocks - visible widget */
|
||||
ALWAYS_BLOCK: '2x00000000000000000000AB',
|
||||
|
||||
/** Always passes - invisible widget */
|
||||
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB',
|
||||
|
||||
/** Always blocks - invisible widget */
|
||||
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',
|
||||
|
||||
/** Forces an interactive challenge - visible widget */
|
||||
FORCE_INTERACTIVE: '3x00000000000000000000FF',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Dummy Secret Keys (Server-Side)
|
||||
*
|
||||
* These only accept the dummy token XXXX.DUMMY.TOKEN.XXXX
|
||||
* Real tokens will fail validation with these keys.
|
||||
*/
|
||||
export const TEST_SECRET_KEYS = {
|
||||
/** Always returns success: true */
|
||||
ALWAYS_PASS: '1x0000000000000000000000000000000AA',
|
||||
|
||||
/** Always returns success: false */
|
||||
ALWAYS_FAIL: '2x0000000000000000000000000000000AA',
|
||||
|
||||
/** Returns "token already spent" error */
|
||||
TOKEN_SPENT: '3x0000000000000000000000000000000AA',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Dummy Token
|
||||
*
|
||||
* Generated by test sitekeys.
|
||||
* Only valid with test secret keys.
|
||||
*/
|
||||
export const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
||||
|
||||
/**
|
||||
* Environment Detection
|
||||
*
|
||||
* Helper to determine if we're in a test environment
|
||||
*/
|
||||
export function isTestEnvironment(request: Request): boolean {
|
||||
// Check for test headers
|
||||
if (request.headers.get('x-test-environment') === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for localhost/test IPs
|
||||
const ip = request.headers.get('CF-Connecting-IP') || ''
|
||||
const testIPs = ['127.0.0.1', '::1', 'localhost']
|
||||
if (testIPs.includes(ip)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for test query parameter
|
||||
const url = new URL(request.url)
|
||||
if (url.searchParams.get('test') === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Test Credentials
|
||||
*
|
||||
* Returns test or production credentials based on environment
|
||||
*/
|
||||
export function getTurnstileCredentials(
|
||||
request: Request,
|
||||
env: {
|
||||
TURNSTILE_SITE_KEY?: string
|
||||
TURNSTILE_SECRET_KEY?: string
|
||||
}
|
||||
) {
|
||||
if (isTestEnvironment(request)) {
|
||||
return {
|
||||
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
|
||||
secretKey: TEST_SECRET_KEYS.ALWAYS_PASS,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sitekey: env.TURNSTILE_SITE_KEY || '',
|
||||
secretKey: env.TURNSTILE_SECRET_KEY || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright Test Example
|
||||
*/
|
||||
/*
|
||||
// playwright.config.ts
|
||||
export default {
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
extraHTTPHeaders: {
|
||||
'x-test-environment': 'true',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// test/turnstile.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('form submission with Turnstile', async ({ page }) => {
|
||||
await page.goto('/contact')
|
||||
|
||||
// Fill form
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.fill('textarea[name="message"]', 'Test message')
|
||||
|
||||
// Turnstile auto-solves with dummy token in test mode
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Verify success
|
||||
await expect(page.locator('.success-message')).toBeVisible()
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cypress Test Example
|
||||
*/
|
||||
/*
|
||||
// cypress/e2e/turnstile.cy.ts
|
||||
describe('Turnstile Form', () => {
|
||||
beforeEach(() => {
|
||||
// Set test header
|
||||
cy.intercept('**', (req) => {
|
||||
req.headers['x-test-environment'] = 'true'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form successfully', () => {
|
||||
cy.visit('/contact')
|
||||
|
||||
cy.get('input[name="email"]').type('test@example.com')
|
||||
cy.get('textarea[name="message"]').type('Test message')
|
||||
|
||||
// Turnstile auto-solves in test mode
|
||||
cy.get('button[type="submit"]').click()
|
||||
|
||||
cy.contains('Success').should('be.visible')
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Jest Mock Example
|
||||
*/
|
||||
/*
|
||||
// jest.setup.ts
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
// Auto-solve with dummy token
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [onSuccess])
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
|
||||
// component.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ContactForm } from './ContactForm'
|
||||
|
||||
test('submits form with Turnstile', async () => {
|
||||
render(<ContactForm />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), {
|
||||
target: { value: 'test@example.com' },
|
||||
})
|
||||
|
||||
// Turnstile auto-solves
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
expect(await screen.findByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Server-Side Test Example (Vitest)
|
||||
*/
|
||||
/*
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { validateTurnstile } from './turnstile-server-validation'
|
||||
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Turnstile Validation', () => {
|
||||
it('validates dummy token with test secret', async () => {
|
||||
const result = await validateTurnstile(
|
||||
DUMMY_TOKEN,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects real token with test secret', async () => {
|
||||
const realToken = 'some-real-token-from-production'
|
||||
|
||||
const result = await validateTurnstile(
|
||||
realToken,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment-Aware Component (React)
|
||||
*/
|
||||
/*
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { TEST_SITEKEYS } from './turnstile-test-config'
|
||||
|
||||
function MyForm() {
|
||||
const sitekey = process.env.NODE_ENV === 'test'
|
||||
? TEST_SITEKEYS.ALWAYS_PASS
|
||||
: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
|
||||
return (
|
||||
<form>
|
||||
<Turnstile
|
||||
siteKey={sitekey}
|
||||
onSuccess={(token) => console.log('Token:', token)}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cloudflare Workers Test Example
|
||||
*/
|
||||
/*
|
||||
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import worker from '../src/index'
|
||||
import { DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Worker with Turnstile', () => {
|
||||
it('validates test token', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('email', 'test@example.com')
|
||||
formData.append('cf-turnstile-response', DUMMY_TOKEN)
|
||||
|
||||
const request = new Request('http://localhost/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'x-test-environment': 'true',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* CI/CD Environment Variables
|
||||
*
|
||||
* Set these in your CI/CD pipeline:
|
||||
*/
|
||||
/*
|
||||
# GitHub Actions
|
||||
env:
|
||||
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
|
||||
|
||||
# GitLab CI
|
||||
variables:
|
||||
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
|
||||
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
|
||||
*/
|
||||
|
||||
export default {
|
||||
TEST_SITEKEYS,
|
||||
TEST_SECRET_KEYS,
|
||||
DUMMY_TOKEN,
|
||||
isTestEnvironment,
|
||||
getTurnstileCredentials,
|
||||
}
|
||||
262
templates/turnstile-widget-explicit.ts
Normal file
262
templates/turnstile-widget-explicit.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Turnstile Widget - Explicit Rendering
|
||||
*
|
||||
* Use explicit rendering when you need programmatic control over:
|
||||
* - When the widget renders
|
||||
* - Widget lifecycle (reset, remove)
|
||||
* - Multiple widgets on the same page
|
||||
* - Dynamic UI / Single Page Applications
|
||||
*/
|
||||
|
||||
declare const turnstile: {
|
||||
render: (container: string | HTMLElement, options: TurnstileOptions) => string
|
||||
reset: (widgetId: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
execute: (widgetId: string) => void
|
||||
getResponse: (widgetId: string) => string | undefined
|
||||
isExpired: (widgetId: string) => boolean
|
||||
}
|
||||
|
||||
interface TurnstileOptions {
|
||||
sitekey: string
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: (error: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'timeout-callback'?: () => void
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'flexible' | 'compact'
|
||||
execution?: 'render' | 'execute'
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
retry?: 'auto' | 'never'
|
||||
'retry-interval'?: number
|
||||
action?: string
|
||||
cdata?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TurnstileManager - Lifecycle management wrapper
|
||||
*/
|
||||
export class TurnstileManager {
|
||||
private widgetId: string | null = null
|
||||
private sitekey: string
|
||||
|
||||
constructor(sitekey: string) {
|
||||
this.sitekey = sitekey
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Turnstile widget
|
||||
*/
|
||||
render(
|
||||
containerId: string,
|
||||
callbacks: {
|
||||
onSuccess: (token: string) => void
|
||||
onError: (error: string) => void
|
||||
onExpired?: () => void
|
||||
},
|
||||
options?: Partial<TurnstileOptions>
|
||||
): string {
|
||||
// Reset if already rendered
|
||||
if (this.widgetId !== null) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
this.widgetId = turnstile.render(containerId, {
|
||||
sitekey: this.sitekey,
|
||||
callback: callbacks.onSuccess,
|
||||
'error-callback': callbacks.onError,
|
||||
'expired-callback': callbacks.onExpired || (() => this.reset()),
|
||||
theme: options?.theme || 'auto',
|
||||
size: options?.size || 'normal',
|
||||
execution: options?.execution || 'render',
|
||||
appearance: options?.appearance || 'always',
|
||||
retry: options?.retry || 'auto',
|
||||
action: options?.action,
|
||||
cdata: options?.cdata,
|
||||
})
|
||||
|
||||
return this.widgetId
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the widget (clears current state)
|
||||
*/
|
||||
reset(): void {
|
||||
if (this.widgetId !== null) {
|
||||
turnstile.reset(this.widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the widget completely
|
||||
*/
|
||||
remove(): void {
|
||||
if (this.widgetId !== null) {
|
||||
turnstile.remove(this.widgetId)
|
||||
this.widgetId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger challenge (execution: 'execute' mode only)
|
||||
*/
|
||||
execute(): void {
|
||||
if (this.widgetId !== null) {
|
||||
turnstile.execute(this.widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token
|
||||
*/
|
||||
getToken(): string | undefined {
|
||||
if (this.widgetId === null) return undefined
|
||||
return turnstile.getResponse(this.widgetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
isExpired(): boolean {
|
||||
if (this.widgetId === null) return true
|
||||
return turnstile.isExpired(this.widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage Example
|
||||
*/
|
||||
export function initializeTurnstile() {
|
||||
const SITE_KEY = 'YOUR_SITE_KEY' // Replace with actual sitekey
|
||||
|
||||
const manager = new TurnstileManager(SITE_KEY)
|
||||
|
||||
// Render widget when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
manager.render(
|
||||
'#turnstile-container',
|
||||
{
|
||||
onSuccess: (token) => {
|
||||
console.log('Turnstile success:', token)
|
||||
// Enable submit button
|
||||
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
|
||||
if (submitBtn) submitBtn.disabled = false
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Turnstile error:', error)
|
||||
// Show error message
|
||||
const errorDiv = document.querySelector('#error-message')
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = 'Verification failed. Please try again.'
|
||||
}
|
||||
},
|
||||
onExpired: () => {
|
||||
console.warn('Turnstile token expired')
|
||||
// Disable submit button
|
||||
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
|
||||
if (submitBtn) submitBtn.disabled = true
|
||||
},
|
||||
},
|
||||
{
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
action: 'login', // Optional: track action in analytics
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Example: Reset on form submission
|
||||
const form = document.querySelector('#myForm') as HTMLFormElement
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const token = manager.getToken()
|
||||
if (!token || manager.isExpired()) {
|
||||
alert('Please complete the verification')
|
||||
return
|
||||
}
|
||||
|
||||
// Submit form with token
|
||||
const formData = new FormData(form)
|
||||
formData.append('cf-turnstile-response', token)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert('Success!')
|
||||
form.reset()
|
||||
manager.reset() // Reset Turnstile for next submission
|
||||
} else {
|
||||
alert('Submission failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error)
|
||||
alert('Network error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Manual Execution Mode
|
||||
*/
|
||||
export function manualExecutionExample() {
|
||||
const manager = new TurnstileManager('YOUR_SITE_KEY')
|
||||
|
||||
// Render in manual execution mode
|
||||
manager.render(
|
||||
'#turnstile-container',
|
||||
{
|
||||
onSuccess: (token) => {
|
||||
console.log('Challenge complete:', token)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Challenge failed:', error)
|
||||
},
|
||||
},
|
||||
{
|
||||
execution: 'execute', // Manual trigger
|
||||
appearance: 'interaction-only', // Show only when needed
|
||||
}
|
||||
)
|
||||
|
||||
// Trigger challenge when user clicks submit
|
||||
document.querySelector('#submit-btn')?.addEventListener('click', () => {
|
||||
manager.execute()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Multiple Widgets
|
||||
*/
|
||||
export function multipleWidgetsExample() {
|
||||
const loginManager = new TurnstileManager('YOUR_SITE_KEY')
|
||||
const signupManager = new TurnstileManager('YOUR_SITE_KEY')
|
||||
|
||||
// Login widget
|
||||
loginManager.render(
|
||||
'#login-turnstile',
|
||||
{
|
||||
onSuccess: (token) => console.log('Login token:', token),
|
||||
onError: (error) => console.error('Login error:', error),
|
||||
},
|
||||
{
|
||||
action: 'login',
|
||||
}
|
||||
)
|
||||
|
||||
// Signup widget
|
||||
signupManager.render(
|
||||
'#signup-turnstile',
|
||||
{
|
||||
onSuccess: (token) => console.log('Signup token:', token),
|
||||
onError: (error) => console.error('Signup error:', error),
|
||||
},
|
||||
{
|
||||
action: 'signup',
|
||||
}
|
||||
)
|
||||
}
|
||||
163
templates/turnstile-widget-implicit.html
Normal file
163
templates/turnstile-widget-implicit.html
Normal file
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turnstile Example - Implicit Rendering</title>
|
||||
|
||||
<!-- CRITICAL: Load from Cloudflare CDN only - never proxy or cache -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<!-- Optional: CSP headers if needed -->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src 'self' https://challenges.cloudflare.com;
|
||||
connect-src 'self' https://challenges.cloudflare.com;
|
||||
">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
background: #0070f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0051cc;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.success {
|
||||
color: green;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Contact Form with Turnstile</h1>
|
||||
|
||||
<form id="contactForm" action="/api/contact" method="POST">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget - Implicit Rendering -->
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="YOUR_SITE_KEY"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
data-expired-callback="onTurnstileExpired"
|
||||
data-theme="auto"
|
||||
data-size="normal"></div>
|
||||
|
||||
<button type="submit" id="submitButton">Submit</button>
|
||||
|
||||
<div id="message"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
let turnstileToken = null;
|
||||
|
||||
function onTurnstileSuccess(token) {
|
||||
console.log('Turnstile success:', token);
|
||||
turnstileToken = token;
|
||||
document.getElementById('submitButton').disabled = false;
|
||||
}
|
||||
|
||||
function onTurnstileError(error) {
|
||||
console.error('Turnstile error:', error);
|
||||
showMessage('Verification failed. Please try again.', 'error');
|
||||
document.getElementById('submitButton').disabled = true;
|
||||
}
|
||||
|
||||
function onTurnstileExpired() {
|
||||
console.warn('Turnstile token expired');
|
||||
turnstileToken = null;
|
||||
document.getElementById('submitButton').disabled = true;
|
||||
showMessage('Verification expired. Please try again.', 'error');
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.className = type;
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('contactForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!turnstileToken) {
|
||||
showMessage('Please complete the verification.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
// Token is automatically added as 'cf-turnstile-response' by Turnstile
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Message sent successfully!', 'success');
|
||||
e.target.reset();
|
||||
turnstileToken = null;
|
||||
// Reset Turnstile widget
|
||||
turnstile.reset();
|
||||
} else {
|
||||
const error = await response.text();
|
||||
showMessage(`Error: ${error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`Network error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Disable submit button initially
|
||||
document.getElementById('submitButton').disabled = true;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
templates/wrangler-turnstile-config.jsonc
Normal file
36
templates/wrangler-turnstile-config.jsonc
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "my-turnstile-app",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-10-22",
|
||||
|
||||
// Public sitekey - safe to commit to version control
|
||||
// Use dummy keys for development, real keys for production
|
||||
"vars": {
|
||||
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA" // Test key - always passes
|
||||
// Production: Replace with your real sitekey from https://dash.cloudflare.com/?to=/:account/turnstile
|
||||
},
|
||||
|
||||
// Secret key - NEVER commit to version control
|
||||
// Set using: wrangler secret put TURNSTILE_SECRET_KEY
|
||||
"secrets": ["TURNSTILE_SECRET_KEY"],
|
||||
|
||||
// Optional: Environment-specific configuration
|
||||
"env": {
|
||||
"production": {
|
||||
"vars": {
|
||||
"TURNSTILE_SITE_KEY": "<YOUR_PRODUCTION_SITE_KEY>"
|
||||
}
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"TURNSTILE_SITE_KEY": "<YOUR_STAGING_SITE_KEY>"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"vars": {
|
||||
// Use test sitekey for development (always passes)
|
||||
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user