Skip to main content
Glama
waldzellai

Exa Websets MCP Server

by waldzellai
WebhookValidator.ts13.3 kB
/** * Webhook Validator Implementation * * Provides webhook signature verification using HMAC-SHA256 and other * validation utilities for webhook security and integrity. */ import { createHmac, timingSafeEqual } from 'crypto'; /** * Webhook validation configuration */ export interface WebhookValidatorConfig { /** Default signature algorithm */ defaultAlgorithm: string; /** Maximum timestamp tolerance in seconds */ timestampTolerance: number; /** Whether to require timestamp validation */ requireTimestamp: boolean; /** Whether to log validation attempts */ enableLogging: boolean; } /** * Webhook validation result */ export interface WebhookValidationResult { /** Whether validation was successful */ valid: boolean; /** Validation error message if failed */ error?: string; /** Validation details */ details?: { signatureValid?: boolean; timestampValid?: boolean; algorithm?: string; timestamp?: number; }; } /** * Webhook signature components */ export interface WebhookSignature { /** Signature algorithm (e.g., 'sha256') */ algorithm: string; /** Signature value */ signature: string; /** Raw signature header value */ raw: string; } /** * Default webhook validator configuration */ const DEFAULT_WEBHOOK_VALIDATOR_CONFIG: WebhookValidatorConfig = { defaultAlgorithm: 'sha256', timestampTolerance: 300, // 5 minutes requireTimestamp: true, enableLogging: false, }; /** * Webhook validator for signature verification and security validation */ export class WebhookValidator { private readonly config: WebhookValidatorConfig; constructor(config: Partial<WebhookValidatorConfig> = {}) { this.config = { ...DEFAULT_WEBHOOK_VALIDATOR_CONFIG, ...config }; } /** * Validate a webhook request * @param payload The raw webhook payload * @param signature The signature header value * @param secret The webhook secret * @param timestamp Optional timestamp for replay protection * @returns Validation result */ validateWebhook( payload: string | Buffer, signature: string, secret: string, timestamp?: string | number ): WebhookValidationResult { try { // Parse signature const parsedSignature = this.parseSignature(signature); if (!parsedSignature) { return { valid: false, error: 'Invalid signature format', }; } // Validate timestamp if required let timestampValid = true; let parsedTimestamp: number | undefined; if (this.config.requireTimestamp && timestamp) { const timestampResult = this.validateTimestamp(timestamp); timestampValid = timestampResult.valid; parsedTimestamp = timestampResult.timestamp; if (!timestampValid) { return { valid: false, error: timestampResult.error, details: { signatureValid: false, timestampValid: false, algorithm: parsedSignature.algorithm, timestamp: parsedTimestamp, }, }; } } // Validate signature const signatureValid = this.verifySignature( payload, parsedSignature, secret, parsedTimestamp ); const result: WebhookValidationResult = { valid: signatureValid && timestampValid, details: { signatureValid, timestampValid, algorithm: parsedSignature.algorithm, timestamp: parsedTimestamp, }, }; if (!result.valid) { result.error = signatureValid ? 'Timestamp validation failed' : 'Signature validation failed'; } if (this.config.enableLogging) { console.log('Webhook validation result:', result); } return result; } catch (error) { return { valid: false, error: error instanceof Error ? error.message : 'Unknown validation error', }; } } /** * Parse signature header * @param signature The signature header value * @returns Parsed signature components or null if invalid */ private parseSignature(signature: string): WebhookSignature | null { if (!signature) { return null; } // Support multiple signature formats: // 1. "sha256=abc123..." (standard format) // 2. "v1=abc123..." (Stripe format) // 3. "t=timestamp,v1=signature" (Stripe format with timestamp) const parts = signature.split(','); let algorithm = this.config.defaultAlgorithm; let signatureValue = ''; for (const part of parts) { const [key, value] = part.split('=', 2); if (!key || !value) { continue; } if (key === 'sha256' || key === 'v1') { algorithm = key === 'v1' ? 'sha256' : key; signatureValue = value; break; } } if (!signatureValue) { // Try simple format: "abc123..." (assume default algorithm) if (signature.match(/^[a-fA-F0-9]+$/)) { signatureValue = signature; } else { return null; } } return { algorithm, signature: signatureValue, raw: signature, }; } /** * Validate timestamp for replay protection * @param timestamp The timestamp to validate * @returns Validation result with parsed timestamp */ private validateTimestamp(timestamp: string | number): { valid: boolean; timestamp?: number; error?: string; } { let parsedTimestamp: number; if (typeof timestamp === 'string') { parsedTimestamp = parseInt(timestamp, 10); if (isNaN(parsedTimestamp)) { return { valid: false, error: 'Invalid timestamp format', }; } } else { parsedTimestamp = timestamp; } const now = Math.floor(Date.now() / 1000); const timeDiff = Math.abs(now - parsedTimestamp); if (timeDiff > this.config.timestampTolerance) { return { valid: false, timestamp: parsedTimestamp, error: `Timestamp too old or too far in future (diff: ${timeDiff}s, tolerance: ${this.config.timestampTolerance}s)`, }; } return { valid: true, timestamp: parsedTimestamp, }; } /** * Verify webhook signature using HMAC * @param payload The webhook payload * @param signature The parsed signature * @param secret The webhook secret * @param timestamp Optional timestamp to include in signature * @returns True if signature is valid */ private verifySignature( payload: string | Buffer, signature: WebhookSignature, secret: string, timestamp?: number ): boolean { try { // Prepare the data to sign let dataToSign: string; if (timestamp) { // Include timestamp in signature (Stripe-style) dataToSign = `${timestamp}.${payload}`; } else { // Simple payload signature dataToSign = typeof payload === 'string' ? payload : payload.toString(); } // Generate expected signature const expectedSignature = this.generateSignature(dataToSign, secret, signature.algorithm); // Compare signatures using timing-safe comparison return this.compareSignatures(signature.signature, expectedSignature); } catch (error) { if (this.config.enableLogging) { console.error('Signature verification error:', error); } return false; } } /** * Generate HMAC signature * @param data The data to sign * @param secret The secret key * @param algorithm The hash algorithm * @returns Generated signature */ private generateSignature(data: string, secret: string, algorithm: string): string { const hmac = createHmac(algorithm, secret); hmac.update(data, 'utf8'); return hmac.digest('hex'); } /** * Compare two signatures using timing-safe comparison * @param signature1 First signature * @param signature2 Second signature * @returns True if signatures match */ private compareSignatures(signature1: string, signature2: string): boolean { if (signature1.length !== signature2.length) { return false; } try { const buffer1 = Buffer.from(signature1, 'hex'); const buffer2 = Buffer.from(signature2, 'hex'); if (buffer1.length !== buffer2.length) { return false; } return timingSafeEqual(buffer1, buffer2); } catch (error) { // Fallback to string comparison if buffer conversion fails return signature1 === signature2; } } /** * Generate a webhook signature for outgoing webhooks * @param payload The payload to sign * @param secret The webhook secret * @param algorithm Optional algorithm (defaults to config default) * @param includeTimestamp Whether to include timestamp in signature * @returns Generated signature header value */ generateWebhookSignature( payload: string | Buffer, secret: string, algorithm?: string, includeTimestamp: boolean = false ): string { const algo = algorithm || this.config.defaultAlgorithm; const timestamp = includeTimestamp ? Math.floor(Date.now() / 1000) : undefined; let dataToSign: string; if (timestamp) { dataToSign = `${timestamp}.${payload}`; } else { dataToSign = typeof payload === 'string' ? payload : payload.toString(); } const signature = this.generateSignature(dataToSign, secret, algo); if (timestamp) { return `t=${timestamp},${algo}=${signature}`; } else { return `${algo}=${signature}`; } } /** * Extract timestamp from signature header (if present) * @param signature The signature header value * @returns Extracted timestamp or null */ extractTimestamp(signature: string): number | null { const parts = signature.split(','); for (const part of parts) { const [key, value] = part.split('=', 2); if (key === 't' && value) { const timestamp = parseInt(value, 10); return isNaN(timestamp) ? null : timestamp; } } return null; } /** * Validate webhook URL format * @param url The webhook URL to validate * @returns Validation result */ validateWebhookUrl(url: string): { valid: boolean; error?: string } { try { const parsedUrl = new URL(url); // Check protocol if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return { valid: false, error: 'Webhook URL must use HTTP or HTTPS protocol', }; } // Recommend HTTPS for production if (parsedUrl.protocol === 'http:' && this.config.enableLogging) { console.warn('Webhook URL uses HTTP instead of HTTPS - not recommended for production'); } // Check for localhost/private IPs in production const hostname = parsedUrl.hostname.toLowerCase(); const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.'); if (isLocalhost && this.config.enableLogging) { console.warn('Webhook URL points to localhost/private IP - may not be reachable'); } return { valid: true }; } catch (error) { return { valid: false, error: `Invalid webhook URL: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } /** * Create a test webhook payload for validation testing * @param eventType The event type * @param data Optional event data * @returns Test webhook payload */ createTestPayload(eventType: string, data: any = {}): string { const payload = { id: `evt_test_${Date.now()}`, object: 'event', type: eventType, data, created: Math.floor(Date.now() / 1000), livemode: false, pending_webhooks: 1, request: { id: null, idempotency_key: null, }, }; return JSON.stringify(payload); } /** * Update validator configuration * @param config Partial configuration to update */ updateConfig(config: Partial<WebhookValidatorConfig>): void { Object.assign(this.config, config); } /** * Get current validator configuration * @returns Current configuration */ getConfig(): WebhookValidatorConfig { return { ...this.config }; } } /** * Utility function to create a webhook validator with common configurations */ export function createWebhookValidator( options: { strict?: boolean; timestampTolerance?: number; enableLogging?: boolean; } = {} ): WebhookValidator { const config: Partial<WebhookValidatorConfig> = { enableLogging: options.enableLogging || false, }; if (options.strict) { config.requireTimestamp = true; config.timestampTolerance = options.timestampTolerance || 60; // 1 minute for strict mode } else { config.requireTimestamp = false; config.timestampTolerance = options.timestampTolerance || 300; // 5 minutes for normal mode } return new WebhookValidator(config); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/waldzellai/exa-mcp-server-websets'

If you have feedback or need assistance with the MCP directory API, please join our Discord server