hook-engine
Version:
Production-grade webhook engine with comprehensive adapter support, security, reliability, structured logging, and CLI tools.
433 lines (432 loc) • 15.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SecurityManager = exports.MemoryRateLimitStore = void 0;
const events_1 = require("events");
/**
* In-memory rate limit store implementation
*/
class MemoryRateLimitStore {
constructor() {
this.store = new Map();
}
async get(key) {
const entry = this.store.get(key);
if (!entry || Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.info;
}
async set(key, info, ttlMs) {
this.store.set(key, {
info,
expiresAt: Date.now() + ttlMs
});
}
async increment(key, ttlMs) {
const existing = await this.get(key);
const now = Date.now();
if (!existing) {
const info = {
totalHits: 1,
totalHitsInWindow: 1,
remainingPoints: 0, // Will be calculated by caller
msBeforeNext: ttlMs,
isFirstInWindow: true
};
await this.set(key, info, ttlMs);
return info;
}
const updated = {
totalHits: existing.totalHits + 1,
totalHitsInWindow: existing.totalHitsInWindow + 1,
remainingPoints: existing.remainingPoints,
msBeforeNext: existing.msBeforeNext,
isFirstInWindow: false
};
await this.set(key, updated, ttlMs);
return updated;
}
async reset(key) {
this.store.delete(key);
}
// Cleanup expired entries
cleanup() {
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (now > entry.expiresAt) {
this.store.delete(key);
}
}
}
}
exports.MemoryRateLimitStore = MemoryRateLimitStore;
/**
* Comprehensive security manager for webhook processing
*/
class SecurityManager extends events_1.EventEmitter {
constructor(config) {
super();
this.auditLogs = [];
this.config = config;
this.rateLimitStore = config.rateLimiting.store || new MemoryRateLimitStore();
this.metrics = {
totalRequests: 0,
blockedRequests: 0,
rateLimitHits: 0,
validationFailures: 0,
ipBlockedRequests: 0,
averageResponseTime: 0,
securityEvents: [],
lastUpdated: new Date()
};
// Start cleanup interval for memory store
if (this.rateLimitStore instanceof MemoryRateLimitStore) {
this.cleanupInterval = setInterval(() => {
this.rateLimitStore.cleanup();
}, 60000); // Cleanup every minute
}
}
/**
* Validate incoming request against all security policies
*/
async validateRequest(req) {
const startTime = Date.now();
this.metrics.totalRequests++;
try {
// 1. Check IP allowlist
const ipResult = await this.validateIP(req);
if (!ipResult.isValid) {
this.logSecurityEvent('ip_blocked', 'high', req, { errors: ipResult.errors });
this.metrics.ipBlockedRequests++;
this.metrics.blockedRequests++;
return ipResult;
}
// 2. Check rate limits
const rateLimitResult = await this.checkRateLimit(req);
if (!rateLimitResult.isValid) {
this.logSecurityEvent('rate_limit_exceeded', 'medium', req, { errors: rateLimitResult.errors });
this.metrics.rateLimitHits++;
this.metrics.blockedRequests++;
return rateLimitResult;
}
// 3. Validate request structure and content
const validationResult = await this.validateRequestContent(req);
if (!validationResult.isValid) {
this.logSecurityEvent('validation_failed', 'medium', req, { errors: validationResult.errors });
this.metrics.validationFailures++;
this.metrics.blockedRequests++;
return validationResult;
}
// 4. Check request size
const sizeResult = this.validateRequestSize(req);
if (!sizeResult.isValid) {
this.logSecurityEvent('request_too_large', 'medium', req, { errors: sizeResult.errors });
this.metrics.blockedRequests++;
return sizeResult;
}
// All validations passed
this.updateResponseTimeMetrics(Date.now() - startTime);
return { isValid: true, errors: [] };
}
catch (error) {
this.logSecurityEvent('validation_failed', 'high', req, {
error: error.message
});
this.metrics.blockedRequests++;
return {
isValid: false,
errors: [`Security validation error: ${error.message}`]
};
}
finally {
this.metrics.lastUpdated = new Date();
}
}
/**
* Validate IP address against allowlist
*/
async validateIP(req) {
if (!this.config.ipAllowlist.enabled) {
return { isValid: true, errors: [] };
}
const clientIP = this.extractClientIP(req);
if (!clientIP) {
return {
isValid: false,
errors: ['Unable to determine client IP address']
};
}
// Check if IP is in allowlist
const isAllowed = this.isIPAllowed(clientIP);
if (!isAllowed && this.config.ipAllowlist.denyByDefault) {
return {
isValid: false,
errors: [`IP address ${clientIP} is not in allowlist`]
};
}
return { isValid: true, errors: [] };
}
/**
* Check rate limits for the request
*/
async checkRateLimit(req) {
const key = this.generateRateLimitKey(req);
const config = this.config.rateLimiting;
try {
const rateLimitInfo = await this.rateLimitStore.increment(key, config.windowMs);
// Calculate remaining points
rateLimitInfo.remainingPoints = Math.max(0, config.maxRequests - rateLimitInfo.totalHitsInWindow);
if (rateLimitInfo.totalHitsInWindow > config.maxRequests) {
// Rate limit exceeded
if (config.onLimitReached) {
config.onLimitReached(req, rateLimitInfo);
}
return {
isValid: false,
errors: [
`Rate limit exceeded: ${rateLimitInfo.totalHitsInWindow}/${config.maxRequests} requests in ${config.windowMs}ms window`
]
};
}
// Add rate limit headers to response (if supported)
if (req.res) {
req.res.setHeader('X-RateLimit-Limit', config.maxRequests);
req.res.setHeader('X-RateLimit-Remaining', rateLimitInfo.remainingPoints);
req.res.setHeader('X-RateLimit-Reset', new Date(Date.now() + rateLimitInfo.msBeforeNext).toISOString());
}
return { isValid: true, errors: [] };
}
catch (error) {
// If rate limiting fails, allow the request but log the error
this.emit('rateLimitError', error);
return { isValid: true, errors: [] };
}
}
/**
* Validate request content (headers, payload, etc.)
*/
async validateRequestContent(req) {
const config = this.config.requestValidation;
const errors = [];
// Check required headers
for (const header of config.requiredHeaders) {
if (!req.headers[header.toLowerCase()]) {
errors.push(`Missing required header: ${header}`);
}
}
// Check content type
const contentType = req.headers['content-type'];
if (contentType && config.allowedContentTypes.length > 0) {
const isAllowed = config.allowedContentTypes.some((allowed) => contentType.toLowerCase().includes(allowed.toLowerCase()));
if (!isAllowed) {
errors.push(`Content type ${contentType} is not allowed`);
}
}
// Validate timestamp if enabled
if (config.enableTimestampValidation) {
const timestampResult = this.validateTimestamp(req);
if (!timestampResult.isValid) {
errors.push(...timestampResult.errors);
}
}
// Run custom validators
if (config.customValidators) {
for (const validator of config.customValidators) {
try {
const result = validator.validate(req);
if (!result.isValid) {
if (validator.required) {
errors.push(...result.errors);
}
// Log warnings even if not required
if (result.warnings) {
this.emit('validationWarning', {
validator: validator.name,
warnings: result.warnings
});
}
}
}
catch (error) {
if (validator.required) {
errors.push(validator.errorMessage || `Validation failed: ${validator.name}`);
}
}
}
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Validate request size
*/
validateRequestSize(req) {
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
if (contentLength > this.config.requestSizeLimit) {
return {
isValid: false,
errors: [`Request size ${contentLength} bytes exceeds limit of ${this.config.requestSizeLimit} bytes`]
};
}
return { isValid: true, errors: [] };
}
/**
* Validate timestamp in request
*/
validateTimestamp(req) {
const timestamp = req.headers['x-timestamp'] || req.headers['timestamp'];
if (!timestamp) {
return {
isValid: false,
errors: ['Missing timestamp header']
};
}
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
const tolerance = this.config.requestValidation.timestampToleranceMs;
if (Math.abs(now - requestTime) > tolerance) {
return {
isValid: false,
errors: [`Timestamp ${timestamp} is outside tolerance window of ${tolerance}ms`]
};
}
return { isValid: true, errors: [] };
}
/**
* Extract client IP from request
*/
extractClientIP(req) {
// Check X-Forwarded-For header (from trusted proxies)
const forwardedFor = req.headers['x-forwarded-for'];
if (forwardedFor && this.config.ipAllowlist.trustedProxies.length > 0) {
const ips = forwardedFor.split(',').map((ip) => ip.trim());
return ips[0]; // First IP is the original client
}
// Check other common headers
return req.headers['x-real-ip'] ||
req.headers['x-client-ip'] ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.ip ||
null;
}
/**
* Check if IP is allowed
*/
isIPAllowed(ip) {
// Check exact matches
if (this.config.ipAllowlist.allowedIPs.includes(ip)) {
return true;
}
// Check CIDR ranges
for (const range of this.config.ipAllowlist.allowedRanges) {
if (this.isIPInCIDR(ip, range)) {
return true;
}
}
return false;
}
/**
* Check if IP is in CIDR range
*/
isIPInCIDR(ip, cidr) {
// Simplified CIDR check - in production, use a proper IP library
try {
const [network, prefixLength] = cidr.split('/');
const prefix = parseInt(prefixLength, 10);
// Convert IPs to integers for comparison
const ipInt = this.ipToInt(ip);
const networkInt = this.ipToInt(network);
const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
return (ipInt & mask) === (networkInt & mask);
}
catch {
return false;
}
}
/**
* Convert IP address to integer
*/
ipToInt(ip) {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}
/**
* Generate rate limit key for request
*/
generateRateLimitKey(req) {
if (this.config.rateLimiting.keyGenerator) {
return this.config.rateLimiting.keyGenerator(req);
}
// Default: use IP address
const ip = this.extractClientIP(req) || 'unknown';
return `rate_limit:${ip}`;
}
/**
* Log security event
*/
logSecurityEvent(event, severity, req, details) {
const auditLog = {
timestamp: new Date(),
event,
severity,
source: req.headers['user-agent'] || 'unknown',
details,
userAgent: req.headers['user-agent'],
ip: this.extractClientIP(req) || undefined,
blocked: true
};
this.auditLogs.push(auditLog);
this.metrics.securityEvents.push(auditLog);
// Emit event for external logging
this.emit('securityEvent', auditLog);
// Keep only last 1000 audit logs in memory
if (this.auditLogs.length > 1000) {
this.auditLogs = this.auditLogs.slice(-1000);
}
}
/**
* Update response time metrics
*/
updateResponseTimeMetrics(responseTime) {
const total = this.metrics.totalRequests;
this.metrics.averageResponseTime =
((this.metrics.averageResponseTime * (total - 1)) + responseTime) / total;
}
/**
* Get security metrics
*/
getMetrics() {
return { ...this.metrics };
}
/**
* Get recent audit logs
*/
getAuditLogs(limit = 100) {
return this.auditLogs.slice(-limit);
}
/**
* Reset rate limit for a specific key
*/
async resetRateLimit(key) {
await this.rateLimitStore.reset(key);
}
/**
* Get rate limit info for a key
*/
async getRateLimitInfo(key) {
return await this.rateLimitStore.get(key);
}
/**
* Cleanup resources
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.removeAllListeners();
}
}
exports.SecurityManager = SecurityManager;