my pkgs monorepo
at main 80 lines 2.6 kB view raw
1/** 2 * Validates and parses an incoming GitHub Sponsors webhook request. 3 * 4 * GitHub signs the JSON body with HMAC-SHA256 and sends it in the 5 * X-Hub-Signature-256 header as `sha256=<hex>`. We verify the signature 6 * before parsing the payload. 7 * 8 * Required environment variable: 9 * GITHUB_WEBHOOK_SECRET — the secret set in your GitHub Sponsors webhook config 10 * 11 * @see https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries 12 */ 13 14import type { GitHubSponsorshipWebhookPayload } from './github-types.js'; 15 16export class GitHubWebhookError extends Error { 17 constructor( 18 message: string, 19 public readonly status: number 20 ) { 21 super(message); 22 } 23} 24 25export interface ParseGitHubWebhookOptions { 26 /** Override GITHUB_WEBHOOK_SECRET from process.env */ 27 secret?: string; 28} 29 30/** Constant-time hex comparison to prevent timing attacks. */ 31async function verifySignature(secret: string, body: string, signature: string): Promise<boolean> { 32 const enc = new TextEncoder(); 33 const key = await crypto.subtle.importKey( 34 'raw', 35 enc.encode(secret), 36 { name: 'HMAC', hash: 'SHA-256' }, 37 false, 38 ['sign'] 39 ); 40 const mac = await crypto.subtle.sign('HMAC', key, enc.encode(body)); 41 const expected = 'sha256=' + Array.from(new Uint8Array(mac)).map((b) => b.toString(16).padStart(2, '0')).join(''); 42 43 if (expected.length !== signature.length) return false; 44 // Constant-time comparison 45 let mismatch = 0; 46 for (let i = 0; i < expected.length; i++) { 47 mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i); 48 } 49 return mismatch === 0; 50} 51 52export async function parseGitHubSponsorsWebhook( 53 request: Request, 54 options?: ParseGitHubWebhookOptions 55): Promise<GitHubSponsorshipWebhookPayload> { 56 const secret = options?.secret ?? process.env.GITHUB_WEBHOOK_SECRET; 57 if (!secret) throw new GitHubWebhookError('GITHUB_WEBHOOK_SECRET is not set', 500); 58 59 const event = request.headers.get('x-github-event'); 60 if (event !== 'sponsorship') { 61 throw new GitHubWebhookError(`Unexpected event type: ${event}`, 400); 62 } 63 64 const signature = request.headers.get('x-hub-signature-256'); 65 if (!signature) throw new GitHubWebhookError('Missing X-Hub-Signature-256 header', 400); 66 67 const body = await request.text(); 68 69 const valid = await verifySignature(secret, body, signature); 70 if (!valid) throw new GitHubWebhookError('Signature verification failed', 401); 71 72 let payload: GitHubSponsorshipWebhookPayload; 73 try { 74 payload = JSON.parse(body); 75 } catch { 76 throw new GitHubWebhookError('Invalid JSON body', 400); 77 } 78 79 return payload; 80}