Initial commit
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user