@voilajsx/appkit
Version:
Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development
419 lines • 18.6 kB
JavaScript
/**
* Core security class with CSRF, rate limiting, sanitization, and encryption
* @module @voilajsx/appkit/security
* @file src/security/security.ts
*
* @llm-rule WHEN: Building apps that need security protection (CSRF, rate limiting, input sanitization, encryption)
* @llm-rule AVOID: Using directly - always get instance via securityClass.get()
* @llm-rule NOTE: Provides enterprise-grade security with CSRF tokens, rate limiting, XSS prevention, and AES-256-GCM encryption
*/
import crypto from 'crypto';
import { createSecurityError } from './defaults.js';
/**
* Security class with enterprise-grade protection functionality
*/
export class SecurityClass {
config;
requestStore;
cleanupInitialized;
constructor(config) {
this.config = config;
this.requestStore = new Map();
this.cleanupInitialized = false;
}
/**
* Creates CSRF protection middleware for forms and AJAX requests
* @llm-rule WHEN: Protecting forms and state-changing requests from CSRF attacks
* @llm-rule AVOID: Using without session middleware - CSRF requires sessions for token storage
* @llm-rule NOTE: Automatically validates tokens on POST/PUT/DELETE/PATCH requests, adds req.csrfToken() method
*/
forms(options = {}) {
const csrfSecret = options.secret || this.config.csrf.secret;
if (!csrfSecret) {
throw createSecurityError('CSRF secret required. Set VOILA_SECURITY_CSRF_SECRET or VOILA_AUTH_SECRET environment variable', 500);
}
const tokenField = options.tokenField || this.config.csrf.tokenField;
const headerField = options.headerField || this.config.csrf.headerField;
const expiryMinutes = options.expiryMinutes || this.config.csrf.expiryMinutes;
return (req, res, next) => {
// Ensure session exists
if (!req.session || typeof req.session !== 'object') {
const error = createSecurityError('Session required for CSRF protection', 500);
return next(error);
}
// Add token generation method to request
req.csrfToken = () => this.generateCSRFToken(req.session, expiryMinutes);
// Skip CSRF verification for safe HTTP methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// Extract token from request
const token = (req.body && req.body[tokenField]) ||
(req.headers && req.headers[headerField.toLowerCase()]) ||
(req.query && req.query[tokenField]);
// Verify token
if (!this.verifyCSRFToken(token, req.session)) {
const error = createSecurityError('Invalid or missing CSRF token', 403);
return next(error);
}
next();
};
}
/**
* Creates rate limiting middleware with configurable limits and windows
* @llm-rule WHEN: Protecting endpoints from abuse and brute force attacks
* @llm-rule AVOID: Using same limits for all endpoints - auth should have stricter limits than API
* @llm-rule NOTE: Uses in-memory storage with automatic cleanup, sets standard rate limit headers
*/
requests(maxRequests, windowMs, options = {}) {
// Handle argument polymorphism
if (typeof maxRequests === 'object') {
options = maxRequests;
maxRequests = options.maxRequests;
windowMs = options.windowMs;
}
else if (typeof windowMs === 'object') {
options = windowMs;
windowMs = options.windowMs;
}
// Use provided values or config defaults
const max = maxRequests || this.config.rateLimit.maxRequests;
const window = windowMs || this.config.rateLimit.windowMs;
const message = options.message || this.config.rateLimit.message;
const keyGenerator = options.keyGenerator || this.getClientKey;
// Validate configuration
if (max < 0 || window <= 0) {
throw createSecurityError('Invalid rate limit configuration', 500);
}
// Initialize cleanup for memory management
this.initializeCleanup(window);
return (req, res, next) => {
const key = keyGenerator(req);
const now = Date.now();
// Get or create rate limit record
let record = this.requestStore.get(key);
if (!record) {
record = { count: 0, resetTime: now + window };
this.requestStore.set(key, record);
}
else if (now > record.resetTime) {
// Reset if window has passed
record.count = 0;
record.resetTime = now + window;
}
// Increment request count
record.count++;
// Set rate limit headers
if (res.setHeader) {
res.setHeader('X-RateLimit-Limit', max);
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - record.count));
res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetTime / 1000));
}
// Check if limit exceeded
if (record.count > max) {
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
if (res.setHeader) {
res.setHeader('Retry-After', retryAfter);
}
const error = createSecurityError(message, 429, {
retryAfter,
limit: max,
remaining: 0,
resetTime: record.resetTime,
});
return next(error);
}
next();
};
}
/**
* Cleans text input with XSS prevention and length limiting
* @llm-rule WHEN: Processing any user text input before storage or display
* @llm-rule AVOID: Storing raw user input - always clean to prevent XSS attacks
* @llm-rule NOTE: Removes dangerous patterns like <script>, javascript:, event handlers
*/
input(text, options = {}) {
if (typeof text !== 'string') {
return '';
}
const maxLength = options.maxLength || this.config.sanitization.maxLength;
const trim = options.trim !== false;
const removeXSS = options.removeXSS !== false;
let result = text;
// Trim whitespace
if (trim) {
result = result.trim();
}
// Basic XSS prevention
if (removeXSS) {
result = result
.replace(/[<>]/g, '') // Remove angle brackets
.replace(/javascript:/gi, '') // Remove javascript: protocol
.replace(/on\w+\s*=/gi, '') // Remove inline event handlers
.replace(/data:/gi, '') // Remove data: protocol
.replace(/vbscript:/gi, '') // Remove vbscript: protocol
.replace(/expression\s*\(/gi, '') // Remove CSS expressions
.replace(/url\s*\(/gi, ''); // Remove CSS url() functions
}
// Length limiting
if (result.length > maxLength) {
result = result.substring(0, maxLength);
}
return result;
}
/**
* Cleans HTML content allowing only specified safe tags
* @llm-rule WHEN: Processing user HTML content like rich text editor input
* @llm-rule AVOID: Allowing all HTML tags - only whitelist safe formatting tags
* @llm-rule NOTE: Removes script, iframe, object tags and dangerous attributes like onclick
*/
html(html, options = {}) {
if (typeof html !== 'string') {
return '';
}
const allowedTags = options.allowedTags || this.config.sanitization.allowedTags;
const stripAllTags = options.stripAllTags !== undefined
? options.stripAllTags
: this.config.sanitization.stripAllTags;
let result = html;
// Strip all tags if requested
if (stripAllTags) {
return result.replace(/<[^>]*>/g, '');
}
// Remove dangerous elements
result = result
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove script tags
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '') // Remove iframe tags
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '') // Remove object tags
.replace(/<embed\b[^>]*>/gi, '') // Remove embed tags
.replace(/<form\b[^<]*(?:(?!<\/form>)<[^<]*)*<\/form>/gi, '') // Remove form tags
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '') // Remove inline event handlers
.replace(/javascript\s*:/gi, '') // Remove javascript: protocol
.replace(/data\s*:/gi, '') // Remove data: protocol
.replace(/vbscript\s*:/gi, '') // Remove vbscript: protocol
.replace(/expression\s*\(/gi, ''); // Remove CSS expressions
// Filter allowed tags if specified
if (allowedTags.length > 0) {
try {
const allowedPattern = allowedTags
.map(tag => tag.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'))
.join('|');
const tagPattern = new RegExp(`<(?!\/?(?:${allowedPattern})\\b)[^>]+>`, 'gi');
result = result.replace(tagPattern, '');
}
catch (error) {
console.warn('HTML sanitization: Invalid allowed tags, stripping all tags');
result = result.replace(/<[^>]*>/g, '');
}
}
return result;
}
/**
* Escapes HTML special characters for safe display in HTML content
* @llm-rule WHEN: Displaying user text content in HTML without allowing any HTML tags
* @llm-rule AVOID: Direct interpolation of user content in HTML - always escape first
* @llm-rule NOTE: Converts &, <, >, quotes to HTML entities for safe display
*/
escape(text) {
if (typeof text !== 'string') {
return '';
}
const HTML_ESCAPE_MAP = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
'`': '`',
'=': '=',
};
return text.replace(/[&<>"'/`=]/g, (char) => HTML_ESCAPE_MAP[char]);
}
/**
* Encrypts sensitive data using AES-256-GCM with authentication
* @llm-rule WHEN: Storing sensitive data like SSNs, credit cards, personal info
* @llm-rule AVOID: Storing sensitive data in plain text - always encrypt before database storage
* @llm-rule NOTE: Uses random IV per encryption, includes authentication tag to prevent tampering
*/
encrypt(data, key, associatedData) {
if (!data) {
throw createSecurityError('Data to encrypt cannot be empty');
}
const encryptionKey = key || this.config.encryption.key;
if (!encryptionKey) {
throw createSecurityError('Encryption key required. Provide as argument or set VOILA_SECURITY_ENCRYPTION_KEY environment variable', 500);
}
this.validateEncryptionKey(encryptionKey);
const keyBuffer = typeof encryptionKey === 'string'
? Buffer.from(encryptionKey, 'hex')
: encryptionKey;
try {
// Generate random IV for each encryption
const iv = crypto.randomBytes(this.config.encryption.ivLength);
const cipher = crypto.createCipheriv(this.config.encryption.algorithm, keyBuffer, iv);
// Set AAD if provided
if (associatedData) {
if (!Buffer.isBuffer(associatedData)) {
throw createSecurityError('Associated data must be a Buffer');
}
cipher.setAAD(associatedData);
}
// Encrypt data
const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
let encrypted = cipher.update(dataBuffer);
encrypted = Buffer.concat([encrypted, cipher.final()]);
// Get authentication tag
const authTag = cipher.getAuthTag();
// Combine IV, ciphertext, and auth tag
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${authTag.toString('hex')}`;
}
catch (error) {
throw createSecurityError(`Encryption failed: ${error.message}`, 500);
}
}
/**
* Decrypts previously encrypted data with authentication verification
* @llm-rule WHEN: Retrieving sensitive data that was encrypted with encrypt() method
* @llm-rule AVOID: Using with data not encrypted by this module - will fail authentication
* @llm-rule NOTE: Automatically verifies authentication tag to detect tampering
*/
decrypt(encryptedData, key, associatedData) {
if (!encryptedData || typeof encryptedData !== 'string') {
throw createSecurityError('Encrypted data must be a non-empty string');
}
const decryptionKey = key || this.config.encryption.key;
if (!decryptionKey) {
throw createSecurityError('Decryption key required. Provide as argument or set VOILA_SECURITY_ENCRYPTION_KEY environment variable', 500);
}
this.validateEncryptionKey(decryptionKey);
const keyBuffer = typeof decryptionKey === 'string'
? Buffer.from(decryptionKey, 'hex')
: decryptionKey;
// Parse encrypted data format
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw createSecurityError('Invalid encrypted data format. Expected IV:ciphertext:authTag');
}
try {
const iv = Buffer.from(parts[0], 'hex');
const encrypted = Buffer.from(parts[1], 'hex');
const authTag = Buffer.from(parts[2], 'hex');
// Validate component lengths
if (iv.length !== this.config.encryption.ivLength ||
authTag.length !== this.config.encryption.tagLength) {
throw createSecurityError('Invalid IV or authentication tag length');
}
// Create decipher
const decipher = crypto.createDecipheriv(this.config.encryption.algorithm, keyBuffer, iv);
// Set AAD if provided
if (associatedData) {
if (!Buffer.isBuffer(associatedData)) {
throw createSecurityError('Associated data must be a Buffer');
}
decipher.setAAD(associatedData);
}
// Set authentication tag
decipher.setAuthTag(authTag);
// Decrypt data
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
catch (error) {
if (error.code === 'EBADTAG') {
throw createSecurityError('Authentication failed: Data may be tampered with or incorrect key/AAD provided', 401);
}
throw createSecurityError(`Decryption failed: ${error.message}`, 500);
}
}
/**
* Generates a cryptographically secure 256-bit encryption key
* @llm-rule WHEN: Setting up encryption for the first time or rotating keys
* @llm-rule AVOID: Using weak or predictable keys - always use this method for key generation
* @llm-rule NOTE: Returns 64-character hex string suitable for VOILA_SECURITY_ENCRYPTION_KEY
*/
generateKey() {
try {
return crypto.randomBytes(this.config.encryption.keyLength).toString('hex');
}
catch (error) {
throw createSecurityError(`Key generation failed: ${error.message}`, 500);
}
}
// Private helper methods
/**
* Generates a cryptographically secure CSRF token
*/
generateCSRFToken(session, expiryMinutes) {
if (!session || typeof session !== 'object') {
throw createSecurityError('Session object required for CSRF token generation', 500);
}
const token = crypto.randomBytes(16).toString('hex');
session.csrfToken = token;
session.csrfTokenExpiry = Date.now() + expiryMinutes * 60 * 1000;
return token;
}
/**
* Verifies CSRF token using timing-safe comparison
*/
verifyCSRFToken(token, session) {
if (!token || typeof token !== 'string' || !session?.csrfToken) {
return false;
}
// Check expiry
if (session.csrfTokenExpiry && Date.now() > session.csrfTokenExpiry) {
return false;
}
try {
const expectedBuffer = Buffer.from(session.csrfToken, 'hex');
const actualBuffer = Buffer.from(token, 'hex');
if (expectedBuffer.length !== actualBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, actualBuffer);
}
catch {
return false;
}
}
/**
* Gets unique identifier for the client
*/
getClientKey = (req) => {
return req.ip ||
req.connection?.remoteAddress ||
(req.headers && req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
'unknown';
};
/**
* Initializes cleanup interval for memory management
*/
initializeCleanup(windowMs) {
if (this.cleanupInitialized)
return;
const cleanupInterval = Math.min(windowMs, 60 * 1000);
setInterval(() => {
const now = Date.now();
for (const [key, record] of this.requestStore.entries()) {
if (now > record.resetTime) {
this.requestStore.delete(key);
}
}
}, cleanupInterval).unref();
this.cleanupInitialized = true;
}
/**
* Validates encryption key format and length
*/
validateEncryptionKey(key) {
if (!key) {
throw createSecurityError('Encryption key is required', 500);
}
const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
if (keyBuffer.length !== this.config.encryption.keyLength) {
throw createSecurityError(`Invalid key length. Expected ${this.config.encryption.keyLength} bytes, got ${keyBuffer.length} bytes`, 500);
}
}
}
//# sourceMappingURL=security.js.map