diff --git a/README.md b/README.md index abf787a..6e30123 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,13 @@ Go to [playground/.env.example](./playground/.env.example) or [playground/nuxt.c You can add your favorite webhook validator by creating a new file in [src/runtime/server/lib/validators/](./src/runtime/server/lib/validators/) -## Example +## Examples + +### Basic Validate a GitHub webhook in a server API route. -`~/server/api/webhooks/github.post.ts` +`~~/server/api/webhooks/github.post.ts` ```js export default defineEventHandler(async (event) => { @@ -122,6 +124,25 @@ export default defineEventHandler(async (event) => { }) ``` +## Reading the request body + +Make sure to read the body after validating the webhook, as most validators rely on a caching/cloning of the raw body to compute the signature and validate the webhook. + +```js +export default defineEventHandler(async (event) => { + const isValidWebhook = await isValidGitHubWebhook(event) + + if (!isValidWebhook) { + throw createError({ status: 401, message: 'Unauthorized: webhook is not valid' }) + } + + // Make sure to read the body after validating the webhook + const body = await readBody(event) + + return { isValidWebhook } +}) +``` + # Development ```sh diff --git a/playground/server/api/webhooks/slack.post.ts b/playground/server/api/webhooks/slack.post.ts index 6dc10dc..94ffdcc 100644 --- a/playground/server/api/webhooks/slack.post.ts +++ b/playground/server/api/webhooks/slack.post.ts @@ -1,12 +1,12 @@ export default defineEventHandler(async (event) => { + const isValidWebhook = await isValidSlackWebhook(event) + const body = await readBody(event) if (body.challenge) { return body.challenge } - const isValidWebhook = await isValidSlackWebhook(event) - if (!isValidWebhook) throw createError({ status: 401, message: 'Unauthorized: webhook is not valid' }) return { isValidWebhook } diff --git a/src/runtime/server/lib/helpers.ts b/src/runtime/server/lib/helpers.ts index 62bb6eb..69eae7d 100644 --- a/src/runtime/server/lib/helpers.ts +++ b/src/runtime/server/lib/helpers.ts @@ -1,7 +1,7 @@ import { subtle, type webcrypto } from 'node:crypto' import { Buffer } from 'node:buffer' import { snakeCase } from 'scule' -import { type H3Event, createError } from 'h3' +import { type H3Event, createError, readRawBody } from 'h3' import type { RuntimeConfig } from '@nuxt/schema' import { useRuntimeConfig } from '#imports' @@ -90,3 +90,17 @@ export const validateSha256 = async ( ) => { return hash === await sha256(payload, options?.encoding) } + +export const readRawBodyClone = async (event: H3Event): Promise => { + const hasClone = 'clone' in (event.req ?? {}) // prepare h3 v2 support + const body = hasClone ? await (event.req as unknown as Request).clone().text() : await readRawBody(event) + if (!hasClone) { + (event as { _requestBody?: string })._requestBody = body + } + return body +} + +export const readBodyClone = async (event: H3Event): Promise => { + const rawBody = await readRawBodyClone(event) + return rawBody ? JSON.parse(rawBody) as T : undefined +} diff --git a/src/runtime/server/lib/validators/discord.ts b/src/runtime/server/lib/validators/discord.ts index 6a20427..8e14423 100644 --- a/src/runtime/server/lib/validators/discord.ts +++ b/src/runtime/server/lib/validators/discord.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { verifyPublicSignature, ED25519, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { verifyPublicSignature, ED25519, ensureConfiguration, readRawBodyClone } from '../helpers' const DISCORD_SIGNATURE = 'x-signature-ed25519' const DISCORD_SIGNATURE_TIMESTAMP = 'x-signature-timestamp' @@ -14,7 +14,7 @@ export const isValidDiscordWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('discord', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookSignature = headers[DISCORD_SIGNATURE] const webhookTimestamp = headers[DISCORD_SIGNATURE_TIMESTAMP] diff --git a/src/runtime/server/lib/validators/dropbox.ts b/src/runtime/server/lib/validators/dropbox.ts index 9444bd8..5eedace 100644 --- a/src/runtime/server/lib/validators/dropbox.ts +++ b/src/runtime/server/lib/validators/dropbox.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const DROPBOX_SIGNATURE = 'X-Dropbox-Signature'.toLowerCase() @@ -13,7 +13,7 @@ export const isValidDropboxWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('dropbox', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookSignature = headers[DROPBOX_SIGNATURE] diff --git a/src/runtime/server/lib/validators/fourthwall.ts b/src/runtime/server/lib/validators/fourthwall.ts index e557c3a..9490e78 100644 --- a/src/runtime/server/lib/validators/fourthwall.ts +++ b/src/runtime/server/lib/validators/fourthwall.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const FOURTHWALL_SIGNATURE = 'X-Fourthwall-Hmac-SHA256'.toLowerCase() @@ -13,7 +13,7 @@ export const isValidFourthwallWebhook = async (event: H3Event): Promise const config = ensureConfiguration('fourthwall', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookSignature = headers[FOURTHWALL_SIGNATURE] diff --git a/src/runtime/server/lib/validators/github.ts b/src/runtime/server/lib/validators/github.ts index 7c88f9c..abe4922 100644 --- a/src/runtime/server/lib/validators/github.ts +++ b/src/runtime/server/lib/validators/github.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const GITHUB_SIGNATURE = 'X-Hub-Signature-256'.toLowerCase() @@ -13,7 +13,7 @@ export const isValidGitHubWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('github', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const header = headers[GITHUB_SIGNATURE] diff --git a/src/runtime/server/lib/validators/gitlab.ts b/src/runtime/server/lib/validators/gitlab.ts index 3b1c23e..82153cd 100644 --- a/src/runtime/server/lib/validators/gitlab.ts +++ b/src/runtime/server/lib/validators/gitlab.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { ensureConfiguration, readRawBodyClone } from '../helpers' const GITLAB_TOKEN = 'X-Gitlab-Token'.toLowerCase() @@ -13,7 +13,7 @@ export const isValidGitLabWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('gitlab', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const header = headers[GITLAB_TOKEN] diff --git a/src/runtime/server/lib/validators/heroku.ts b/src/runtime/server/lib/validators/heroku.ts index 40a8fde..9859847 100644 --- a/src/runtime/server/lib/validators/heroku.ts +++ b/src/runtime/server/lib/validators/heroku.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const HEROKU_HMAC = 'Heroku-Webhook-Hmac-SHA256'.toLowerCase() @@ -13,7 +13,7 @@ export const isValidHerokuWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('heroku', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const header = headers[HEROKU_HMAC] diff --git a/src/runtime/server/lib/validators/hygraph.ts b/src/runtime/server/lib/validators/hygraph.ts index 39cbaf9..1912cc1 100644 --- a/src/runtime/server/lib/validators/hygraph.ts +++ b/src/runtime/server/lib/validators/hygraph.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const DEFAULT_TOLERANCE = 300 // 5 minutes tolerance const HYGRAPH_SIGNATURE = 'gcms-signature' @@ -38,7 +38,7 @@ export const isValidHygraphWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('hygraph', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const hygraphSignature = headers[HYGRAPH_SIGNATURE] diff --git a/src/runtime/server/lib/validators/kick.ts b/src/runtime/server/lib/validators/kick.ts index d7ae0ce..324b0f8 100644 --- a/src/runtime/server/lib/validators/kick.ts +++ b/src/runtime/server/lib/validators/kick.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { verifyPublicSignature, RSASSA_PKCS1_v1_5_SHA256, stripPemHeaders } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { verifyPublicSignature, RSASSA_PKCS1_v1_5_SHA256, stripPemHeaders, readRawBodyClone } from '../helpers' import { useRuntimeConfig } from '#imports' const KICK_MESSAGE_ID = 'Kick-Event-Message-Id'.toLowerCase() @@ -27,7 +27,7 @@ export const isValidKickWebhook = async (event: H3Event): Promise => { const config = useRuntimeConfig(event).webhook.kick const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const messageId = headers[KICK_MESSAGE_ID] const messageTimestamp = headers[KICK_MESSAGE_TIMESTAMP] diff --git a/src/runtime/server/lib/validators/mailchannels.ts b/src/runtime/server/lib/validators/mailchannels.ts index 3615724..0899558 100644 --- a/src/runtime/server/lib/validators/mailchannels.ts +++ b/src/runtime/server/lib/validators/mailchannels.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { verifyPublicSignature, ED25519, validateSha256, stripPemHeaders } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { verifyPublicSignature, ED25519, validateSha256, stripPemHeaders, readRawBodyClone } from '../helpers' import { useRuntimeConfig } from '#imports' const MAILCHANNELS_CONTENT_DIGEST = 'content-digest' @@ -50,7 +50,7 @@ export const isValidMailChannelsWebhook = async (event: H3Event): Promise => { const config = ensureConfiguration('meta', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const signatureHeader = headers[META_SIGNATURE] diff --git a/src/runtime/server/lib/validators/nuxthub.ts b/src/runtime/server/lib/validators/nuxthub.ts index b9f1857..4809310 100644 --- a/src/runtime/server/lib/validators/nuxthub.ts +++ b/src/runtime/server/lib/validators/nuxthub.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { ensureConfiguration, sha256 } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { ensureConfiguration, readRawBodyClone, sha256 } from '../helpers' const NUXTHUB_SIGNATURE = 'x-nuxthub-signature' @@ -13,7 +13,7 @@ export const isValidNuxtHubWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('nuxthub', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookSignature = headers[NUXTHUB_SIGNATURE] diff --git a/src/runtime/server/lib/validators/paddle.ts b/src/runtime/server/lib/validators/paddle.ts index a6bc044..5673432 100644 --- a/src/runtime/server/lib/validators/paddle.ts +++ b/src/runtime/server/lib/validators/paddle.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const MAX_VALID_TIME_DIFFERENCE = 5 const PADDLE_SIGNATURE = 'paddle-signature' @@ -29,7 +29,7 @@ export const isValidPaddleWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('paddle', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const paddleSignature = headers[PADDLE_SIGNATURE] diff --git a/src/runtime/server/lib/validators/paypal.ts b/src/runtime/server/lib/validators/paypal.ts index b8ab278..85950f3 100644 --- a/src/runtime/server/lib/validators/paypal.ts +++ b/src/runtime/server/lib/validators/paypal.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readBody } from 'h3' -import { ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { ensureConfiguration, readBodyClone } from '../helpers' const baseAPI = import.meta.dev ? 'https://api-m.sandbox.paypal.com/v1' : 'https://api-m.paypal.com/v1' @@ -13,7 +13,7 @@ export const isValidPaypalWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('paypal', event) const headers = getRequestHeaders(event) - const body = await readBody(event) + const body = await readBodyClone(event) if (!body || !headers) return false diff --git a/src/runtime/server/lib/validators/polar.ts b/src/runtime/server/lib/validators/polar.ts index a4abe97..fe7c84f 100644 --- a/src/runtime/server/lib/validators/polar.ts +++ b/src/runtime/server/lib/validators/polar.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const POLAR_SIGNATURE_ID = 'webhook-id' const POLAR_SIGNATURE = 'webhook-signature' @@ -16,7 +16,7 @@ export const isValidPolarWebhook = async (event: H3Event): Promise => { const config = ensureConfiguration('polar', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookId = headers[POLAR_SIGNATURE_ID] const webhookSignature = headers[POLAR_SIGNATURE] diff --git a/src/runtime/server/lib/validators/shopify.ts b/src/runtime/server/lib/validators/shopify.ts index 7695325..b8d463a 100644 --- a/src/runtime/server/lib/validators/shopify.ts +++ b/src/runtime/server/lib/validators/shopify.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const SHOPIFY_SIGNATURE = 'X-Shopify-Hmac-Sha256'.toLowerCase() @@ -13,7 +13,7 @@ export const isValidShopifyWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('shopify', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookSignature = headers[SHOPIFY_SIGNATURE] diff --git a/src/runtime/server/lib/validators/slack.ts b/src/runtime/server/lib/validators/slack.ts index 5b2359e..a192198 100644 --- a/src/runtime/server/lib/validators/slack.ts +++ b/src/runtime/server/lib/validators/slack.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const SLACK_SIGNATURE = 'X-Slack-Signature'.toLowerCase() const SLACK_TIMESTAMP = 'X-Slack-Request-Timestamp'.toLowerCase() @@ -15,7 +15,7 @@ export const isValidSlackWebhook = async (event: H3Event): Promise => { const config = ensureConfiguration('slack', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const fullSignature = headers[SLACK_SIGNATURE] const timestamp = headers[SLACK_TIMESTAMP] diff --git a/src/runtime/server/lib/validators/stripe.ts b/src/runtime/server/lib/validators/stripe.ts index b0c0844..2896042 100644 --- a/src/runtime/server/lib/validators/stripe.ts +++ b/src/runtime/server/lib/validators/stripe.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const DEFAULT_TOLERANCE = 300 const STRIPE_SIGNATURE = 'Stripe-Signature'.toLowerCase() @@ -29,7 +29,7 @@ export const isValidStripeWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('stripe', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const stripeSignature = headers[STRIPE_SIGNATURE] diff --git a/src/runtime/server/lib/validators/svix.ts b/src/runtime/server/lib/validators/svix.ts index 5e65b6c..eceae8f 100644 --- a/src/runtime/server/lib/validators/svix.ts +++ b/src/runtime/server/lib/validators/svix.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const SVIX_SIGNATURE_ID = 'svix-id' const SVIX_SIGNATURE = 'svix-signature' @@ -15,7 +15,7 @@ export const isValidSvixWebhook = async (event: H3Event): Promise => { const config = ensureConfiguration('svix', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const webhookId = headers[SVIX_SIGNATURE_ID] const webhookSignature = headers[SVIX_SIGNATURE] diff --git a/src/runtime/server/lib/validators/twitch.ts b/src/runtime/server/lib/validators/twitch.ts index ba16ac5..b1538f3 100644 --- a/src/runtime/server/lib/validators/twitch.ts +++ b/src/runtime/server/lib/validators/twitch.ts @@ -1,5 +1,5 @@ -import { type H3Event, getRequestHeaders, readRawBody } from 'h3' -import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers' +import { type H3Event, getRequestHeaders } from 'h3' +import { computeSignature, HMAC_SHA256, ensureConfiguration, readRawBodyClone } from '../helpers' const TWITCH_MESSAGE_ID = 'Twitch-Eventsub-Message-Id'.toLowerCase() const TWITCH_MESSAGE_TIMESTAMP = 'Twitch-Eventsub-Message-Timestamp'.toLowerCase() @@ -16,7 +16,7 @@ export const isValidTwitchWebhook = async (event: H3Event): Promise => const config = ensureConfiguration('twitch', event) const headers = getRequestHeaders(event) - const body = await readRawBody(event) + const body = await readRawBodyClone(event) const message_id = headers[TWITCH_MESSAGE_ID] const message_timestamp = headers[TWITCH_MESSAGE_TIMESTAMP]