← back to Watches
security/captcha-handler.js
250 lines
/**
* CAPTCHA HANDLER
* Protection against automated attacks and bots
*
* Features:
* - reCAPTCHA v3 integration
* - Invisible verification
* - Score-based validation
* - Fallback to v2 for low scores
*/
import crypto from 'crypto';
class CaptchaHandler {
constructor() {
this.enabled = process.env.RECAPTCHA_ENABLED === 'true';
this.secretKey = process.env.RECAPTCHA_SECRET_KEY || '';
this.siteKey = process.env.RECAPTCHA_SITE_KEY || '';
this.minScore = parseFloat(process.env.RECAPTCHA_MIN_SCORE) || 0.5;
this.verifyEndpoint = 'https://www.google.com/recaptcha/api/siteverify';
// Simple CAPTCHA for when reCAPTCHA is not configured
this.simpleCaptchas = new Map();
}
/**
* Verify reCAPTCHA token
*/
async verifyRecaptcha(token, action = 'submit') {
if (!this.enabled || !this.secretKey) {
console.warn('reCAPTCHA not configured, skipping verification');
return { success: true, score: 1.0, bypass: true };
}
try {
const response = await fetch(this.verifyEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `secret=${this.secretKey}&response=${token}`
});
const data = await response.json();
if (!data.success) {
return {
success: false,
error: 'CAPTCHA verification failed',
errorCodes: data['error-codes']
};
}
// Check score (v3 only)
if (data.score !== undefined && data.score < this.minScore) {
return {
success: false,
error: 'CAPTCHA score too low',
score: data.score,
requiresV2: true
};
}
return {
success: true,
score: data.score || 1.0,
action: data.action,
hostname: data.hostname
};
} catch (error) {
console.error('reCAPTCHA verification error:', error);
return {
success: false,
error: 'CAPTCHA verification service unavailable'
};
}
}
/**
* Generate simple math CAPTCHA (fallback)
*/
generateSimpleCaptcha() {
const num1 = Math.floor(Math.random() * 10) + 1;
const num2 = Math.floor(Math.random() * 10) + 1;
const operations = ['+', '-', '*'];
const operation = operations[Math.floor(Math.random() * operations.length)];
let answer;
let question;
switch (operation) {
case '+':
answer = num1 + num2;
question = `${num1} + ${num2}`;
break;
case '-':
answer = Math.max(num1, num2) - Math.min(num1, num2);
question = `${Math.max(num1, num2)} - ${Math.min(num1, num2)}`;
break;
case '*':
answer = num1 * num2;
question = `${num1} × ${num2}`;
break;
}
const captchaId = crypto.randomUUID();
const expiresAt = Date.now() + 300000; // 5 minutes
this.simpleCaptchas.set(captchaId, {
answer: answer.toString(),
expiresAt: expiresAt
});
// Clean expired captchas
this.cleanExpiredCaptchas();
return {
id: captchaId,
question: `What is ${question}?`,
expiresAt: expiresAt
};
}
/**
* Verify simple CAPTCHA answer
*/
verifySimpleCaptcha(captchaId, answer) {
const captcha = this.simpleCaptchas.get(captchaId);
if (!captcha) {
return {
success: false,
error: 'CAPTCHA not found or expired'
};
}
if (Date.now() > captcha.expiresAt) {
this.simpleCaptchas.delete(captchaId);
return {
success: false,
error: 'CAPTCHA expired'
};
}
const isValid = answer.toString().trim() === captcha.answer;
// Delete after verification (one-time use)
this.simpleCaptchas.delete(captchaId);
return {
success: isValid,
error: isValid ? null : 'Incorrect answer'
};
}
/**
* Clean expired simple captchas
*/
cleanExpiredCaptchas() {
const now = Date.now();
for (const [id, captcha] of this.simpleCaptchas.entries()) {
if (now > captcha.expiresAt) {
this.simpleCaptchas.delete(id);
}
}
}
/**
* Get site key for client-side implementation
*/
getSiteKey() {
return this.siteKey;
}
/**
* Check if CAPTCHA is enabled
*/
isEnabled() {
return this.enabled;
}
}
/**
* Express middleware for CAPTCHA verification
*/
export const captchaMiddleware = (captchaHandler, options = {}) => {
const {
requireRecaptcha = false,
action = 'submit',
tokenField = 'recaptchaToken',
simpleCaptchaFields = { id: 'captchaId', answer: 'captchaAnswer' }
} = options;
return async (req, res, next) => {
// Skip if CAPTCHA not enabled
if (!captchaHandler.isEnabled() && !requireRecaptcha) {
return next();
}
// Check for reCAPTCHA token
const recaptchaToken = req.body[tokenField] || req.query[tokenField];
if (recaptchaToken) {
const result = await captchaHandler.verifyRecaptcha(recaptchaToken, action);
if (!result.success) {
return res.status(400).json({
error: 'CAPTCHA verification failed',
message: result.error,
requiresV2: result.requiresV2 || false
});
}
req.captchaVerified = true;
req.captchaScore = result.score;
return next();
}
// Check for simple CAPTCHA
const simpleCaptchaId = req.body[simpleCaptchaFields.id];
const simpleCaptchaAnswer = req.body[simpleCaptchaFields.answer];
if (simpleCaptchaId && simpleCaptchaAnswer) {
const result = captchaHandler.verifySimpleCaptcha(simpleCaptchaId, simpleCaptchaAnswer);
if (!result.success) {
return res.status(400).json({
error: 'CAPTCHA verification failed',
message: result.error
});
}
req.captchaVerified = true;
return next();
}
// No CAPTCHA provided
if (requireRecaptcha || captchaHandler.isEnabled()) {
return res.status(400).json({
error: 'CAPTCHA required',
message: 'Please complete the CAPTCHA verification'
});
}
next();
};
};
export default CaptchaHandler;