@usehenri/webhooks 0.0.0 → 1.2.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.
@@ -0,0 +1,330 @@
1
+ const { createHmac, randomBytes, timingSafeEqual } = require('crypto');
2
+
3
+ const { WebhookError, coded } = require('./errors');
4
+
5
+ /**
6
+ * The signature henri puts on every delivery.
7
+ *
8
+ * ## What is followed, and what is not
9
+ *
10
+ * The scheme is **Standard Webhooks** (https://www.standardwebhooks.com),
11
+ * the specification Svix wrote and a growing number of senders emit, and it
12
+ * is followed to the byte: the three headers are `webhook-id`,
13
+ * `webhook-timestamp` and `webhook-signature`, the signed content is
14
+ * `id.timestamp.body`, the algorithm is HMAC-SHA256, the signature is
15
+ * base64 with a `v1,` scheme prefix, and the secret is `whsec_` followed by
16
+ * the base64 of the key. A receiver that already has a Standard Webhooks
17
+ * library verifies henri with nothing written.
18
+ *
19
+ * That specification was picked over the two schemes most receivers have
20
+ * seen, on the strength of what they leave out:
21
+ *
22
+ * - **GitHub** sends `X-Hub-Signature-256: sha256=<hex of hmac(body)>`. The
23
+ * body and nothing else is signed: no timestamp, so a captured request
24
+ * replays for as long as the secret lives, and no delivery id, so a
25
+ * receiver cannot tell a retry from a duplicate.
26
+ * - **Shopify** sends `X-Shopify-Hmac-Sha256: <base64 of hmac(body)>`. Same
27
+ * omissions, and the encoding differs from GitHub's for the same
28
+ * algorithm, which is the single most common reason a hand-written
29
+ * verification fails on a genuine request.
30
+ * - **Stripe** does have a timestamp: `Stripe-Signature: t=<seconds>,
31
+ * v1=<hex of hmac("t.body")>`, with several `v1=` during a secret
32
+ * rotation and a tolerance of five minutes. It is the scheme this one is
33
+ * closest to.
34
+ *
35
+ * Where henri differs from Stripe, and why:
36
+ *
37
+ * - the timestamp is its own header rather than a field inside the
38
+ * signature header, so a receiver reads the recency check without parsing
39
+ * the signature first;
40
+ * - the **delivery id is part of the signed content**. Stripe re-signs a
41
+ * retry with a fresh timestamp and the event id only in the body, so
42
+ * deduplication means trusting the body before verifying it. henri keeps
43
+ * one `webhook-id` for every attempt of one delivery, signed, so
44
+ * "have I already processed this?" is answered from verified bytes;
45
+ * - the signature is base64, not hex, because the specification says so.
46
+ *
47
+ * ## Replay protection is part of this, not an extra
48
+ *
49
+ * The timestamp is signed, so it cannot be moved without the key. It is the
50
+ * moment of *this attempt*, not of the event: a delivery retried six hours
51
+ * later carries a fresh timestamp and a fresh signature, and stays inside
52
+ * the receiver's window. The event's own moment is in the signed body, as
53
+ * `timestamp`. A receiver refuses anything outside `TOLERANCE` (five
54
+ * minutes, Stripe's default and the one this package documents) and then
55
+ * refuses a `webhook-id` it has already answered. Neither half is enough on
56
+ * its own: the window bounds the replay, the id makes retries safe.
57
+ *
58
+ * ## Rotation
59
+ *
60
+ * Two things rotate, and the scheme identifier is what makes the second one
61
+ * possible:
62
+ *
63
+ * - the **key**: an endpoint may hold several secrets at once, and every
64
+ * one of them signs, so a receiver has a window to install the new key
65
+ * (`henri webhooks:rotate --grace 24h`). Stripe does the same thing.
66
+ * - the **scheme**: `v1` is HMAC-SHA256. A future asymmetric scheme is
67
+ * `v1a` in the specification, and a receiver that follows the advice
68
+ * below -- ignore every scheme you do not know -- keeps working while
69
+ * both are being sent.
70
+ */
71
+
72
+ /** The scheme identifier of HMAC-SHA256, base64 */
73
+ const SCHEME = 'v1';
74
+
75
+ /** How a secret is written down, so a scanner can recognize one */
76
+ const PREFIX = 'whsec_';
77
+
78
+ /** How many bytes of key a generated secret carries */
79
+ const SECRET_BYTES = 32;
80
+
81
+ /** The shortest key this package will sign with (128 bits) */
82
+ const MIN_KEY_BYTES = 16;
83
+
84
+ /** The window a receiver should accept, in milliseconds */
85
+ const TOLERANCE = 300000;
86
+
87
+ /** The headers a delivery carries */
88
+ const HEADERS = {
89
+ id: 'webhook-id',
90
+ signature: 'webhook-signature',
91
+ timestamp: 'webhook-timestamp',
92
+ };
93
+
94
+ /**
95
+ * A new endpoint secret
96
+ *
97
+ * @returns {string} `whsec_` and the base64 of 32 random bytes
98
+ */
99
+ const generate = () =>
100
+ `${PREFIX}${randomBytes(SECRET_BYTES).toString('base64')}`;
101
+
102
+ /**
103
+ * The key a secret carries
104
+ *
105
+ * The `whsec_` prefix is a label, not key material: it is stripped, and
106
+ * what is left is base64 decoded. This is the step a hand-written verifier
107
+ * forgets, so the documentation says it twice and this function is
108
+ * exported.
109
+ *
110
+ * @param {string} secret An endpoint secret
111
+ * @returns {Buffer} The key
112
+ * @throws {Error} HENRI_WEBHOOK_INVALID_SECRET when it carries no key
113
+ */
114
+ const keyOf = (secret) => {
115
+ const text = String(secret || '');
116
+ const encoded = text.startsWith(PREFIX) ? text.slice(PREFIX.length) : text;
117
+ const key = Buffer.from(encoded, 'base64');
118
+
119
+ if (key.length < MIN_KEY_BYTES) {
120
+ throw coded(
121
+ 'HENRI_WEBHOOK_INVALID_SECRET',
122
+ `@usehenri/webhooks: a signing secret is "${PREFIX}" and the base64 of at least ${MIN_KEY_BYTES} bytes`,
123
+ {
124
+ hint: 'Let henri generate one: henri webhooks:rotate <id>',
125
+ }
126
+ );
127
+ }
128
+
129
+ return key;
130
+ };
131
+
132
+ /**
133
+ * What is signed: the delivery id, the moment and the body, in that order
134
+ *
135
+ * The body is the bytes that are sent, never a re-serialization of them: a
136
+ * signature is sensitive to a re-ordered key or a changed space, and a
137
+ * receiver that parses before verifying has already lost.
138
+ *
139
+ * @param {object} delivery `id`, `timestamp` (unix seconds) and `body`
140
+ * @returns {string} The signed content
141
+ */
142
+ const content = ({ body, id, timestamp }) => `${id}.${timestamp}.${body}`;
143
+
144
+ /**
145
+ * One signature
146
+ *
147
+ * @param {object} delivery `id`, `timestamp`, `body` and `secret`
148
+ * @returns {string} `v1,<base64>`
149
+ */
150
+ const signOne = ({ body, id, secret, timestamp }) =>
151
+ `${SCHEME},${createHmac('sha256', keyOf(secret))
152
+ .update(content({ body, id, timestamp }), 'utf8')
153
+ .digest('base64')}`;
154
+
155
+ /**
156
+ * The value of the `webhook-signature` header
157
+ *
158
+ * One signature per secret the endpoint holds, space delimited, which is
159
+ * what the specification's `signature(s)` means and what makes a key
160
+ * rotation invisible to a receiver that verifies them in turn.
161
+ *
162
+ * @param {object} delivery `id`, `timestamp`, `body` and `secrets`
163
+ * @returns {string} The header value
164
+ * @throws {WebhookError} NO_SECRET when the endpoint holds none
165
+ */
166
+ const sign = ({ body, id, secrets, timestamp }) => {
167
+ const keys = (Array.isArray(secrets) ? secrets : [secrets]).filter(Boolean);
168
+
169
+ if (keys.length === 0) {
170
+ throw new WebhookError(
171
+ 'HENRI_WEBHOOK_NO_SECRET',
172
+ '@usehenri/webhooks: the endpoint holds no signing secret',
173
+ {
174
+ hint: 'Give it one with: henri webhooks:rotate <id>',
175
+ retryable: false,
176
+ }
177
+ );
178
+ }
179
+
180
+ return keys
181
+ .map((secret) => signOne({ body, id, secret, timestamp }))
182
+ .join(' ');
183
+ };
184
+
185
+ /**
186
+ * The headers of a delivery, signature included
187
+ *
188
+ * Nothing a receiver could route on is sent outside the signature: the
189
+ * event type lives in the signed body, so an unsigned header cannot send a
190
+ * handler down the wrong branch.
191
+ *
192
+ * @param {object} delivery `id`, `body`, `secrets`, `now` and `agent`
193
+ * @returns {object} The headers, lowercased
194
+ */
195
+ const headersFor = ({ agent, body, id, now = Date.now(), secrets }) => {
196
+ const timestamp = Math.floor(now / 1000);
197
+
198
+ return {
199
+ 'content-type': 'application/json; charset=utf-8',
200
+ 'user-agent': agent || 'henri-webhooks',
201
+ [HEADERS.id]: id,
202
+ [HEADERS.signature]: sign({ body, id, secrets, timestamp }),
203
+ [HEADERS.timestamp]: String(timestamp),
204
+ };
205
+ };
206
+
207
+ /**
208
+ * The signatures of a header value, unknown schemes dropped
209
+ *
210
+ * @param {string} value The `webhook-signature` header
211
+ * @returns {Array<Buffer>} The digests carrying the `v1` scheme
212
+ */
213
+ const parse = (value) =>
214
+ String(value || '')
215
+ .split(/\s+/u)
216
+ .filter(Boolean)
217
+ .map((entry) => entry.split(','))
218
+ .filter(([scheme, digest]) => scheme === SCHEME && digest)
219
+ .map(([, digest]) => Buffer.from(digest, 'base64'));
220
+
221
+ /**
222
+ * Whether two digests are the same, in constant time
223
+ *
224
+ * @param {Buffer} left One digest
225
+ * @param {Buffer} right The other
226
+ * @returns {boolean} Whether they match
227
+ */
228
+ const same = (left, right) =>
229
+ left.length === right.length && timingSafeEqual(left, right);
230
+
231
+ /**
232
+ * One header of a request, whatever shape the headers arrived in
233
+ *
234
+ * @param {(object|Function)} headers An object, or a `name => value`
235
+ * @param {string} name The header name, lowercase
236
+ * @returns {?string} The value
237
+ */
238
+ const headerOf = (headers, name) => {
239
+ if (typeof headers === 'function') {
240
+ return headers(name) || null;
241
+ }
242
+
243
+ if (!headers || typeof headers !== 'object') {
244
+ return null;
245
+ }
246
+
247
+ const found =
248
+ typeof headers.get === 'function' ? headers.get(name) : headers[name];
249
+
250
+ return typeof found === 'undefined' || found === null ? null : String(found);
251
+ };
252
+
253
+ /**
254
+ * Verifies a delivery, the way a receiver does
255
+ *
256
+ * The package that signs is the package that verifies, so the snippet in
257
+ * the guide has something to be checked against: `__tests__/signature.spec.js`
258
+ * runs the documented steps over what `headersFor()` produced.
259
+ *
260
+ * @param {object} options Options
261
+ * @param {(string|Buffer)} options.body The raw body, as received
262
+ * @param {(object|Function)} options.headers The request headers
263
+ * @param {(string|Array<string>)} options.secret The endpoint secret(s)
264
+ * @param {number} [options.tolerance=TOLERANCE] The window, in milliseconds
265
+ * @param {number} [options.now] The moment to measure the window from
266
+ * @returns {object} `{ ok, reason, id, timestamp }`
267
+ */
268
+ const verify = ({
269
+ body,
270
+ headers,
271
+ now = Date.now(),
272
+ secret,
273
+ tolerance = TOLERANCE,
274
+ }) => {
275
+ const id = headerOf(headers, HEADERS.id);
276
+ const stamp = headerOf(headers, HEADERS.timestamp);
277
+ const signatures = parse(headerOf(headers, HEADERS.signature));
278
+ const timestamp = Number(stamp);
279
+
280
+ if (!id || !stamp || signatures.length === 0) {
281
+ return { id, ok: false, reason: 'missing', timestamp: null };
282
+ }
283
+
284
+ if (!Number.isFinite(timestamp)) {
285
+ return { id, ok: false, reason: 'timestamp', timestamp: null };
286
+ }
287
+
288
+ if (tolerance > 0 && Math.abs(now - timestamp * 1000) > tolerance) {
289
+ return { id, ok: false, reason: 'stale', timestamp };
290
+ }
291
+
292
+ const raw = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
293
+ const expected = (Array.isArray(secret) ? secret : [secret])
294
+ .filter(Boolean)
295
+ .map((key) =>
296
+ Buffer.from(
297
+ createHmac('sha256', keyOf(key))
298
+ .update(content({ body: raw, id, timestamp }), 'utf8')
299
+ .digest('base64'),
300
+ 'base64'
301
+ )
302
+ );
303
+ const matched = expected.some((digest) =>
304
+ signatures.some((candidate) => same(candidate, digest))
305
+ );
306
+
307
+ return {
308
+ id,
309
+ ok: matched,
310
+ reason: matched ? null : 'signature',
311
+ timestamp,
312
+ };
313
+ };
314
+
315
+ module.exports = {
316
+ HEADERS,
317
+ MIN_KEY_BYTES,
318
+ PREFIX,
319
+ SCHEME,
320
+ SECRET_BYTES,
321
+ TOLERANCE,
322
+ content,
323
+ generate,
324
+ headersFor,
325
+ keyOf,
326
+ parse,
327
+ sign,
328
+ signOne,
329
+ verify,
330
+ };
@@ -0,0 +1,41 @@
1
+ const mongo = require('./mongo');
2
+ const sql = require('./sql');
3
+
4
+ const { WebhookError } = require('../errors');
5
+
6
+ /**
7
+ * Picks the backend of a store adapter
8
+ *
9
+ * The endpoints live in the database the application already runs, reached
10
+ * through the adapter's own surface: `query()` on the SQL adapters
11
+ * (sequelize and its dialect packages, drizzle), the MongoDB collections on
12
+ * the mongoose and disk adapters. No henri model is involved either way.
13
+ *
14
+ * @param {object} adapter A henri store adapter
15
+ * @param {object} tables `{ endpoints }` table names
16
+ * @returns {object} A store backend
17
+ * @throws {WebhookError} When the adapter cannot hold the endpoints
18
+ */
19
+ const storeFor = (adapter, tables) => {
20
+ if (!adapter) {
21
+ throw new WebhookError(
22
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
23
+ '@usehenri/webhooks: no store to hold the endpoints'
24
+ );
25
+ }
26
+
27
+ if (adapter.mongoose) {
28
+ return mongo.create(adapter, tables);
29
+ }
30
+
31
+ if (typeof adapter.query !== 'function') {
32
+ throw new WebhookError(
33
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
34
+ `@usehenri/webhooks: the ${adapter.adapterName || 'unknown'} adapter has neither query() nor a MongoDB connection`
35
+ );
36
+ }
37
+
38
+ return sql.create(adapter, tables);
39
+ };
40
+
41
+ module.exports = { mongo, sql, storeFor };
@@ -0,0 +1,229 @@
1
+ const debug = require('debug')('henri:webhooks:mongo');
2
+
3
+ const { WebhookError } = require('../errors');
4
+
5
+ /**
6
+ * The MongoDB backend of the endpoints collection.
7
+ *
8
+ * Documents carry the same field names as the SQL columns, on purpose: the
9
+ * two backends hand the package the same rows, so everything above this
10
+ * file is written once.
11
+ */
12
+
13
+ /**
14
+ * The MongoDB store
15
+ *
16
+ * @class MongoStore
17
+ */
18
+ class MongoStore {
19
+ /**
20
+ * Creates an instance of MongoStore.
21
+ *
22
+ * @param {object} adapter A henri mongoose (or disk) adapter
23
+ * @param {object} tables `{ endpoints }` collection names
24
+ * @memberof MongoStore
25
+ */
26
+ constructor(adapter, tables) {
27
+ this.adapter = adapter;
28
+ this.tables = tables;
29
+ this.dialect = 'mongodb';
30
+ this.kind = 'mongo';
31
+ }
32
+
33
+ /**
34
+ * The database of the store
35
+ *
36
+ * @returns {object} A MongoDB database
37
+ * @throws {WebhookError} When the store is not connected
38
+ * @memberof MongoStore
39
+ */
40
+ database() {
41
+ const connection =
42
+ this.adapter.mongoose && this.adapter.mongoose.connection;
43
+
44
+ if (!connection || connection.readyState !== 1 || !connection.db) {
45
+ throw new WebhookError(
46
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
47
+ `@usehenri/webhooks: the ${this.adapter.name} store is not connected`
48
+ );
49
+ }
50
+
51
+ return connection.db;
52
+ }
53
+
54
+ /**
55
+ * The endpoints collection
56
+ *
57
+ * @returns {object} A MongoDB collection
58
+ * @memberof MongoStore
59
+ */
60
+ endpoints() {
61
+ return this.database().collection(this.tables.endpoints);
62
+ }
63
+
64
+ /**
65
+ * Creates the collection and its indexes; idempotent
66
+ *
67
+ * @returns {Promise<Array<string>>} What was created
68
+ * @memberof MongoStore
69
+ */
70
+ async install() {
71
+ const collection = this.endpoints();
72
+
73
+ await collection.createIndex({ id: 1 }, { unique: true });
74
+ await collection.createIndex({ disabled_at: 1, owner: 1 });
75
+
76
+ debug('indexes ready on %s', this.tables.endpoints);
77
+
78
+ return [`${this.tables.endpoints}: id (unique), owner + disabled_at`];
79
+ }
80
+
81
+ /**
82
+ * Drops the collection
83
+ *
84
+ * @returns {Promise<Array<string>>} What was dropped
85
+ * @memberof MongoStore
86
+ */
87
+ async uninstall() {
88
+ await this.endpoints()
89
+ .drop()
90
+ .catch(() => null);
91
+
92
+ return [`${this.tables.endpoints} dropped`];
93
+ }
94
+
95
+ /**
96
+ * Whether the collection answers
97
+ *
98
+ * @returns {Promise<boolean>} true when it does
99
+ * @memberof MongoStore
100
+ */
101
+ async installed() {
102
+ try {
103
+ await this.endpoints().countDocuments({}, { limit: 1 });
104
+
105
+ return true;
106
+ } catch (error) {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Inserts an endpoint
113
+ *
114
+ * @param {object} row A row, in database shape
115
+ * @returns {Promise<object>} The endpoint, read back
116
+ * @memberof MongoStore
117
+ */
118
+ async insert(row) {
119
+ await this.endpoints().insertOne({ ...row });
120
+
121
+ return this.find(row.id);
122
+ }
123
+
124
+ /**
125
+ * One endpoint by id
126
+ *
127
+ * @param {string} id The endpoint id
128
+ * @returns {Promise<?object>} The document, or null
129
+ * @memberof MongoStore
130
+ */
131
+ async find(id) {
132
+ return this.endpoints().findOne({ id }, { projection: { _id: 0 } });
133
+ }
134
+
135
+ /**
136
+ * Writes a few fields of an endpoint
137
+ *
138
+ * @param {string} id The endpoint id
139
+ * @param {object} changes The fields to write
140
+ * @returns {Promise<?object>} The document, read back
141
+ * @memberof MongoStore
142
+ */
143
+ async update(id, changes) {
144
+ await this.endpoints().updateOne({ id }, { $set: { ...changes } });
145
+
146
+ return this.find(id);
147
+ }
148
+
149
+ /**
150
+ * Deletes an endpoint
151
+ *
152
+ * @param {string} id The endpoint id
153
+ * @returns {Promise<boolean>} Whether there was one to delete
154
+ * @memberof MongoStore
155
+ */
156
+ async remove(id) {
157
+ const answer = await this.endpoints().deleteOne({ id });
158
+
159
+ return (answer.deletedCount || 0) > 0;
160
+ }
161
+
162
+ /**
163
+ * The filter of a listing
164
+ *
165
+ * @param {object} filter `owner` and `disabled`
166
+ * @returns {object} A MongoDB filter
167
+ * @memberof MongoStore
168
+ */
169
+ query(filter) {
170
+ const query = {};
171
+
172
+ if (typeof filter.owner === 'string') {
173
+ query.owner = filter.owner;
174
+ }
175
+
176
+ // `null` is a filter, not the absence of one
177
+ if (filter.owner === null) {
178
+ query.owner = null;
179
+ }
180
+
181
+ if (filter.disabled === false) {
182
+ query.disabled_at = null;
183
+ }
184
+
185
+ if (filter.disabled === true) {
186
+ query.disabled_at = { $ne: null };
187
+ }
188
+
189
+ return query;
190
+ }
191
+
192
+ /**
193
+ * The endpoints of an application, or of one owner
194
+ *
195
+ * @param {object} [filter={}] `owner`, `disabled`, `limit`, `offset`
196
+ * @returns {Promise<Array<object>>} The documents
197
+ * @memberof MongoStore
198
+ */
199
+ async list(filter = {}) {
200
+ return this.endpoints()
201
+ .find(this.query(filter), { projection: { _id: 0 } })
202
+ .sort({ created_at: 1, id: 1 })
203
+ .skip(Math.max(0, Number(filter.offset) || 0))
204
+ .limit(Math.max(1, Math.min(Number(filter.limit) || 1000, 10000)))
205
+ .toArray();
206
+ }
207
+
208
+ /**
209
+ * How many endpoints there are
210
+ *
211
+ * @param {object} [filter={}] `owner`, `disabled`
212
+ * @returns {Promise<number>} The count
213
+ * @memberof MongoStore
214
+ */
215
+ async count(filter = {}) {
216
+ return this.endpoints().countDocuments(this.query(filter));
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Builds the MongoDB store of an adapter
222
+ *
223
+ * @param {object} adapter A henri mongoose (or disk) adapter
224
+ * @param {object} tables `{ endpoints }` collection names
225
+ * @returns {MongoStore} The store
226
+ */
227
+ const create = (adapter, tables) => new MongoStore(adapter, tables);
228
+
229
+ module.exports = { MongoStore, create };