Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:31 +08:00
commit 0aa89c365d
19 changed files with 4303 additions and 0 deletions

View 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
}