Skip to content

Commit 3cefdb9

Browse files
feat: add webhook verify utility function to node (#457)
* feat: add webhook verify utility function to node * fix: try to fix regex warning * fix: remove crypto package This is in core and doesnt need to come from npm * fix(webhooks): expired signature test This test was throwing an error, but not the Expired Signature error that we were expecting. It was throwing because we were passing in `new Date()` into the test instead of `Date.now()`. I've fixed all of the tests here, and ensured that we're casting to int when constructing the new Date() in verify() * fix(webhook): switch to using esm syntax for imports * feat(webhooks): add integration test for node/express * feat(webhooks): add integration testing layer like the regular one * chore(webhooks): lint * chore(webhooks): lint * refactor(webhooks): replace regex with a string.split solution Inspired by stripe-node * refactor(webhooks): rename `verify()` to `verifyWebhook()' * fix(webhooks): add code to account for missing/empty signature * chore: lint Co-authored-by: Dom Harrington <[email protected]>
1 parent 51d65d8 commit 3cefdb9

File tree

11 files changed

+300
-13
lines changed

11 files changed

+300
-13
lines changed

.github/workflows/nodejs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939

4040
- run: docker-compose run integration_node_express
4141
- run: docker-compose run integration_node_hapi
42+
- run: docker-compose run integration_webhooks_node_express
4243

4344
- name: Cleanup
4445
if: always()
+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import http from 'http';
2+
import crypto from 'crypto';
3+
import { cwd } from 'process';
4+
import { spawn } from 'child_process';
5+
6+
import getPort from 'get-port';
7+
8+
if (!process.env.EXAMPLE_SERVER) {
9+
// eslint-disable-next-line no-console
10+
console.error('Missing `EXAMPLE_SERVER` environment variable');
11+
process.exit(1);
12+
}
13+
14+
function post(url, body, options) {
15+
return new Promise((resolve, reject) => {
16+
const request = http
17+
.request(url, { method: 'post', ...options }, response => {
18+
response.end = new Promise(res => {
19+
response.on('end', res);
20+
});
21+
resolve(response);
22+
})
23+
.on('error', reject);
24+
25+
request.write(body);
26+
request.end();
27+
});
28+
}
29+
30+
const randomApiKey = 'rdme_abcdefghijklmnopqrstuvwxyz';
31+
32+
describe('Metrics SDK Webhook Integration Tests', () => {
33+
let httpServer;
34+
let PORT;
35+
36+
beforeAll(async () => {
37+
const [command, ...args] = process.env.EXAMPLE_SERVER.split(' ');
38+
PORT = await getPort();
39+
40+
httpServer = spawn(command, args, {
41+
cwd: cwd(),
42+
env: {
43+
README_API_KEY: randomApiKey,
44+
PORT,
45+
...process.env,
46+
},
47+
});
48+
return new Promise((resolve, reject) => {
49+
httpServer.stderr.on('data', data => {
50+
// For some reason Flask prints on stderr 🤷‍♂️
51+
if (data.toString().match(/Running on/)) return resolve();
52+
// eslint-disable-next-line no-console
53+
console.error(`stderr: ${data}`);
54+
return reject(data.toString());
55+
});
56+
httpServer.on('error', err => {
57+
// eslint-disable-next-line no-console
58+
console.error('error', err);
59+
return reject(err.toString());
60+
});
61+
// eslint-disable-next-line consistent-return
62+
httpServer.stdout.on('data', data => {
63+
if (data.toString().match(/listening/)) return resolve();
64+
// eslint-disable-next-line no-console
65+
console.log(`stdout: ${data}`);
66+
});
67+
});
68+
});
69+
70+
afterAll(() => {
71+
return httpServer.kill();
72+
});
73+
74+
it('should return with a user object if the signature is correct', async () => {
75+
const time = Date.now();
76+
const body = {
77+
78+
};
79+
const unsigned = `${time}.${JSON.stringify(body)}`;
80+
const hmac = crypto.createHmac('sha256', randomApiKey);
81+
const output = `t=${time},v0=${hmac.update(unsigned).digest('hex')}`;
82+
83+
const response = await post(`http://localhost:${PORT}/webhook`, JSON.stringify(body), {
84+
headers: {
85+
'readme-signature': output,
86+
'content-type': 'application/json',
87+
},
88+
});
89+
let responseBody = '';
90+
// eslint-disable-next-line no-restricted-syntax
91+
for await (const chunk of response) {
92+
responseBody += chunk;
93+
}
94+
responseBody = JSON.parse(responseBody);
95+
96+
expect(response.statusCode).toBe(200);
97+
expect(responseBody).toMatchObject({
98+
petstore_auth: 'default-key',
99+
basic_auth: { user: 'user', pass: 'pass' },
100+
});
101+
});
102+
103+
it('should return with a 401 if the signature is not correct', async () => {
104+
const response = await post(`http://localhost:${PORT}/webhook`, JSON.stringify({ email: '[email protected]' }), {
105+
headers: {
106+
'readme-signature': 'adsdsdas',
107+
'content-type': 'application/json',
108+
},
109+
});
110+
111+
expect(response.statusCode).toBe(401);
112+
});
113+
114+
it('should return with a 401 if the signature is empty/missing', async () => {
115+
const response = await post(`http://localhost:${PORT}/webhook`, JSON.stringify({ email: '[email protected]' }), {
116+
headers: {
117+
'content-type': 'application/json',
118+
},
119+
});
120+
121+
expect(response.statusCode).toBe(401);
122+
});
123+
});

__tests__/integrations/node.Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ RUN npm ci
1818

1919
# Install top level dependencies
2020
WORKDIR /src
21-
ADD __tests__ ./
21+
ADD __tests__ ./__tests__
2222
ADD package*.json ./
2323
RUN npm ci

docker-compose.yml

+12-4
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,38 @@ services:
44
build:
55
context: .
66
dockerfile: ./__tests__/integrations/node.Dockerfile
7-
command: npm run test:integration
7+
command: npm run test:integration-metrics
88
environment:
99
- EXAMPLE_SERVER=node ./packages/node/examples/express/index.js
1010

11+
integration_webhooks_node_express:
12+
build:
13+
context: .
14+
dockerfile: ./__tests__/integrations/node.Dockerfile
15+
command: npm run test:integration-webhooks
16+
environment:
17+
- EXAMPLE_SERVER=node ./packages/node/examples/express/webhook.js
18+
1119
integration_node_hapi:
1220
build:
1321
context: .
1422
dockerfile: ./__tests__/integrations/node.Dockerfile
15-
command: npm run test:integration
23+
command: npm run test:integration-metrics
1624
environment:
1725
- EXAMPLE_SERVER=node ./packages/node/examples/hapi/index.js
1826

1927
integration_dotnet_v6.0:
2028
build:
2129
context: .
2230
dockerfile: ./__tests__/integrations/dotnet.Dockerfile
23-
command: npm run test:integration
31+
command: npm run test:integration-metrics
2432
environment:
2533
- EXAMPLE_SERVER=dotnet examples/net6.0/out/net6.0.dll
2634

2735
integration_python_flask:
2836
build:
2937
context: .
3038
dockerfile: ./__tests__/integrations/python.Dockerfile
31-
command: npm run test:integration
39+
command: npm run test:integration-metrics
3240
environment:
3341
- EXAMPLE_SERVER=python examples/flask/app.py

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"prepare": "husky install",
88
"publish": "npx lerna publish",
99
"test": "npm test --workspaces",
10-
"test:integration": "NODE_OPTIONS=--experimental-vm-modules npx jest",
10+
"test:integration-metrics": "NODE_OPTIONS=--experimental-vm-modules npx jest __tests__/integration.test.js",
11+
"test:integration-webhooks": "NODE_OPTIONS=--experimental-vm-modules npx jest __tests__/integration-webhooks.test.js",
1112
"version": "npx conventional-changelog-cli --pkg lerna.json -i CHANGELOG.md -s && git add CHANGELOG.md"
1213
},
1314
"repository": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import crypto from 'crypto';
2+
3+
import verifyWebhook from '../../src/lib/verify-webhook';
4+
5+
describe('verifyWebhook()', () => {
6+
it('should return the body if the signature is valid', () => {
7+
const body = { email: '[email protected]' };
8+
const secret = 'docs4dayz';
9+
const time = Date.now();
10+
const unsigned = `${time}.${JSON.stringify(body)}`;
11+
const hmac = crypto.createHmac('sha256', secret);
12+
const signature = `t=${time},v0=${hmac.update(unsigned).digest('hex')}`;
13+
14+
const verifiedBody = verifyWebhook(body, signature, secret);
15+
expect(verifiedBody).toStrictEqual(body);
16+
});
17+
18+
it('should throw an error if signature is invalid', () => {
19+
const body = { email: '[email protected]' };
20+
const secret = 'docs4dayz';
21+
const time = Date.now();
22+
const unsigned = `${time}.${JSON.stringify(body)}`;
23+
const hmac = crypto.createHmac('sha256', 'invalidsecret');
24+
const signature = `t=${time},v0=${hmac.update(unsigned).digest('hex')}`;
25+
26+
expect(() => {
27+
verifyWebhook(body, signature, secret);
28+
}).toThrow(/Invalid Signature/);
29+
});
30+
31+
it('should throw an error if timestamp is too old', () => {
32+
const body = { email: '[email protected]' };
33+
const secret = 'docs4dayz';
34+
const time = new Date();
35+
time.setHours(time.getHours() - 1);
36+
const unsigned = `${time.getTime()}.${JSON.stringify(body)}`;
37+
const hmac = crypto.createHmac('sha256', secret);
38+
const signature = `t=${time.getTime()},v0=${hmac.update(unsigned).digest('hex')}`;
39+
40+
expect(() => {
41+
verifyWebhook(body, signature, secret);
42+
}).toThrow(/Expired Signature/);
43+
});
44+
45+
it('should throw an error if signature is missing', () => {
46+
const body = { email: '[email protected]' };
47+
const secret = 'docs4dayz';
48+
const signature = undefined;
49+
50+
expect(() => {
51+
verifyWebhook(body, signature, secret);
52+
}).toThrow(/Missing Signature/);
53+
});
54+
});

packages/node/examples/express/package-lock.json

+7-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env node
2+
3+
import express from 'express';
4+
import readme from 'readmeio';
5+
6+
if (!process.env.README_API_KEY) {
7+
// eslint-disable-next-line no-console
8+
console.error('Missing `README_API_KEY` environment variable');
9+
process.exit(1);
10+
}
11+
12+
const app = express();
13+
const port = process.env.PORT || 4000;
14+
15+
app.post('/webhook', express.json({ type: 'application/json' }), (req, res) => {
16+
// Verify the request is legitimate and came from ReadMe
17+
const signature = req.headers['readme-signature'];
18+
// Your ReadMe secret
19+
const secret = process.env.README_API_KEY;
20+
try {
21+
readme.verifyWebhook(req.body, signature, secret);
22+
} catch (e) {
23+
// Handle invalid requests
24+
return res.sendStatus(401);
25+
}
26+
// Fetch the user from the db
27+
// eslint-disable-next-line @typescript-eslint/no-use-before-define, @typescript-eslint/no-unused-vars
28+
return db.find({ email: req.body.email }).then(user => {
29+
return res.json({
30+
// OAS Security variables
31+
petstore_auth: 'default-key',
32+
basic_auth: { user: 'user', pass: 'pass' },
33+
});
34+
});
35+
});
36+
37+
const server = app.listen(port, 'localhost', function () {
38+
// eslint-disable-next-line no-console
39+
console.log('Example app listening at http://%s:%s', server.address().address, port);
40+
});
41+
42+
class db {
43+
static find() {
44+
return Promise.resolve({});
45+
}
46+
}

packages/node/package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/node/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/**
22
* @deprecated use expressMiddleware instead
33
*/
4+
import verifyWebhook from './lib/verify-webhook';
5+
46
export { expressMiddleware as metrics } from './lib/express-middleware';
57
export { expressMiddleware } from './lib/express-middleware';
68
export { log } from './lib/metrics-log';
9+
export { verifyWebhook };

0 commit comments

Comments
 (0)