feat(webhooks): add GitHub webhook signature verifier
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
const toHex = (buffer: ArrayBuffer): string =>
|
||||||
|
Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
export const timingSafeEqualHex = (a: string, b: string): boolean => {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let mismatch = 0;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return mismatch === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyGithubSignature = async (
|
||||||
|
secret: string,
|
||||||
|
rawBody: string,
|
||||||
|
signatureHeader: string | null,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
|
||||||
|
// Web Crypto's importKey throws on a zero-length HMAC key; guard so the
|
||||||
|
// verifier never throws on attacker-controlled input (returns false instead).
|
||||||
|
if (!secret) return false;
|
||||||
|
const provided = signatureHeader.slice('sha256='.length);
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign'],
|
||||||
|
);
|
||||||
|
const mac = await crypto.subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(rawBody),
|
||||||
|
);
|
||||||
|
return timingSafeEqualHex(toHex(mac), provided);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
timingSafeEqualHex,
|
||||||
|
verifyGithubSignature,
|
||||||
|
} from '../../convex/githubWebhookVerify';
|
||||||
|
|
||||||
|
const SECRET = "It's a Secret to Everybody";
|
||||||
|
const BODY = 'Hello, World!';
|
||||||
|
const VALID_SIGNATURE =
|
||||||
|
'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17';
|
||||||
|
|
||||||
|
describe('verifyGithubSignature', () => {
|
||||||
|
test('verifies the GitHub known vector', async () => {
|
||||||
|
expect(await verifyGithubSignature(SECRET, BODY, VALID_SIGNATURE)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a tampered body', async () => {
|
||||||
|
expect(
|
||||||
|
await verifyGithubSignature(SECRET, 'Hello, World?', VALID_SIGNATURE),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a wrong secret', async () => {
|
||||||
|
expect(
|
||||||
|
await verifyGithubSignature('wrong secret', BODY, VALID_SIGNATURE),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a header missing the sha256= prefix', async () => {
|
||||||
|
expect(
|
||||||
|
await verifyGithubSignature(
|
||||||
|
SECRET,
|
||||||
|
BODY,
|
||||||
|
'757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a null header', async () => {
|
||||||
|
expect(await verifyGithubSignature(SECRET, BODY, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an empty header', async () => {
|
||||||
|
expect(await verifyGithubSignature(SECRET, BODY, '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a correct-format but wrong-length signature', async () => {
|
||||||
|
expect(
|
||||||
|
await verifyGithubSignature(SECRET, BODY, 'sha256=deadbeef'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an empty secret', async () => {
|
||||||
|
expect(await verifyGithubSignature('', BODY, VALID_SIGNATURE)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timingSafeEqualHex', () => {
|
||||||
|
test('returns true for equal strings', () => {
|
||||||
|
expect(timingSafeEqualHex('abcd', 'abcd')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for equal-length but differing strings', () => {
|
||||||
|
expect(timingSafeEqualHex('abcd', 'abce')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for different lengths', () => {
|
||||||
|
expect(timingSafeEqualHex('abcd', 'abcde')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user