Open Source Team Metrics based on PRs
at main 6.4 kB view raw
1// Enhanced webhook security utilities 2import crypto from 'crypto'; 3 4// Cache for processed webhook IDs to prevent replay attacks 5const processedWebhooks = new Map<string, number>(); 6const WEBHOOK_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes 7 8/** 9 * Verify GitHub webhook signature with enhanced security 10 */ 11export function verifyGitHubSignature( 12 payload: string, 13 signature: string | null, 14 secret: string 15): { valid: boolean; error?: string } { 16 if (!signature || !secret) { 17 return { valid: false, error: 'Missing signature or secret' }; 18 } 19 20 const expectedSignature = crypto 21 .createHmac('sha256', secret) 22 .update(payload, 'utf8') 23 .digest('hex'); 24 25 const expectedSignatureWithPrefix = `sha256=${expectedSignature}`; 26 27 // Use crypto.timingSafeEqual to prevent timing attacks 28 if (signature.length !== expectedSignatureWithPrefix.length) { 29 return { valid: false, error: 'Invalid signature format' }; 30 } 31 32 const isValid = crypto.timingSafeEqual( 33 Buffer.from(signature, 'utf8'), 34 Buffer.from(expectedSignatureWithPrefix, 'utf8') 35 ); 36 37 return { valid: isValid, error: isValid ? undefined : 'Signature verification failed' }; 38} 39 40/** 41 * Check if webhook has already been processed (replay attack prevention) 42 */ 43export async function checkWebhookReplay( 44 deliveryId: string | null, 45 timestamp?: string | null 46): Promise<{ isReplay: boolean; error?: string }> { 47 if (!deliveryId) { 48 return { isReplay: false, error: 'No delivery ID provided' }; 49 } 50 51 // Check timestamp if provided 52 if (timestamp) { 53 const webhookTime = new Date(timestamp).getTime(); 54 const now = Date.now(); 55 56 // Reject webhooks older than 5 minutes 57 if (Math.abs(now - webhookTime) > WEBHOOK_EXPIRY_MS) { 58 return { isReplay: true, error: 'Webhook timestamp too old' }; 59 } 60 } 61 62 // Check if we've already processed this webhook ID 63 const existingTimestamp = processedWebhooks.get(deliveryId); 64 if (existingTimestamp) { 65 return { isReplay: true, error: 'Webhook already processed' }; 66 } 67 68 // If using Vercel KV (optional, for distributed systems) 69 // Commented out until @vercel/kv is installed 70 // if (process.env.KV_REST_API_URL) { 71 // try { 72 // const kvKey = `webhook:${deliveryId}`; 73 // const exists = await kv.get(kvKey); 74 // 75 // if (exists) { 76 // return { isReplay: true, error: 'Webhook already processed (KV)' }; 77 // } 78 // 79 // // Store with expiry 80 // await kv.set(kvKey, Date.now(), { ex: 300 }); // 5 minutes TTL 81 // } catch (error) { 82 // console.warn('Failed to check/store webhook in KV:', error); 83 // // Fall back to in-memory storage 84 // } 85 // } 86 87 // Store in memory 88 processedWebhooks.set(deliveryId, Date.now()); 89 90 // Clean up old entries periodically 91 if (Math.random() < 0.01) { 92 cleanupProcessedWebhooks(); 93 } 94 95 return { isReplay: false }; 96} 97 98/** 99 * Clean up old processed webhook IDs from memory 100 */ 101function cleanupProcessedWebhooks() { 102 const now = Date.now(); 103 for (const [id, timestamp] of processedWebhooks.entries()) { 104 if (now - timestamp > WEBHOOK_EXPIRY_MS) { 105 processedWebhooks.delete(id); 106 } 107 } 108} 109 110/** 111 * Validate webhook payload size 112 */ 113export function validatePayloadSize( 114 contentLength: string | null, 115 maxSizeBytes: number = 5 * 1024 * 1024 // 5MB default 116): { valid: boolean; error?: string } { 117 if (!contentLength) { 118 return { valid: true }; // Can't validate without header 119 } 120 121 const size = parseInt(contentLength, 10); 122 if (isNaN(size)) { 123 return { valid: false, error: 'Invalid content-length header' }; 124 } 125 126 if (size > maxSizeBytes) { 127 return { 128 valid: false, 129 error: `Payload too large: ${size} bytes (max: ${maxSizeBytes})` 130 }; 131 } 132 133 return { valid: true }; 134} 135 136/** 137 * Validate webhook event type 138 */ 139export function validateEventType( 140 eventType: string | null, 141 allowedEvents: string[] 142): { valid: boolean; error?: string } { 143 if (!eventType) { 144 return { valid: false, error: 'Missing event type header' }; 145 } 146 147 if (!allowedEvents.includes(eventType)) { 148 return { 149 valid: false, 150 error: `Unsupported event type: ${eventType}` 151 }; 152 } 153 154 return { valid: true }; 155} 156 157/** 158 * Complete webhook validation pipeline 159 */ 160export async function validateWebhook( 161 request: Request, 162 secret: string, 163 allowedEvents: string[] = ['pull_request', 'pull_request_review', 'installation', 'ping'] 164): Promise<{ valid: boolean; error?: string; eventType?: string; payload?: any }> { 165 // Extract headers 166 const signature = request.headers.get('x-hub-signature-256'); 167 const eventType = request.headers.get('x-github-event'); 168 const deliveryId = request.headers.get('x-github-delivery'); 169 const contentLength = request.headers.get('content-length'); 170 171 // Validate payload size 172 const sizeCheck = validatePayloadSize(contentLength); 173 if (!sizeCheck.valid) { 174 return { valid: false, error: sizeCheck.error }; 175 } 176 177 // Validate event type 178 const eventCheck = validateEventType(eventType, allowedEvents); 179 if (!eventCheck.valid) { 180 return { valid: false, error: eventCheck.error }; 181 } 182 183 // Read body 184 let bodyText: string; 185 try { 186 bodyText = await request.text(); 187 } catch (error) { 188 return { valid: false, error: 'Failed to read request body' }; 189 } 190 191 // Verify signature 192 const signatureCheck = verifyGitHubSignature(bodyText, signature, secret); 193 if (!signatureCheck.valid) { 194 return { valid: false, error: signatureCheck.error }; 195 } 196 197 // Check for replay attack 198 const replayCheck = await checkWebhookReplay(deliveryId); 199 if (replayCheck.isReplay) { 200 return { valid: false, error: replayCheck.error }; 201 } 202 203 // Parse JSON 204 let payload: any; 205 try { 206 payload = JSON.parse(bodyText); 207 } catch (error) { 208 return { valid: false, error: 'Invalid JSON payload' }; 209 } 210 211 return { 212 valid: true, 213 eventType: eventType!, 214 payload 215 }; 216} 217 218/** 219 * Generate webhook secret (for initial setup) 220 */ 221export function generateWebhookSecret(length: number = 32): string { 222 return crypto.randomBytes(length).toString('hex'); 223} 224 225/** 226 * Create a test signature for webhook testing 227 */ 228export function createTestSignature(payload: string, secret: string): string { 229 const signature = crypto 230 .createHmac('sha256', secret) 231 .update(payload, 'utf8') 232 .digest('hex'); 233 234 return `sha256=${signature}`; 235}