Open Source Team Metrics based on PRs
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}