captcha-canvas
Version:
A captcha generator by using skia-canvas module.
371 lines (370 loc) • 19.9 kB
JavaScript
;
/**
* Input validation utilities for the captcha-canvas library.
*
* This module provides comprehensive validation functions for all CAPTCHA
* configuration parameters, ensuring that inputs meet security and usability
* requirements before processing.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.VALIDATION_CONSTRAINTS = void 0;
exports.validateDimensions = validateDimensions;
exports.validateColor = validateColor;
exports.validateFont = validateFont;
exports.validateFontSize = validateFontSize;
exports.validateOpacity = validateOpacity;
exports.validateCharacterCount = validateCharacterCount;
exports.validateText = validateText;
exports.validateBackground = validateBackground;
exports.validateRotation = validateRotation;
exports.validateDecoyCount = validateDecoyCount;
exports.validateTraceSize = validateTraceSize;
exports.validateCaptchaConfig = validateCaptchaConfig;
const errors_1 = require("./errors");
// Constants for validation limits
exports.VALIDATION_CONSTRAINTS = {
// Canvas dimensions
MIN_WIDTH: 50,
MAX_WIDTH: 2000,
MIN_HEIGHT: 30,
MAX_HEIGHT: 1000,
MIN_ASPECT_RATIO: 0.2,
MAX_ASPECT_RATIO: 5.0,
// Text configuration
MIN_CHARACTERS: 4,
MAX_CHARACTERS: 20,
MIN_FONT_SIZE: 8,
MAX_FONT_SIZE: 200,
// Opacity values
MIN_OPACITY: 0,
MAX_OPACITY: 1,
// Decoy configuration
MAX_DECOYS: 1000,
// Trace configuration
MAX_TRACE_SIZE: 20,
// Rotation limits
MAX_ROTATION: 90,
};
/**
* Validates canvas dimensions and throws CaptchaValidationError if invalid.
*
* @param width - Canvas width in pixels
* @param height - Canvas height in pixels
* @throws {CaptchaValidationError} When dimensions are invalid
*/
function validateDimensions(width, height) {
if (typeof width !== 'number' || typeof height !== 'number') {
throw new errors_1.CaptchaValidationError('Dimensions must be numbers', 'dimensions', 'numbers', typeof width + ', ' + typeof height);
}
if (!Number.isFinite(width) || !Number.isFinite(height)) {
throw new errors_1.CaptchaValidationError('Dimensions must be finite numbers', 'dimensions', 'finite numbers', width + ', ' + height);
}
if (width < exports.VALIDATION_CONSTRAINTS.MIN_WIDTH) {
throw new errors_1.CaptchaValidationError(`Width must be at least ${exports.VALIDATION_CONSTRAINTS.MIN_WIDTH}px`, 'width', `≥ ${exports.VALIDATION_CONSTRAINTS.MIN_WIDTH}px`, `${width}px`, ['Increase width to minimum required size', 'Use smaller canvas if possible']);
}
if (width > exports.VALIDATION_CONSTRAINTS.MAX_WIDTH) {
throw new errors_1.CaptchaValidationError(`Width must be no more than ${exports.VALIDATION_CONSTRAINTS.MAX_WIDTH}px`, 'width', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_WIDTH}px`, `${width}px`, ['Reduce width to maximum allowed size', 'Consider splitting into multiple CAPTCHAs']);
}
if (height < exports.VALIDATION_CONSTRAINTS.MIN_HEIGHT) {
throw new errors_1.CaptchaValidationError(`Height must be at least ${exports.VALIDATION_CONSTRAINTS.MIN_HEIGHT}px`, 'height', `≥ ${exports.VALIDATION_CONSTRAINTS.MIN_HEIGHT}px`, `${height}px`, ['Increase height to minimum required size', 'Use larger canvas for better readability']);
}
if (height > exports.VALIDATION_CONSTRAINTS.MAX_HEIGHT) {
throw new errors_1.CaptchaValidationError(`Height must be no more than ${exports.VALIDATION_CONSTRAINTS.MAX_HEIGHT}px`, 'height', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_HEIGHT}px`, `${height}px`, ['Reduce height to maximum allowed size', 'Consider using horizontal layout']);
}
const aspectRatio = width / height;
if (aspectRatio < exports.VALIDATION_CONSTRAINTS.MIN_ASPECT_RATIO) {
throw new errors_1.CaptchaValidationError(`Canvas aspect ratio is too extreme (${aspectRatio.toFixed(2)}:1). Minimum ratio is ${exports.VALIDATION_CONSTRAINTS.MIN_ASPECT_RATIO}:1`, 'dimensions', `≥ ${exports.VALIDATION_CONSTRAINTS.MIN_ASPECT_RATIO}:1 aspect ratio`, `${aspectRatio.toFixed(2)}:1`, ['Increase width or decrease height', 'Use more balanced dimensions']);
}
if (aspectRatio > exports.VALIDATION_CONSTRAINTS.MAX_ASPECT_RATIO) {
throw new errors_1.CaptchaValidationError(`Canvas aspect ratio is too extreme (${aspectRatio.toFixed(2)}:1). Maximum ratio is ${exports.VALIDATION_CONSTRAINTS.MAX_ASPECT_RATIO}:1`, 'dimensions', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_ASPECT_RATIO}:1 aspect ratio`, `${aspectRatio.toFixed(2)}:1`, ['Decrease width or increase height', 'Use more balanced dimensions']);
}
}
/**
* Validates color strings and throws CaptchaValidationError if invalid.
*
* Supports hex colors (#RGB, #RRGGBB), RGB colors (rgb(r,g,b)),
* RGBA colors (rgba(r,g,b,a)), and named colors.
*
* @param color - Color string to validate
* @param fieldName - Field name for error reporting
* @throws {CaptchaValidationError} When color is invalid
*/
function validateColor(color, fieldName = 'color') {
if (typeof color !== 'string') {
throw new errors_1.CaptchaValidationError('Color must be a string', fieldName, 'string', typeof color);
}
if (!color.trim()) {
throw new errors_1.CaptchaValidationError('Color cannot be empty', fieldName, 'non-empty string', 'empty string');
}
// Check for valid color formats
const hexRegex = /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/;
const rgbRegex = /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/;
const rgbaRegex = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/;
const namedColorRegex = /^[a-zA-Z]+$/;
if (!hexRegex.test(color) && !rgbRegex.test(color) && !rgbaRegex.test(color) && !namedColorRegex.test(color)) {
throw new errors_1.CaptchaValidationError(`Invalid color format: "${color}". Use hex (#RGB, #RRGGBB), RGB (rgb(r,g,b)), RGBA (rgba(r,g,b,a)), or named colors`, fieldName, 'valid color string (hex, rgb, rgba, or named color)', color, ['Use hex format like "#ff0000" or "#f00"', 'Use RGB format like "rgb(255,0,0)"', 'Use named colors like "red"']);
}
}
/**
* Validates font names and throws CaptchaValidationError if invalid.
*
* @param font - Font name to validate
* @throws {CaptchaValidationError} When font is invalid
*/
function validateFont(font) {
if (typeof font !== 'string') {
throw new errors_1.CaptchaValidationError('Font must be a string', 'font', 'string', typeof font);
}
if (!font.trim()) {
throw new errors_1.CaptchaValidationError('Font cannot be empty', 'font', 'non-empty string', 'empty string');
}
// Basic font name validation - allow common font patterns
const fontRegex = /^[a-zA-Z0-9\s\-_.'"]+$/;
if (!fontRegex.test(font)) {
throw new errors_1.CaptchaValidationError(`Invalid font name: "${font}". Font names should only contain letters, numbers, spaces, and basic punctuation`, 'font', 'valid font name', font, ['Use common web-safe fonts like "Arial", "Times New Roman"', 'Use font names without special characters']);
}
}
/**
* Validates font size and throws CaptchaValidationError if invalid.
*
* @param size - Font size in pixels
* @throws {CaptchaValidationError} When font size is invalid
*/
function validateFontSize(size) {
// Convert string to number if needed
let numericSize;
if (typeof size === 'string') {
// Remove any non-numeric characters (like 'px')
const cleanSize = size.replace(/[^\d.-]/g, '');
numericSize = parseFloat(cleanSize);
if (isNaN(numericSize)) {
throw new errors_1.CaptchaValidationError('Font size must be a valid number', 'size', 'number', typeof size, [
'Use a numeric value like 40 or "40px"',
'Remove any non-numeric characters'
]);
}
}
else {
numericSize = size;
}
if (typeof numericSize !== 'number') {
throw new errors_1.CaptchaValidationError('Font size must be a number', 'size', 'number', typeof numericSize);
}
if (!Number.isFinite(numericSize)) {
throw new errors_1.CaptchaValidationError('Font size must be a finite number', 'size', 'finite number', numericSize.toString());
}
if (numericSize < exports.VALIDATION_CONSTRAINTS.MIN_FONT_SIZE) {
throw new errors_1.CaptchaValidationError(`Font size must be at least ${exports.VALIDATION_CONSTRAINTS.MIN_FONT_SIZE}px`, 'size', `≥ ${exports.VALIDATION_CONSTRAINTS.MIN_FONT_SIZE}px`, `${numericSize}px`, ['Increase font size for better readability', 'Use larger canvas for smaller fonts']);
}
if (numericSize > exports.VALIDATION_CONSTRAINTS.MAX_FONT_SIZE) {
throw new errors_1.CaptchaValidationError(`Font size must be no more than ${exports.VALIDATION_CONSTRAINTS.MAX_FONT_SIZE}px`, 'size', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_FONT_SIZE}px`, `${numericSize}px`, ['Reduce font size to fit canvas', 'Use larger canvas for larger fonts']);
}
}
/**
* Validates opacity values and throws CaptchaValidationError if invalid.
*
* @param opacity - Opacity value (0.0 to 1.0)
* @throws {CaptchaValidationError} When opacity is invalid
*/
function validateOpacity(opacity) {
if (typeof opacity !== 'number') {
throw new errors_1.CaptchaValidationError('Opacity must be a number', 'opacity', 'number', typeof opacity);
}
if (!Number.isFinite(opacity)) {
throw new errors_1.CaptchaValidationError('Opacity must be a finite number', 'opacity', 'finite number', opacity.toString());
}
if (opacity < exports.VALIDATION_CONSTRAINTS.MIN_OPACITY) {
throw new errors_1.CaptchaValidationError(`Opacity must be at least ${exports.VALIDATION_CONSTRAINTS.MIN_OPACITY}`, 'opacity', `≥ ${exports.VALIDATION_CONSTRAINTS.MIN_OPACITY}`, opacity.toString(), ['Set opacity to 0 or higher', 'Use 0 for completely transparent elements']);
}
if (opacity > exports.VALIDATION_CONSTRAINTS.MAX_OPACITY) {
throw new errors_1.CaptchaValidationError(`Opacity must be no more than ${exports.VALIDATION_CONSTRAINTS.MAX_OPACITY}`, 'opacity', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_OPACITY}`, opacity.toString(), ['Set opacity to 1 or lower', 'Use 1 for completely opaque elements']);
}
}
/**
* Validates character count and throws CaptchaValidationError if invalid.
*
* @param characters - Number of characters
* @throws {CaptchaValidationError} When character count is invalid
* @throws {CaptchaSecurityError} When character count is insufficient for security
*/
function validateCharacterCount(characters) {
if (typeof characters !== 'number') {
throw new errors_1.CaptchaValidationError('Character count must be a number', 'characters', 'number', typeof characters);
}
if (!Number.isFinite(characters) || !Number.isInteger(characters)) {
throw new errors_1.CaptchaValidationError('Character count must be a finite integer', 'characters', 'finite integer', characters.toString());
}
if (characters < exports.VALIDATION_CONSTRAINTS.MIN_CHARACTERS) {
throw new errors_1.CaptchaSecurityError(`Character count (${characters}) is too low for adequate security. Minimum is ${exports.VALIDATION_CONSTRAINTS.MIN_CHARACTERS}`, 'low', `Use at least ${exports.VALIDATION_CONSTRAINTS.MIN_CHARACTERS} characters for better security`);
}
if (characters > exports.VALIDATION_CONSTRAINTS.MAX_CHARACTERS) {
throw new errors_1.CaptchaValidationError(`Character count (${characters}) exceeds maximum allowed. Maximum is ${exports.VALIDATION_CONSTRAINTS.MAX_CHARACTERS}`, 'characters', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_CHARACTERS}`, characters.toString(), ['Reduce character count to maximum allowed', 'Use larger canvas for more characters']);
}
}
/**
* Validates text content and throws CaptchaValidationError if invalid.
*
* @param text - Text content to validate
* @param expectedLength - Expected length of text (optional)
* @throws {CaptchaValidationError} When text is invalid
*/
function validateText(text, expectedLength) {
if (typeof text !== 'string') {
throw new errors_1.CaptchaValidationError('Text must be a string', 'text', 'string', typeof text);
}
if (!text.trim()) {
throw new errors_1.CaptchaValidationError('Text cannot be empty or whitespace only', 'text', 'non-empty string', 'empty or whitespace string');
}
if (expectedLength !== undefined && text.length !== expectedLength) {
throw new errors_1.CaptchaValidationError(`Text length (${text.length}) does not match expected length (${expectedLength})`, 'text', `exactly ${expectedLength} characters`, `${text.length} characters`, ['Adjust text to match expected length', 'Update expected length to match text']);
}
// Check for potentially problematic characters
const problematicChars = /[^\w\s\-_.,!?]/;
if (problematicChars.test(text)) {
console.warn('Text contains potentially problematic characters that may affect rendering');
}
}
/**
* Validates background image path/URL and throws CaptchaValidationError if invalid.
*
* @param background - Background image source (path, URL, or Buffer)
* @throws {CaptchaValidationError} When background is invalid
*/
function validateBackground(background) {
if (typeof background !== 'string' && !Buffer.isBuffer(background)) {
throw new errors_1.CaptchaValidationError('Background must be a string (path/URL) or Buffer', 'background', 'string or Buffer', typeof background);
}
if (typeof background === 'string' && !background.trim()) {
throw new errors_1.CaptchaValidationError('Background path/URL cannot be empty', 'background', 'non-empty string or Buffer', 'empty string');
}
if (typeof background === 'string' && background.length > 2048) {
throw new errors_1.CaptchaValidationError('Background path/URL is too long (max 2048 characters)', 'background', '≤ 2048 characters', `${background.length} characters`, ['Use shorter file paths or URLs', 'Consider using relative paths']);
}
}
/**
* Validates rotation angle and throws CaptchaValidationError if invalid.
*
* @param rotation - Maximum rotation angle in degrees
* @throws {CaptchaValidationError} When rotation is invalid
*/
function validateRotation(rotation) {
if (typeof rotation !== 'number') {
throw new errors_1.CaptchaValidationError('Rotation must be a number', 'rotate', 'number', typeof rotation);
}
if (!Number.isFinite(rotation) || rotation < 0) {
throw new errors_1.CaptchaValidationError('Rotation must be a non-negative finite number', 'rotate', 'non-negative finite number', rotation.toString());
}
if (rotation > exports.VALIDATION_CONSTRAINTS.MAX_ROTATION) {
throw new errors_1.CaptchaValidationError(`Rotation angle (${rotation}°) is too large. Maximum is ${exports.VALIDATION_CONSTRAINTS.MAX_ROTATION}°`, 'rotate', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_ROTATION}°`, `${rotation}°`, ['Reduce rotation angle to maximum allowed', 'Use smaller rotation for better readability']);
}
}
/**
* Validates decoy configuration and throws CaptchaValidationError if invalid.
*
* @param total - Number of decoy characters
* @throws {CaptchaValidationError} When decoy count is invalid
*/
function validateDecoyCount(total) {
if (typeof total !== 'number') {
throw new errors_1.CaptchaValidationError('Decoy count must be a number', 'decoy.total', 'number', typeof total);
}
if (!Number.isFinite(total) || !Number.isInteger(total) || total < 0) {
throw new errors_1.CaptchaValidationError('Decoy count must be a non-negative finite integer', 'decoy.total', 'non-negative finite integer', total.toString());
}
if (total > exports.VALIDATION_CONSTRAINTS.MAX_DECOYS) {
throw new errors_1.CaptchaValidationError(`Decoy count (${total}) exceeds maximum allowed. Maximum is ${exports.VALIDATION_CONSTRAINTS.MAX_DECOYS}`, 'decoy.total', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_DECOYS}`, total.toString(), ['Reduce decoy count to maximum allowed', 'Consider performance impact of many decoys']);
}
}
/**
* Validates trace line configuration and throws CaptchaValidationError if invalid.
*
* @param size - Trace line width in pixels
* @throws {CaptchaValidationError} When trace size is invalid
*/
function validateTraceSize(size) {
if (typeof size !== 'number') {
throw new errors_1.CaptchaValidationError('Trace size must be a number', 'trace.size', 'number', typeof size);
}
if (!Number.isFinite(size) || size < 0) {
throw new errors_1.CaptchaValidationError('Trace size must be a non-negative finite number', 'trace.size', 'non-negative finite number', size.toString());
}
if (size > exports.VALIDATION_CONSTRAINTS.MAX_TRACE_SIZE) {
throw new errors_1.CaptchaValidationError(`Trace size (${size}px) is too large. Maximum is ${exports.VALIDATION_CONSTRAINTS.MAX_TRACE_SIZE}px`, 'trace.size', `≤ ${exports.VALIDATION_CONSTRAINTS.MAX_TRACE_SIZE}px`, `${size}px`, ['Reduce trace size to maximum allowed', 'Use thinner lines for better readability']);
}
}
/**
* Validates complete captcha configuration object.
*
* @param config - Configuration object to validate
* @throws {CaptchaValidationError} When any configuration is invalid
* @throws {CaptchaSecurityError} When security-related configuration is insufficient
*/
function validateCaptchaConfig(config) {
// Validate dimensions
if (config.width !== undefined || config.height !== undefined) {
validateDimensions(config.width || 300, config.height || 100);
}
// Validate captcha text configuration
if (config.captcha) {
if (config.captcha.characters !== undefined) {
validateCharacterCount(config.captcha.characters);
}
if (config.captcha.text !== undefined) {
validateText(config.captcha.text);
}
if (config.captcha.size !== undefined) {
validateFontSize(config.captcha.size);
}
if (config.captcha.color !== undefined) {
validateColor(config.captcha.color, 'captcha.color');
}
if (config.captcha.colors !== undefined && Array.isArray(config.captcha.colors)) {
config.captcha.colors.forEach((color, index) => {
validateColor(color, `captcha.colors[${index}]`);
});
}
if (config.captcha.rotate !== undefined) {
validateRotation(config.captcha.rotate);
}
if (config.captcha.font !== undefined) {
validateFont(config.captcha.font);
}
if (config.captcha.opacity !== undefined) {
validateOpacity(config.captcha.opacity);
}
}
// Validate trace configuration
if (config.trace) {
if (config.trace.size !== undefined) {
validateTraceSize(config.trace.size);
}
if (config.trace.color !== undefined) {
validateColor(config.trace.color, 'trace.color');
}
if (config.trace.opacity !== undefined) {
validateOpacity(config.trace.opacity);
}
}
// Validate decoy configuration
if (config.decoy) {
if (config.decoy.total !== undefined) {
validateDecoyCount(config.decoy.total);
}
if (config.decoy.size !== undefined) {
validateFontSize(config.decoy.size);
}
if (config.decoy.color !== undefined) {
validateColor(config.decoy.color, 'decoy.color');
}
if (config.decoy.opacity !== undefined) {
validateOpacity(config.decoy.opacity);
}
if (config.decoy.font !== undefined) {
validateFont(config.decoy.font);
}
}
// Validate background
if (config.background !== undefined) {
validateBackground(config.background);
}
}