diff --git a/packages/monday/jest.config.cjs b/packages/monday/jest.config.cjs index 296a927ca..8c6218f64 100644 --- a/packages/monday/jest.config.cjs +++ b/packages/monday/jest.config.cjs @@ -44,6 +44,7 @@ module.exports = { ], }, moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', '^corsair/http$': '/../corsair/http.ts', '^(\\.\\.?/.*)\\.js$': '$1', }, diff --git a/packages/monday/webhooks/column-value-changed.ts b/packages/monday/webhooks/column-value-changed.ts index c91908907..3f7d1f34a 100644 --- a/packages/monday/webhooks/column-value-changed.ts +++ b/packages/monday/webhooks/column-value-changed.ts @@ -1,19 +1,19 @@ import { logEventFromContext } from 'corsair/core'; import type { MondayWebhooks } from '../index'; -import { createMondayMatch } from './types'; +import { createMondayMatch, verifyMondayWebhookSignature } from './types'; export const columnValueChanged: MondayWebhooks['columnValueChanged'] = { match: createMondayMatch('change_column_value'), handler: async (ctx, request) => { - // const verification = verifyMondayWebhookSignature(request, ctx.key); - // if (!verification.valid) { - // return { - // success: false, - // statusCode: 401, - // error: verification.error || 'Signature verification failed', - // }; - // } + const verification = verifyMondayWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } const event = request.payload.event; diff --git a/packages/monday/webhooks/item-created.ts b/packages/monday/webhooks/item-created.ts index afc0aae98..268b7ba45 100644 --- a/packages/monday/webhooks/item-created.ts +++ b/packages/monday/webhooks/item-created.ts @@ -1,19 +1,19 @@ import { logEventFromContext } from 'corsair/core'; import type { MondayWebhooks } from '../index'; -import { createMondayMatch } from './types'; +import { createMondayMatch, verifyMondayWebhookSignature } from './types'; export const itemCreated: MondayWebhooks['itemCreated'] = { match: createMondayMatch('create_pulse'), handler: async (ctx, request) => { - // const verification = verifyMondayWebhookSignature(request, ctx.key); - // if (!verification.valid) { - // return { - // success: false, - // statusCode: 401, - // error: verification.error || 'Signature verification failed', - // }; - // } + const verification = verifyMondayWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } const event = request.payload.event; diff --git a/packages/monday/webhooks/status-changed.ts b/packages/monday/webhooks/status-changed.ts index b72863f6f..8895ba6a4 100644 --- a/packages/monday/webhooks/status-changed.ts +++ b/packages/monday/webhooks/status-changed.ts @@ -1,19 +1,19 @@ import { logEventFromContext } from 'corsair/core'; import type { MondayWebhooks } from '../index'; -import { createMondayMatch } from './types'; +import { createMondayMatch, verifyMondayWebhookSignature } from './types'; export const statusChanged: MondayWebhooks['statusChanged'] = { match: createMondayMatch('change_status_column_value'), handler: async (ctx, request) => { - // const verification = verifyMondayWebhookSignature(request, ctx.key); - // if (!verification.valid) { - // return { - // success: false, - // statusCode: 401, - // error: verification.error || 'Signature verification failed', - // }; - // } + const verification = verifyMondayWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } const event = request.payload.event; diff --git a/packages/monday/webhooks/tenant-matcher.ts b/packages/monday/webhooks/tenant-matcher.ts index f13c219c8..7a6c106fe 100644 --- a/packages/monday/webhooks/tenant-matcher.ts +++ b/packages/monday/webhooks/tenant-matcher.ts @@ -7,9 +7,8 @@ function readJwtPayload( const authorization = getHeader(request.headers, 'authorization'); if (!authorization) return null; - const token = authorization.startsWith('Bearer ') - ? authorization.slice('Bearer '.length) - : authorization; + // RFC 9110: auth scheme token is case-insensitive ("Bearer" / "bearer" / …). + const token = authorization.replace(/^Bearer\s+/i, ''); const parts = token.split('.'); const payloadSegment = parts[1]; if (!payloadSegment) return null; diff --git a/packages/monday/webhooks/types.ts b/packages/monday/webhooks/types.ts index 252722856..8d3214ac3 100644 --- a/packages/monday/webhooks/types.ts +++ b/packages/monday/webhooks/types.ts @@ -1,9 +1,9 @@ +import * as crypto from 'node:crypto'; import type { CorsairWebhookMatcher, RawWebhookRequest, WebhookRequest, } from 'corsair/core'; -import { verifyHmacSignature } from 'corsair/http'; import { z } from 'zod'; // ── Shared Sub-Schemas ──────────────────────────────────────────────────────── @@ -122,20 +122,91 @@ export function createMondayMatch(eventType: string): CorsairWebhookMatcher { // ── Signature Verification ──────────────────────────────────────────────────── -export function verifyMondayWebhookSignature( - request: WebhookRequest, +// Monday board / integration webhooks put an HS256 JWT in Authorization, +// signed with the app Signing Secret (or Client Secret for lifecycle events). +// See https://developer.monday.com/apps/docs/authorization-header +// This is NOT a body HMAC — treating the JWT as an HMAC digest 401s real traffic. + +function base64UrlEncode(buf: Buffer): string { + return buf + .toString('base64') + .replace(/=+$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +function base64UrlDecode(input: string): Buffer { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ); + return Buffer.from(padded, 'base64'); +} + +function timingSafeEqualString(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) return false; + return crypto.timingSafeEqual(aBuf, bBuf); +} + +function verifyMondayJwt( + token: string, secret: string, ): { valid: boolean; error?: string } { - if (!secret) { - return { valid: true }; + const parts = token.split('.'); + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { + return { valid: false, error: 'Invalid Authorization JWT' }; } - const rawBody = request.rawBody; - if (!rawBody) { - return { - valid: false, - error: 'Missing raw body for signature verification', + const [headerB64, payloadB64, signatureB64] = parts; + + try { + const header = JSON.parse(base64UrlDecode(headerB64).toString('utf8')) as { + alg?: string; }; + if (header.alg !== 'HS256') { + return { valid: false, error: 'Unsupported JWT algorithm' }; + } + } catch { + return { valid: false, error: 'Invalid Authorization JWT' }; + } + + const expectedSig = base64UrlEncode( + crypto + .createHmac('sha256', secret) + .update(`${headerB64}.${payloadB64}`) + .digest(), + ); + + if (!timingSafeEqualString(expectedSig, signatureB64)) { + return { valid: false, error: 'Invalid signature' }; + } + + try { + const payload = JSON.parse( + base64UrlDecode(payloadB64).toString('utf8'), + ) as { exp?: number }; + if ( + typeof payload.exp === 'number' && + Math.floor(Date.now() / 1000) >= payload.exp + ) { + return { valid: false, error: 'Token expired' }; + } + } catch { + return { valid: false, error: 'Invalid Authorization JWT' }; + } + + return { valid: true }; +} + +export function verifyMondayWebhookSignature( + request: WebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + if (!secret) { + return { valid: false, error: 'Missing webhook secret' }; } const headers = request.headers; @@ -147,8 +218,8 @@ export function verifyMondayWebhookSignature( return { valid: false, error: 'Missing Authorization header' }; } - const isValid = verifyHmacSignature(rawBody, secret, authHeader, 'sha256'); - return isValid - ? { valid: true } - : { valid: false, error: 'Invalid signature' }; + // RFC 9110: auth scheme token is case-insensitive ("Bearer" / "bearer" / …). + const token = authHeader.replace(/^Bearer\s+/i, ''); + + return verifyMondayJwt(token, secret); } diff --git a/packages/monday/webhooks/webhooks.test.ts b/packages/monday/webhooks/webhooks.test.ts new file mode 100644 index 000000000..ccc3edf0f --- /dev/null +++ b/packages/monday/webhooks/webhooks.test.ts @@ -0,0 +1,247 @@ +import * as crypto from 'node:crypto'; +import type { WebhookRequest } from 'corsair/core'; +import type { MondayContext } from '../index'; +import { columnValueChanged } from './column-value-changed'; +import { itemCreated } from './item-created'; +import { statusChanged } from './status-changed'; +import { verifyMondayWebhookSignature } from './types'; + +jest.mock('corsair/core', () => { + const actual = jest.requireActual('corsair/core'); + return { + ...actual, + logEventFromContext: jest.fn().mockResolvedValue(null), + }; +}); + +const WEBHOOK_SECRET = 'monday-webhook-secret'; + +function base64UrlEncode(buf: Buffer): string { + return buf + .toString('base64') + .replace(/=+$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +function signMondayJwt( + payload: Record, + secret: string, +): string { + const header = base64UrlEncode( + Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })), + ); + const body = base64UrlEncode(Buffer.from(JSON.stringify(payload))); + const signature = base64UrlEncode( + crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest(), + ); + return `${header}.${body}.${signature}`; +} + +function makeWebhookRequest( + event: Record, + options?: { + secret?: string; + authorization?: string; + omitAuthorization?: boolean; + payloadExtra?: Record; + expired?: boolean; + }, +): WebhookRequest> { + const payload = { event, ...options?.payloadExtra }; + const rawBody = JSON.stringify(payload); + const now = Math.floor(Date.now() / 1000); + const jwtPayload = { + accountId: 1825529, + userId: 4012689, + aud: 'https://example.com/monday/webhook', + iat: now, + exp: options?.expired ? now - 60 : now + 5 * 60, + }; + + const authorization = options?.omitAuthorization + ? undefined + : (options?.authorization ?? + signMondayJwt(jwtPayload, options?.secret ?? WEBHOOK_SECRET)); + + return { + payload, + rawBody, + headers: authorization ? { authorization } : {}, + } as unknown as WebhookRequest>; +} + +function makeCtx(): MondayContext { + return { + key: WEBHOOK_SECRET, + db: { + items: { + upsertByEntityId: jest.fn().mockResolvedValue({ id: 'entity-1' }), + }, + }, + database: {}, + $getAccountId: jest.fn().mockResolvedValue('account-1'), + } as unknown as MondayContext; +} + +const createPulseEvent = { + type: 'create_pulse', + pulseId: 123, + pulseName: 'A task', + boardId: 456, + groupId: 'topics', + triggerTime: '2026-05-22T00:00:00Z', + userId: 789, +}; + +describe('verifyMondayWebhookSignature', () => { + it('should fail closed when the secret is missing', () => { + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent), + '', + ); + expect(result).toEqual({ valid: false, error: 'Missing webhook secret' }); + }); + + it('should return invalid when the Authorization header is missing', () => { + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent, { omitAuthorization: true }), + WEBHOOK_SECRET, + ); + expect(result).toEqual({ + valid: false, + error: 'Missing Authorization header', + }); + }); + + it('should return invalid for a forged JWT', () => { + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent, { secret: 'a-different-secret' }), + WEBHOOK_SECRET, + ); + expect(result).toEqual({ valid: false, error: 'Invalid signature' }); + }); + + it('should return invalid for an expired JWT', () => { + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent, { expired: true }), + WEBHOOK_SECRET, + ); + expect(result).toEqual({ valid: false, error: 'Token expired' }); + }); + + it('should return valid for a correctly signed JWT', () => { + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent), + WEBHOOK_SECRET, + ); + expect(result).toEqual({ valid: true }); + }); + + it('should accept Bearer-prefixed Authorization JWTs', () => { + const token = signMondayJwt( + { + accountId: 1, + exp: Math.floor(Date.now() / 1000) + 60, + }, + WEBHOOK_SECRET, + ); + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent, { + authorization: `Bearer ${token}`, + }), + WEBHOOK_SECRET, + ); + expect(result).toEqual({ valid: true }); + }); + + it('should accept lowercase bearer-prefixed Authorization JWTs', () => { + const token = signMondayJwt( + { + accountId: 1, + exp: Math.floor(Date.now() / 1000) + 60, + }, + WEBHOOK_SECRET, + ); + const result = verifyMondayWebhookSignature( + makeWebhookRequest(createPulseEvent, { + authorization: `bearer ${token}`, + }), + WEBHOOK_SECRET, + ); + expect(result).toEqual({ valid: true }); + }); +}); + +describe('monday webhook handlers verify signatures', () => { + it('itemCreated rejects a forged signature with 401 and writes nothing', async () => { + const ctx = makeCtx(); + const result = await itemCreated.handler( + ctx, + makeWebhookRequest(createPulseEvent, { + secret: 'a-different-secret', + }) as never, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.statusCode).toBe(401); + } + expect(ctx.db.items?.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('itemCreated rejects when no secret is configured, and writes nothing', async () => { + const ctx = { ...makeCtx(), key: '' } as MondayContext; + const result = await itemCreated.handler( + ctx, + makeWebhookRequest(createPulseEvent) as never, + ); + + expect(result.success).toBe(false); + expect(ctx.db.items?.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('itemCreated accepts a correctly signed request and persists the item', async () => { + const ctx = makeCtx(); + const result = await itemCreated.handler( + ctx, + makeWebhookRequest(createPulseEvent) as never, + ); + + expect(result.success).toBe(true); + expect(ctx.db.items?.upsertByEntityId).toHaveBeenCalledWith( + '123', + expect.objectContaining({ id: '123', name: 'A task' }), + ); + }); + + it('statusChanged rejects a forged signature with 401', async () => { + const result = await statusChanged.handler( + makeCtx(), + makeWebhookRequest( + { type: 'change_status_column_value', pulseId: 1, boardId: 2 }, + { secret: 'a-different-secret' }, + ) as never, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.statusCode).toBe(401); + } + }); + + it('columnValueChanged rejects a forged signature with 401', async () => { + const result = await columnValueChanged.handler( + makeCtx(), + makeWebhookRequest( + { type: 'change_column_value', pulseId: 1, boardId: 2 }, + { secret: 'a-different-secret' }, + ) as never, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.statusCode).toBe(401); + } + }); +});