diff --git a/README.md b/README.md index ca36802..f5c1903 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A simple nuxt module that works on the edge to easily validate incoming webhooks ## Features -- 20 [Webhook validators](#supported-webhook-validators) +- 21 [Webhook validators](#supported-webhook-validators) - Works on the edge - Exposed [Server utils](#server-utils) @@ -78,6 +78,7 @@ Go to [playground/.env.example](./playground/.env.example) or [playground/nuxt.c #### Supported webhook validators: +- Bitbucket - Brevo - Discord - Dropbox diff --git a/playground/.env.example b/playground/.env.example index 63ded04..c6f1da3 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -1,3 +1,6 @@ +# Bitbucket Validator +NUXT_WEBHOOK_BITBUCKET_SECRET_KEY= + # Brevo Validator NUXT_WEBHOOK_BREVO_TOKEN= diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 90ec9a6..58e39dc 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -8,6 +8,9 @@ export default defineNuxtConfig({ devtools: { enabled: true }, runtimeConfig: { webhook: { + bitbucket: { + secretKey: '', + }, brevo: { token: '', }, diff --git a/playground/server/api/webhooks/bitbucket.ts b/playground/server/api/webhooks/bitbucket.ts new file mode 100644 index 0000000..085bc93 --- /dev/null +++ b/playground/server/api/webhooks/bitbucket.ts @@ -0,0 +1,7 @@ +export default defineEventHandler(async (event) => { + const isValidWebhook = await isValidBitbucketWebhook(event) + + if (!isValidWebhook) throw createError({ status: 401, message: 'Unauthorized: webhook is not valid' }) + + return { isValidWebhook } +}) diff --git a/src/module.ts b/src/module.ts index 86a7b68..a37247f 100644 --- a/src/module.ts +++ b/src/module.ts @@ -20,6 +20,10 @@ export default defineNuxtModule({ const runtimeConfig = nuxt.options.runtimeConfig // Webhook settings runtimeConfig.webhook = defu(runtimeConfig.webhook, {}) + // Bitbucket Webhook + runtimeConfig.webhook.bitbucket = defu(runtimeConfig.webhook.bitbucket, { + secretKey: '', + }) // Brevo Webhook runtimeConfig.webhook.brevo = defu(runtimeConfig.webhook.brevo, { token: '', diff --git a/src/runtime/server/lib/validators/bitbucket.ts b/src/runtime/server/lib/validators/bitbucket.ts new file mode 100644 index 0000000..791bb27 --- /dev/null +++ b/src/runtime/server/lib/validators/bitbucket.ts @@ -0,0 +1,25 @@ +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' + +const BITBUCKET_SIGNATURE = 'X-Hub-Signature'.toLowerCase() +const HMAC_PREFIX = 'sha256=' + +/** + * Validates Bitbucket webhooks on the Edge + * @see {@link https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/#Validating-webhook-deliveries} + * @param event H3Event + * @returns {boolean} `true` if the webhook is valid, `false` otherwise + */ +export const isValidBitbucketWebhook = async (event: H3Event): Promise => { + const config = ensureConfiguration('bitbucket', event) + + const headers = getRequestHeaders(event) + const body = await readRawBodyClone(event) + + const signature = headers[BITBUCKET_SIGNATURE] + + if (!signature || !body) return false + + const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, body) + return signature === `${HMAC_PREFIX}${computedHash}` +} diff --git a/test/events.ts b/test/events.ts index f167be8..92f780d 100644 --- a/test/events.ts +++ b/test/events.ts @@ -1,3 +1,4 @@ +export { simulateBitbucketEvent } from './simulations/bitbucket' export { simulateBrevoEvent } from './simulations/brevo' export { simulateDiscordEvent } from './simulations/discord' export { simulateDropboxEvent } from './simulations/dropbox' diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 823036f..95afa6c 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -7,6 +7,9 @@ export default defineNuxtConfig({ modules: [myModule], runtimeConfig: { webhook: { + bitbucket: { + secretKey: 'testBitbucketSecretKey', + }, brevo: { token: 'testToken', }, diff --git a/test/simulations/bitbucket.ts b/test/simulations/bitbucket.ts new file mode 100644 index 0000000..b0230f4 --- /dev/null +++ b/test/simulations/bitbucket.ts @@ -0,0 +1,25 @@ +import { subtle } from 'node:crypto' +import { Buffer } from 'node:buffer' +import { $fetch } from '@nuxt/test-utils/e2e' +import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers' +import nuxtConfig from '../fixtures/basic/nuxt.config' + +const body = { data: 'testBody' } +const secretKey = nuxtConfig.runtimeConfig?.webhook?.bitbucket?.secretKey + +export const simulateBitbucketEvent = async () => { + const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign']) + const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(JSON.stringify(body))) + const computedHash = Buffer.from(hmac).toString('hex') + const validSignature = `sha256=${computedHash}` + + const headers = { + 'X-Hub-Signature': validSignature, + } + + return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/bitbucket', { + method: 'POST', + headers, + body, + }) +}