diff --git a/packages/zohomail/webhooks.integration.test.ts b/packages/zohomail/webhooks.integration.test.ts index 687c82525..e169b5932 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,143 @@ 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(); + }); + + 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(); }); diff --git a/packages/zohomail/webhooks/challenge.ts b/packages/zohomail/webhooks/challenge.ts index 7929fd2eb..93574783a 100644 --- a/packages/zohomail/webhooks/challenge.ts +++ b/packages/zohomail/webhooks/challenge.ts @@ -27,22 +27,38 @@ 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', + }; + } + + if (existingSecret === hookSecret) { + return { + success: true, + data: { hookSecret }, }; } 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 +67,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 {