hono-webhooks 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -37
- package/dist/cli.js +183 -35
- package/dist/index.d.ts +69 -11
- package/dist/index.js +60 -19
- package/dist/otel.d.ts +9 -5
- package/dist/otel.js +8 -5
- package/dist/signatures.d.ts +64 -0
- package/dist/signatures.js +171 -24
- package/package.json +2 -2
- package/src/cli-commands.ts +19 -4
- package/src/index.ts +141 -31
- package/src/otel.ts +17 -9
- package/src/signatures.ts +277 -33
package/src/signatures.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from 'hono'
|
|
2
|
-
import type { Verify } from './index'
|
|
2
|
+
import type { Verify, VerifyInput } from './index'
|
|
3
3
|
|
|
4
4
|
/** A signing secret, or a function reading it per request (e.g. from `c.env`). */
|
|
5
5
|
export type Secret = string | ((c: Context<any>) => string | Promise<string>)
|
|
@@ -25,8 +25,31 @@ export interface HmacOptions {
|
|
|
25
25
|
hash?: 'SHA-1' | 'SHA-256' | 'SHA-512'
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The URL a provider signed. Behind a proxy `c.req.url` is the internal URL,
|
|
30
|
+
* so return the public one; return several and any match passes.
|
|
31
|
+
* @default (c) => c.req.url
|
|
32
|
+
*/
|
|
33
|
+
export type SignedUrl = (
|
|
34
|
+
c: Context<any>
|
|
35
|
+
) => string | readonly string[] | Promise<string | readonly string[]>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a built-in scheme rejected a request, passed to `fail` and so to
|
|
39
|
+
* `onEvent` as `validation.issues[0].message`.
|
|
40
|
+
*/
|
|
41
|
+
export type VerifyFailureReason =
|
|
42
|
+
| 'missing-header'
|
|
43
|
+
| 'malformed'
|
|
44
|
+
| 'timestamp-out-of-tolerance'
|
|
45
|
+
| 'mismatch'
|
|
46
|
+
|
|
28
47
|
const encoder = new TextEncoder()
|
|
29
48
|
|
|
49
|
+
/** `fail` is optional here so a scheme called by hand with a bare input still works. */
|
|
50
|
+
const reject = (input: VerifyInput, reason: VerifyFailureReason) =>
|
|
51
|
+
input.fail?.(reason) ?? false
|
|
52
|
+
|
|
30
53
|
/** A string secret is used as UTF-8; bytes are used as is. */
|
|
31
54
|
function hmacKey(secret: string | Uint8Array, hash: string) {
|
|
32
55
|
if (!secret.length) {
|
|
@@ -91,6 +114,12 @@ function fromBase64(base64: string) {
|
|
|
91
114
|
const resolveSecret = async (secret: Secret, c: Context) =>
|
|
92
115
|
typeof secret === 'function' ? secret(c) : secret
|
|
93
116
|
|
|
117
|
+
const signedUrls = async (url: SignedUrl | undefined, c: Context) =>
|
|
118
|
+
[(await url?.(c)) ?? c.req.url].flat()
|
|
119
|
+
|
|
120
|
+
const outsideTolerance = (seconds: number, toleranceSeconds: number) =>
|
|
121
|
+
Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds
|
|
122
|
+
|
|
94
123
|
/**
|
|
95
124
|
* A scheme where one header carries an HMAC of the raw body. Covers most
|
|
96
125
|
* providers; `github` and `shopify` are built with it.
|
|
@@ -102,22 +131,23 @@ export function hmac(options: HmacOptions): SignatureScheme {
|
|
|
102
131
|
const { header, encoding, prefix = '', hash = 'SHA-256' } = options
|
|
103
132
|
const name = header.toLowerCase()
|
|
104
133
|
return {
|
|
105
|
-
verify:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await resolveSecret(secret, c),
|
|
116
|
-
rawBody,
|
|
134
|
+
verify: (secret) => async (input) => {
|
|
135
|
+
const value = input.headers[name]
|
|
136
|
+
if (!value) return reject(input, 'missing-header')
|
|
137
|
+
if (!value.startsWith(prefix)) return reject(input, 'malformed')
|
|
138
|
+
const encoded = value.slice(prefix.length)
|
|
139
|
+
const signature =
|
|
140
|
+
encoding === 'hex' ? fromHex(encoded) : fromBase64(encoded)
|
|
141
|
+
if (!signature) return reject(input, 'malformed')
|
|
142
|
+
return (
|
|
143
|
+
(await hmacVerify(
|
|
144
|
+
await resolveSecret(secret, input.c),
|
|
145
|
+
input.rawBody,
|
|
117
146
|
signature,
|
|
118
147
|
hash
|
|
119
|
-
)
|
|
120
|
-
|
|
148
|
+
)) || reject(input, 'mismatch')
|
|
149
|
+
)
|
|
150
|
+
},
|
|
121
151
|
async sign(rawBody, secret) {
|
|
122
152
|
const signature = await hmacSign(secret, rawBody, hash)
|
|
123
153
|
return {
|
|
@@ -134,6 +164,9 @@ export const github = hmac({
|
|
|
134
164
|
prefix: 'sha256='
|
|
135
165
|
})
|
|
136
166
|
|
|
167
|
+
/** Meta (WhatsApp, Messenger, Instagram): signs like GitHub, with the app secret. */
|
|
168
|
+
export const meta = github
|
|
169
|
+
|
|
137
170
|
/** Shopify: `x-shopify-hmac-sha256: <base64>`. */
|
|
138
171
|
export const shopify = hmac({
|
|
139
172
|
header: 'x-shopify-hmac-sha256',
|
|
@@ -150,27 +183,27 @@ export const stripe: SignatureScheme<
|
|
|
150
183
|
> = {
|
|
151
184
|
verify:
|
|
152
185
|
(secret, { toleranceSeconds = 300 } = {}) =>
|
|
153
|
-
async (
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
186
|
+
async (input) => {
|
|
187
|
+
const header = input.headers['stripe-signature']
|
|
188
|
+
if (!header) return reject(input, 'missing-header')
|
|
189
|
+
const parts = header.split(',').map((part) => part.split('='))
|
|
157
190
|
// Signed over the timestamp exactly as sent.
|
|
158
191
|
const timestamp = parts.find(([k]) => k === 't')?.[1] ?? ''
|
|
159
|
-
if (!/^\d+$/.test(timestamp)) return
|
|
160
|
-
if (
|
|
161
|
-
return
|
|
192
|
+
if (!/^\d+$/.test(timestamp)) return reject(input, 'malformed')
|
|
193
|
+
if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
|
|
194
|
+
return reject(input, 'timestamp-out-of-tolerance')
|
|
162
195
|
}
|
|
163
|
-
const key = await resolveSecret(secret, c)
|
|
196
|
+
const key = await resolveSecret(secret, input.c)
|
|
164
197
|
for (const [k, v] of parts) {
|
|
165
198
|
const signature = k === 'v1' && v ? fromHex(v) : undefined
|
|
166
199
|
if (
|
|
167
200
|
signature &&
|
|
168
|
-
(await hmacVerify(key, `${timestamp}.${rawBody}`, signature))
|
|
201
|
+
(await hmacVerify(key, `${timestamp}.${input.rawBody}`, signature))
|
|
169
202
|
) {
|
|
170
203
|
return true
|
|
171
204
|
}
|
|
172
205
|
}
|
|
173
|
-
return
|
|
206
|
+
return reject(input, 'mismatch')
|
|
174
207
|
},
|
|
175
208
|
async sign(rawBody, secret, { timestamp } = {}) {
|
|
176
209
|
const t = timestamp ?? Math.floor(Date.now() / 1000)
|
|
@@ -201,28 +234,36 @@ export const svix: SignatureScheme<
|
|
|
201
234
|
> = {
|
|
202
235
|
verify:
|
|
203
236
|
(secret, { toleranceSeconds = 300 } = {}) =>
|
|
204
|
-
async (
|
|
237
|
+
async (input) => {
|
|
238
|
+
const { headers } = input
|
|
205
239
|
const prefix = headers['svix-id'] ? 'svix' : 'webhook'
|
|
206
240
|
const id = headers[`${prefix}-id`]
|
|
207
|
-
const timestamp = headers[`${prefix}-timestamp`]
|
|
241
|
+
const timestamp = headers[`${prefix}-timestamp`]
|
|
208
242
|
const signatures = headers[`${prefix}-signature`]
|
|
209
|
-
if (!id || !signatures ||
|
|
210
|
-
|
|
211
|
-
|
|
243
|
+
if (!id || !signatures || !timestamp) {
|
|
244
|
+
return reject(input, 'missing-header')
|
|
245
|
+
}
|
|
246
|
+
if (!/^\d+$/.test(timestamp)) return reject(input, 'malformed')
|
|
247
|
+
if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
|
|
248
|
+
return reject(input, 'timestamp-out-of-tolerance')
|
|
212
249
|
}
|
|
213
|
-
const key = svixKey(await resolveSecret(secret, c))
|
|
250
|
+
const key = svixKey(await resolveSecret(secret, input.c))
|
|
214
251
|
for (const entry of signatures.split(' ')) {
|
|
215
252
|
const [version, encoded] = entry.split(',')
|
|
216
253
|
const signature =
|
|
217
254
|
version === 'v1' && encoded ? fromBase64(encoded) : undefined
|
|
218
255
|
if (
|
|
219
256
|
signature &&
|
|
220
|
-
(await hmacVerify(
|
|
257
|
+
(await hmacVerify(
|
|
258
|
+
key,
|
|
259
|
+
`${id}.${timestamp}.${input.rawBody}`,
|
|
260
|
+
signature
|
|
261
|
+
))
|
|
221
262
|
) {
|
|
222
263
|
return true
|
|
223
264
|
}
|
|
224
265
|
}
|
|
225
|
-
return
|
|
266
|
+
return reject(input, 'mismatch')
|
|
226
267
|
},
|
|
227
268
|
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
228
269
|
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll('-', '')}`
|
|
@@ -238,3 +279,206 @@ export const svix: SignatureScheme<
|
|
|
238
279
|
}
|
|
239
280
|
}
|
|
240
281
|
}
|
|
282
|
+
|
|
283
|
+
export interface SharedSecretOptions {
|
|
284
|
+
/** Header carrying the secret, e.g. `x-gitlab-token` or `authorization`. */
|
|
285
|
+
header: string
|
|
286
|
+
/** Text before the secret in the header, e.g. `Bearer `. */
|
|
287
|
+
prefix?: string
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Web Crypto has no timingSafeEqual: HMAC the expected secret under a random
|
|
291
|
+
// key, then let crypto.subtle.verify (constant time) check the given one.
|
|
292
|
+
let compareKey: Promise<CryptoKey> | undefined
|
|
293
|
+
async function secretsEqual(given: string, expected: string) {
|
|
294
|
+
if (!expected) throw new Error('hono-webhooks: the shared secret is empty')
|
|
295
|
+
compareKey ??= crypto.subtle.generateKey(
|
|
296
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
297
|
+
false,
|
|
298
|
+
['sign', 'verify']
|
|
299
|
+
) as Promise<CryptoKey>
|
|
300
|
+
const key = await compareKey
|
|
301
|
+
const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(expected))
|
|
302
|
+
return crypto.subtle.verify('HMAC', key, mac, encoder.encode(given))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A secret sent as is in a header (GitLab's `x-gitlab-token`, Telegram's
|
|
307
|
+
* `x-telegram-bot-api-secret-token`, `authorization: Bearer <token>`),
|
|
308
|
+
* compared in constant time. An empty secret throws (a 500).
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* verify: sharedSecret({ header: 'x-gitlab-token' }).verify((c) => c.env.GITLAB_TOKEN)
|
|
312
|
+
*/
|
|
313
|
+
export function sharedSecret(options: SharedSecretOptions): SignatureScheme {
|
|
314
|
+
const { prefix = '' } = options
|
|
315
|
+
const name = options.header.toLowerCase()
|
|
316
|
+
return {
|
|
317
|
+
verify: (secret) => async (input) => {
|
|
318
|
+
const value = input.headers[name]
|
|
319
|
+
if (!value) return reject(input, 'missing-header')
|
|
320
|
+
if (!value.startsWith(prefix)) return reject(input, 'malformed')
|
|
321
|
+
return (
|
|
322
|
+
(await secretsEqual(
|
|
323
|
+
value.slice(prefix.length),
|
|
324
|
+
await resolveSecret(secret, input.c)
|
|
325
|
+
)) || reject(input, 'mismatch')
|
|
326
|
+
)
|
|
327
|
+
},
|
|
328
|
+
async sign(_rawBody, secret) {
|
|
329
|
+
return { [name]: `${prefix}${secret}` }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Infisical: `x-infisical-signature: t=<ms>;<hex>`, an HMAC-SHA256 of the
|
|
336
|
+
* JSON body. The body's own `timestamp` (milliseconds) is what is signed, so
|
|
337
|
+
* that is what must be within `toleranceSeconds` (default 300).
|
|
338
|
+
*/
|
|
339
|
+
export const infisical: SignatureScheme<{ toleranceSeconds?: number }> = {
|
|
340
|
+
verify:
|
|
341
|
+
(secret, { toleranceSeconds = 300 } = {}) =>
|
|
342
|
+
async (input) => {
|
|
343
|
+
const header = input.headers['x-infisical-signature']
|
|
344
|
+
if (!header) return reject(input, 'missing-header')
|
|
345
|
+
const signature = fromHex(header.split(';')[1] ?? '')
|
|
346
|
+
if (!signature?.length) return reject(input, 'malformed')
|
|
347
|
+
let timestamp: unknown
|
|
348
|
+
try {
|
|
349
|
+
timestamp = JSON.parse(input.rawBody)?.timestamp
|
|
350
|
+
} catch {}
|
|
351
|
+
if (typeof timestamp !== 'number') return reject(input, 'malformed')
|
|
352
|
+
if (
|
|
353
|
+
!(await hmacVerify(
|
|
354
|
+
await resolveSecret(secret, input.c),
|
|
355
|
+
input.rawBody,
|
|
356
|
+
signature
|
|
357
|
+
))
|
|
358
|
+
) {
|
|
359
|
+
return reject(input, 'mismatch')
|
|
360
|
+
}
|
|
361
|
+
return (
|
|
362
|
+
!outsideTolerance(timestamp / 1000, toleranceSeconds) ||
|
|
363
|
+
reject(input, 'timestamp-out-of-tolerance')
|
|
364
|
+
)
|
|
365
|
+
},
|
|
366
|
+
async sign(rawBody, secret) {
|
|
367
|
+
let t: unknown
|
|
368
|
+
try {
|
|
369
|
+
t = JSON.parse(rawBody)?.timestamp
|
|
370
|
+
} catch {}
|
|
371
|
+
const signature = toHex(await hmacSign(secret, rawBody))
|
|
372
|
+
return { 'x-infisical-signature': `t=${t ?? Date.now()};${signature}` }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Twilio signs the URL followed by each form field's name and value, sorted by name. */
|
|
377
|
+
function twilioPayload(url: string, rawBody: string) {
|
|
378
|
+
const fields = [...new URLSearchParams(rawBody)].sort(([a, x], [b, y]) =>
|
|
379
|
+
a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1
|
|
380
|
+
)
|
|
381
|
+
return url + fields.map(([k, v]) => k + v).join('')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Twilio: `x-twilio-signature: <base64>`, an HMAC-SHA1 with the auth token
|
|
386
|
+
* over the full public URL and the sorted form fields (`bodyType: 'form'`).
|
|
387
|
+
* Pass `url` when `c.req.url` is not the URL Twilio called.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* verify: twilio.verify((c) => c.env.TWILIO_AUTH_TOKEN, { url: (c) => publicUrl(c) })
|
|
391
|
+
*/
|
|
392
|
+
export const twilio: SignatureScheme<{ url?: SignedUrl }, { url: string }> = {
|
|
393
|
+
verify:
|
|
394
|
+
(secret, { url } = {}) =>
|
|
395
|
+
async (input) => {
|
|
396
|
+
const signature = input.headers['x-twilio-signature']
|
|
397
|
+
if (!signature) return reject(input, 'missing-header')
|
|
398
|
+
const bytes = fromBase64(signature)
|
|
399
|
+
if (!bytes) return reject(input, 'malformed')
|
|
400
|
+
const key = await resolveSecret(secret, input.c)
|
|
401
|
+
for (const candidate of await signedUrls(url, input.c)) {
|
|
402
|
+
const payload = twilioPayload(candidate, input.rawBody)
|
|
403
|
+
if (await hmacVerify(key, payload, bytes, 'SHA-1')) return true
|
|
404
|
+
}
|
|
405
|
+
return reject(input, 'mismatch')
|
|
406
|
+
},
|
|
407
|
+
async sign(rawBody, secret, options) {
|
|
408
|
+
if (!options?.url) throw new Error('hono-webhooks: twilio.sign needs a url')
|
|
409
|
+
const signature = await hmacSign(
|
|
410
|
+
secret,
|
|
411
|
+
twilioPayload(options.url, rawBody),
|
|
412
|
+
'SHA-1'
|
|
413
|
+
)
|
|
414
|
+
return { 'x-twilio-signature': toBase64(signature) }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const HUBSPOT_DECODE: Record<string, string> = {
|
|
419
|
+
'%3A': ':',
|
|
420
|
+
'%2F': '/',
|
|
421
|
+
'%3F': '?',
|
|
422
|
+
'%40': '@',
|
|
423
|
+
'%21': '!',
|
|
424
|
+
'%24': '$',
|
|
425
|
+
'%27': "'",
|
|
426
|
+
'%28': '(',
|
|
427
|
+
'%29': ')',
|
|
428
|
+
'%2A': '*',
|
|
429
|
+
'%2C': ',',
|
|
430
|
+
'%3B': ';'
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** HubSpot decodes these escapes in the URL before signing. */
|
|
434
|
+
const hubspotUrl = (url: string) =>
|
|
435
|
+
url.replace(
|
|
436
|
+
/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi,
|
|
437
|
+
(match) => HUBSPOT_DECODE[match.toUpperCase()] ?? match
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* HubSpot v3: `x-hubspot-signature-v3: <base64>`, an HMAC-SHA256 with the
|
|
442
|
+
* app's client secret over `<METHOD><url><body><timestamp>`, and
|
|
443
|
+
* `x-hubspot-request-timestamp` in milliseconds, rejected when more than
|
|
444
|
+
* `toleranceSeconds` (default 300) away. Pass `url` when `c.req.url` is not
|
|
445
|
+
* the URL HubSpot called.
|
|
446
|
+
*/
|
|
447
|
+
export const hubspot: SignatureScheme<
|
|
448
|
+
{ url?: SignedUrl; toleranceSeconds?: number },
|
|
449
|
+
{ url: string; method?: string; timestamp?: number }
|
|
450
|
+
> = {
|
|
451
|
+
verify:
|
|
452
|
+
(secret, { url, toleranceSeconds = 300 } = {}) =>
|
|
453
|
+
async (input) => {
|
|
454
|
+
const signature = input.headers['x-hubspot-signature-v3']
|
|
455
|
+
const timestamp = input.headers['x-hubspot-request-timestamp']
|
|
456
|
+
if (!signature || !timestamp) return reject(input, 'missing-header')
|
|
457
|
+
const bytes = fromBase64(signature)
|
|
458
|
+
if (!bytes || !/^\d+$/.test(timestamp)) return reject(input, 'malformed')
|
|
459
|
+
if (outsideTolerance(Number(timestamp) / 1000, toleranceSeconds)) {
|
|
460
|
+
return reject(input, 'timestamp-out-of-tolerance')
|
|
461
|
+
}
|
|
462
|
+
const key = await resolveSecret(secret, input.c)
|
|
463
|
+
const method = input.c.req.method.toUpperCase()
|
|
464
|
+
for (const candidate of await signedUrls(url, input.c)) {
|
|
465
|
+
const payload = `${method}${hubspotUrl(candidate)}${input.rawBody}${timestamp}`
|
|
466
|
+
if (await hmacVerify(key, payload, bytes)) return true
|
|
467
|
+
}
|
|
468
|
+
return reject(input, 'mismatch')
|
|
469
|
+
},
|
|
470
|
+
async sign(rawBody, secret, options) {
|
|
471
|
+
if (!options?.url)
|
|
472
|
+
throw new Error('hono-webhooks: hubspot.sign needs a url')
|
|
473
|
+
const t = options.timestamp ?? Date.now()
|
|
474
|
+
const method = (options.method ?? 'POST').toUpperCase()
|
|
475
|
+
const signature = await hmacSign(
|
|
476
|
+
secret,
|
|
477
|
+
`${method}${hubspotUrl(options.url)}${rawBody}${t}`
|
|
478
|
+
)
|
|
479
|
+
return {
|
|
480
|
+
'x-hubspot-signature-v3': toBase64(signature),
|
|
481
|
+
'x-hubspot-request-timestamp': String(t)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|