Skip to content

Commit 6fca667

Browse files
committed
feat: add brevo webhook validator
1 parent 9ce0139 commit 6fca667

File tree

10 files changed

+81
-4
lines changed

10 files changed

+81
-4
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-
- 19 [Webhook validators](#supported-webhook-validators)
17+
- 20 [Webhook validators](#supported-webhook-validators)
1818
- Works on the edge
1919
- Exposed [Server utils](#server-utils)
2020

@@ -78,6 +78,7 @@ Go to [playground/.env.example](./playground/.env.example) or [playground/nuxt.c
7878

7979
#### Supported webhook validators:
8080

81+
- Brevo
8182
- Discord
8283
- Dropbox
8384
- Fourthwall

playground/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Brevo Validator
2+
NUXT_WEBHOOK_BREVO_TOKEN=
3+
14
# Discord Validator
25
NUXT_WEBHOOK_DISCORD_PUBLIC_KEY=
36

playground/nuxt.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export default defineNuxtConfig({
88
devtools: { enabled: true },
99
runtimeConfig: {
1010
webhook: {
11+
brevo: {
12+
token: '',
13+
},
1114
discord: {
1215
publicKey: '',
1316
},
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default defineEventHandler(async (event) => {
2+
const isValidWebhook = await isValidBrevoWebhook(event)
3+
4+
if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5+
6+
return { isValidWebhook }
7+
})

src/module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export default defineNuxtModule<ModuleOptions>({
2020
const runtimeConfig = nuxt.options.runtimeConfig
2121
// Webhook settings
2222
runtimeConfig.webhook = defu(runtimeConfig.webhook, {})
23+
// Brevo Webhook
24+
runtimeConfig.webhook.brevo = defu(runtimeConfig.webhook.brevo, {
25+
token: '',
26+
})
2327
// Discord Webhook
2428
runtimeConfig.webhook.discord = defu(runtimeConfig.webhook.discord, {
2529
publicKey: '',
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { type H3Event, getRequestHeader, getRequestIP } from 'h3'
2+
import { useRuntimeConfig } from '#imports'
3+
4+
const ALLOWED_IP_RANGES = ['1.179.112.0/20', '172.246.240.0/20']
5+
6+
const ipToInt = (ip: string) => {
7+
return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0)
8+
}
9+
10+
const isIpInRange = (ip: string, cidr: string) => {
11+
const [range, bits] = cidr.split('/')
12+
if (!range || !bits) return false
13+
14+
const mask = ~(2 ** (32 - Number(bits)) - 1)
15+
return (ipToInt(ip) & mask) === (ipToInt(range) & mask)
16+
}
17+
18+
/**
19+
* Validates Brevo webhooks on the Edge
20+
* @see {@link https://help.brevo.com/hc/en-us/articles/27824932835474}
21+
* @param event H3Event
22+
* @returns {boolean} `true` if the webhook is valid, `false` otherwise
23+
*/
24+
export const isValidBrevoWebhook = async (event: H3Event): Promise<boolean> => {
25+
const ip = getRequestIP(event, { xForwardedFor: true })
26+
27+
if (!ip || !ALLOWED_IP_RANGES.some(cidr => isIpInRange(ip, cidr))) return false
28+
29+
// const config = ensureConfiguration('brevo', event) // No need to ensure brevo configuration
30+
const config = useRuntimeConfig(event).webhook.brevo
31+
32+
if (config.token) {
33+
const authorization = getRequestHeader(event, 'authorization') || ''
34+
if (!authorization.toLowerCase().startsWith('bearer ')) return false
35+
const token = authorization.slice(7)
36+
if (token !== config.token) return false
37+
}
38+
39+
return true
40+
}

test/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { simulateBrevoEvent } from './simulations/brevo'
12
export { simulateDiscordEvent } from './simulations/discord'
23
export { simulateDropboxEvent } from './simulations/dropbox'
34
export { simulateFourthwallEvent } from './simulations/fourthwall'

test/fixtures/basic/nuxt.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export default defineNuxtConfig({
77
modules: [myModule],
88
runtimeConfig: {
99
webhook: {
10+
brevo: {
11+
token: 'testToken',
12+
},
1013
discord: {
1114
publicKey: 'fcf4594ff55a5898a7e7ce541b93dc8ce618c7a4fa96ab7efd1ac2890571345c',
1215
},

test/module.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ describe('webhooks', async () => {
5050
// Iterate over the `events` object dynamically
5151
const events = await import('./events')
5252
for (const [methodName, simulation] of Object.entries(events)) {
53-
const match = methodName.match(/^simulate(.*)Event$/)
54-
if (!match) continue
55-
const webhookName = match[1]
53+
const [, webhookName] = methodName.match(/^simulate(.*)Event$/) || []
54+
if (!webhookName) continue
5655
it(`valid ${webhookName} webhook`, async () => {
5756
const response = await simulation()
5857
expect(response).toStrictEqual(validWebhook)

test/simulations/brevo.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { $fetch } from '@nuxt/test-utils/e2e'
2+
3+
const body = { test: 'testData' }
4+
5+
export const simulateBrevoEvent = async () => {
6+
const headers = {
7+
'authorization': 'Bearer testToken',
8+
'x-forwarded-for': '1.179.112.0',
9+
}
10+
11+
return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/brevo', {
12+
method: 'POST',
13+
headers,
14+
body,
15+
})
16+
}

0 commit comments

Comments
 (0)