From d804426c268e39a7ef01031192a4296db5a6344c Mon Sep 17 00:00:00 2001 From: Rudra2637 Date: Thu, 6 Aug 2026 10:40:32 +0530 Subject: [PATCH 1/2] fix(zohomail): validate handshake signature before persisting secret0 --- .../zohomail/webhooks.integration.test.ts | 107 +++++++++++++++++- packages/zohomail/webhooks/challenge.ts | 51 ++++++++- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/packages/zohomail/webhooks.integration.test.ts b/packages/zohomail/webhooks.integration.test.ts index 687c82525..42aa41e13 100644 --- a/packages/zohomail/webhooks.integration.test.ts +++ b/packages/zohomail/webhooks.integration.test.ts @@ -171,7 +171,7 @@ describe('Zoho Mail webhook — full bound pipeline', () => { testDb.cleanup(); }); - it('rejects handshake when signature does not match the secret', async () => { + it('rejects handshake when signature does not match the secret and does not persist', async () => { const { corsair, testDb } = await buildCorsair({ webhookSecret: undefined, }); @@ -188,6 +188,111 @@ describe('Zoho Mail webhook — full bound pipeline', () => { rawBody, }); expect(response.success).toBe(false); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBeNull(); + + testDb.cleanup(); + }); + + it('cannot overwrite existing secret with a bare x-hook-secret POST (missing signature)', async () => { + const { corsair, testDb } = await buildCorsair({ + webhookSecret: undefined, + }); + const handshake = corsair.zohomail.webhooks.challenge.handshake; + + // 1. Establish first-time secret + const initialSecret = 'initial-secret'; + await handshake.handler({ + payload: {}, + headers: { 'x-hook-secret': initialSecret }, + }); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + // 2. Attempt to overwrite with bare x-hook-secret request (no signature) + const newSecret = 'new-secret-attempt'; + const response = await handshake.handler({ + payload: {}, + headers: { 'x-hook-secret': newSecret }, + }); + + expect(response.success).toBe(false); + expect(response.error).toMatch(/Cannot overwrite existing secret/i); + + // 3. Verify the secret remains the initial one + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + testDb.cleanup(); + }); + + it('cannot overwrite existing secret with an invalid signature', async () => { + const { corsair, testDb } = await buildCorsair({ + webhookSecret: undefined, + }); + const handshake = corsair.zohomail.webhooks.challenge.handshake; + + // 1. Establish first-time secret + const initialSecret = 'initial-secret'; + await handshake.handler({ + payload: {}, + headers: { 'x-hook-secret': initialSecret }, + }); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + // 2. Attempt overwrite with new secret but signed with wrong/new secret instead of proving the old one + const newSecret = 'new-secret-attempt'; + const rawBody = eventBody(); + const response = await handshake.handler({ + payload: JSON.parse(rawBody), + headers: { + 'x-hook-secret': newSecret, + 'x-hook-signature': sign(rawBody, 'wrong-secret'), + }, + rawBody, + }); + + expect(response.success).toBe(false); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + testDb.cleanup(); + }); + + it('can overwrite/rotate existing secret with a valid signature matched against the old secret', async () => { + const { corsair, testDb } = await buildCorsair({ + webhookSecret: undefined, + }); + const handshake = corsair.zohomail.webhooks.challenge.handshake; + + // 1. Establish first-time secret + const initialSecret = 'initial-secret'; + await handshake.handler({ + payload: {}, + headers: { 'x-hook-secret': initialSecret }, + }); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + // 2. Legitimate rotation: sign request with the old/existing secret + const newSecret = 'rotated-secret'; + const rawBody = eventBody(); + const response = await handshake.handler({ + payload: JSON.parse(rawBody), + headers: { + 'x-hook-secret': newSecret, + 'x-hook-signature': sign(rawBody, initialSecret), // signs with old secret + }, + rawBody, + }); + + expect(response.success).toBe(true); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe(newSecret); testDb.cleanup(); }); diff --git a/packages/zohomail/webhooks/challenge.ts b/packages/zohomail/webhooks/challenge.ts index 7929fd2eb..fff72857d 100644 --- a/packages/zohomail/webhooks/challenge.ts +++ b/packages/zohomail/webhooks/challenge.ts @@ -27,22 +27,31 @@ export const handshake: ZohoMailWebhooks['handshake'] = { }; } + let existingSecret: string | undefined; try { - await ctx.keys.set_webhook_signature(hookSecret); + existingSecret = (await ctx.keys.get_webhook_signature()) ?? undefined; } catch (error) { console.warn( - '[corsair:zohomail] Failed to persist webhook secret:', + '[corsair:zohomail] Failed to retrieve existing webhook secret:', error, ); return { success: false, statusCode: 500, - error: 'Failed to persist webhook secret', + error: 'Failed to retrieve existing webhook secret', }; } const signature = getZohoWebhookSignature(headers); - if (signature) { + + if (existingSecret) { + if (!signature) { + return { + success: false, + statusCode: 401, + error: 'Cannot overwrite existing secret without a valid signature', + }; + } const rawBody = request.rawBody; if (!rawBody) { return { @@ -51,13 +60,45 @@ export const handshake: ZohoMailWebhooks['handshake'] = { error: 'Missing raw body for signature verification', }; } - if (!verifyZohoWebhookSignature(rawBody, hookSecret, signature)) { + if (!verifyZohoWebhookSignature(rawBody, existingSecret, signature)) { return { success: false, statusCode: 401, error: 'Invalid signature', }; } + } else { + if (signature) { + const rawBody = request.rawBody; + if (!rawBody) { + return { + success: false, + statusCode: 401, + error: 'Missing raw body for signature verification', + }; + } + if (!verifyZohoWebhookSignature(rawBody, hookSecret, signature)) { + return { + success: false, + statusCode: 401, + error: 'Invalid signature', + }; + } + } + } + + try { + await ctx.keys.set_webhook_signature(hookSecret); + } catch (error) { + console.warn( + '[corsair:zohomail] Failed to persist webhook secret:', + error, + ); + return { + success: false, + statusCode: 500, + error: 'Failed to persist webhook secret', + }; } return { From 01d666d3058d8ed34a556e852b594c6fd11c893f Mon Sep 17 00:00:00 2001 From: Rudra2637 Date: Sun, 9 Aug 2026 10:03:43 +0530 Subject: [PATCH 2/2] fix(zohomail): treat repeat handshake with identical secret as no-op ACK --- .../zohomail/webhooks.integration.test.ts | 32 +++++++++++++++++++ packages/zohomail/webhooks/challenge.ts | 7 ++++ 2 files changed, 39 insertions(+) diff --git a/packages/zohomail/webhooks.integration.test.ts b/packages/zohomail/webhooks.integration.test.ts index 42aa41e13..e169b5932 100644 --- a/packages/zohomail/webhooks.integration.test.ts +++ b/packages/zohomail/webhooks.integration.test.ts @@ -297,6 +297,38 @@ describe('Zoho Mail webhook — full bound pipeline', () => { testDb.cleanup(); }); + it('treats repeat handshake with the same secret as a no-op ACK', async () => { + const { corsair, testDb } = await buildCorsair({ + webhookSecret: undefined, + }); + const handshake = corsair.zohomail.webhooks.challenge.handshake; + + // 1. Establish first-time secret + const initialSecret = 'initial-secret'; + const firstResponse = await handshake.handler({ + payload: {}, + headers: { 'x-hook-secret': initialSecret }, + }); + expect(firstResponse.success).toBe(true); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + // 2. Retry handshake with the EXACT same secret (no signature) + const retryResponse = await handshake.handler({ + payload: {}, + headers: { 'x-hook-secret': initialSecret }, + }); + + expect(retryResponse.success).toBe(true); + expect(retryResponse.data?.hookSecret).toBe(initialSecret); + expect(await corsair.zohomail.keys.get_webhook_signature()).toBe( + initialSecret, + ); + + testDb.cleanup(); + }); + it('perserves large numeric messageId values from raw JSON', async () => { const { corsair, testDb } = await buildCorsair(); const wh = corsair.zohomail.webhooks.messages.received; diff --git a/packages/zohomail/webhooks/challenge.ts b/packages/zohomail/webhooks/challenge.ts index fff72857d..93574783a 100644 --- a/packages/zohomail/webhooks/challenge.ts +++ b/packages/zohomail/webhooks/challenge.ts @@ -42,6 +42,13 @@ export const handshake: ZohoMailWebhooks['handshake'] = { }; } + if (existingSecret === hookSecret) { + return { + success: true, + data: { hookSecret }, + }; + } + const signature = getZohoWebhookSignature(headers); if (existingSecret) {