Skip to content

Commit 5e78215

Browse files
committed
feat: add Slack webhook validator
1 parent 8b4fb48 commit 5e78215

File tree

9 files changed

+92
-1
lines changed

9 files changed

+92
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ A simple nuxt module that works on the edge to easily validate incoming webhooks
1414

1515
## Features
1616

17-
- 20 [Webhook validators](#supported-webhook-validators)
17+
- 21 [Webhook validators](#supported-webhook-validators)
1818
- Works on the edge
1919
- Exposed [Server utils](#server-utils)
2020

@@ -95,6 +95,7 @@ Go to [playground/.env.example](./playground/.env.example) or [playground/nuxt.c
9595
- Polar
9696
- Resend
9797
- Shopify
98+
- Slack
9899
- Stripe
99100
- Svix
100101
- Twitch

playground/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ NUXT_WEBHOOK_RESEND_SECRET_KEY=
4545
# Shopify Validator
4646
NUXT_WEBHOOK_SHOPIFY_SECRET_KEY=
4747

48+
# Slack Validator
49+
NUXT_WEBHOOK_SLACK_SECRET_KEY=
50+
4851
# Stripe Validator
4952
NUXT_WEBHOOK_STRIPE_SECRET_KEY=
5053

playground/nuxt.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export default defineNuxtConfig({
5757
shopify: {
5858
secretKey: '',
5959
},
60+
slack: {
61+
secretKey: '',
62+
},
6063
stripe: {
6164
secretKey: '',
6265
},
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default defineEventHandler(async (event) => {
2+
const body = await readBody(event)
3+
4+
if (body.challenge) {
5+
return body.challenge
6+
}
7+
8+
const isValidWebhook = await isValidSlackWebhook(event)
9+
10+
if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
11+
12+
return { isValidWebhook }
13+
})

src/module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export default defineNuxtModule<ModuleOptions>({
8282
runtimeConfig.webhook.shopify = defu(runtimeConfig.webhook.shopify, {
8383
secretKey: '',
8484
})
85+
// Slack Webhook
86+
runtimeConfig.webhook.slack = defu(runtimeConfig.webhook.slack, {
87+
secretKey: '',
88+
})
8589
// Stripe Webhook
8690
runtimeConfig.webhook.stripe = defu(runtimeConfig.webhook.stripe, {
8791
secretKey: '',
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2+
import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3+
4+
const SLACK_SIGNATURE = 'X-Slack-Signature'.toLowerCase()
5+
const SLACK_TIMESTAMP = 'X-Slack-Request-Timestamp'.toLowerCase()
6+
const DEFAULT_TOLERANCE = 300 // 5 minutes
7+
8+
/**
9+
* Validates Slack webhooks on the Edge
10+
* @see {@link https://docs.slack.dev/authentication/verifying-requests-from-slack/}
11+
* @param event H3Event
12+
* @returns {boolean} `true` if the webhook is valid, `false` otherwise
13+
*/
14+
export const isValidSlackWebhook = async (event: H3Event): Promise<boolean> => {
15+
const config = ensureConfiguration('slack', event)
16+
17+
const headers = getRequestHeaders(event)
18+
const body = await readRawBody(event)
19+
20+
const fullSignature = headers[SLACK_SIGNATURE]
21+
const timestamp = headers[SLACK_TIMESTAMP]
22+
23+
if (!body || !fullSignature || !timestamp) return false
24+
25+
// Validate the timestamp to avoid replay attacks
26+
const now = Math.floor(Date.now() / 1000)
27+
if (now - Number.parseInt(timestamp) > DEFAULT_TOLERANCE) return false
28+
29+
const [signatureVersion, webhookSignature] = fullSignature.split('=')
30+
const payload = `${signatureVersion}:${timestamp}:${body}`
31+
32+
const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, payload)
33+
return computedHash === webhookSignature
34+
}

test/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { simulateTwitchEvent } from './simulations/twitch'
1111
export { simulatePaypalEvent } from './simulations/paypal'
1212
export { simulateResendEvent } from './simulations/resend'
1313
export { simulateShopifyEvent } from './simulations/shopify'
14+
export { simulateSlackEvent } from './simulations/slack'
1415
export { simulateStripeEvent } from './simulations/stripe'
1516
export { simulateSvixEvent } from './simulations/svix'
1617
export { simulateNuxthubEvent } from './simulations/nuxthub'

test/fixtures/basic/nuxt.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export default defineNuxtConfig({
5656
shopify: {
5757
secretKey: 'testShopifySecretKey',
5858
},
59+
slack: {
60+
secretKey: 'testSlackSecretKey',
61+
},
5962
stripe: {
6063
secretKey: 'testStripeSecretKey',
6164
},

test/simulations/slack.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { subtle } from 'node:crypto'
2+
import { Buffer } from 'node:buffer'
3+
import { $fetch } from '@nuxt/test-utils/e2e'
4+
import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5+
import nuxtConfig from '../fixtures/basic/nuxt.config'
6+
7+
const body = { data: 'testBody' }
8+
const secretKey = nuxtConfig.runtimeConfig?.webhook?.slack?.secretKey
9+
const signatureVersion = 'v0'
10+
11+
export const simulateSlackEvent = async () => {
12+
const timestamp = Math.floor(Date.now() / 1000)
13+
const signingPayload = `${signatureVersion}:${timestamp}:${JSON.stringify(body)}`
14+
const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
15+
const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(signingPayload))
16+
const computedHash = Buffer.from(hmac).toString('hex')
17+
const validSignature = `${signatureVersion}=${computedHash}`
18+
19+
const headers = {
20+
'X-Slack-Signature': validSignature,
21+
'X-Slack-Request-Timestamp': timestamp.toString(),
22+
}
23+
24+
return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/slack', {
25+
method: 'POST',
26+
headers,
27+
body,
28+
})
29+
}

0 commit comments

Comments
 (0)