@antasphere/chassis-server 1.1.0 → 1.3.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/dist/api/apikeys.d.ts +4 -1
- package/dist/api/apikeys.d.ts.map +1 -1
- package/dist/api/apikeys.js +11 -1
- package/dist/api/apikeys.js.map +1 -1
- package/dist/api/create-api.d.ts +3 -0
- package/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +8 -3
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +6 -0
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/members.d.ts +5 -2
- package/dist/api/members.d.ts.map +1 -1
- package/dist/api/members.js +1 -0
- package/dist/api/members.js.map +1 -1
- package/dist/api/projects.d.ts +3 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +13 -1
- package/dist/api/projects.js.map +1 -1
- package/dist/api/webhooks.d.ts +24 -0
- package/dist/api/webhooks.d.ts.map +1 -0
- package/dist/api/webhooks.js +170 -0
- package/dist/api/webhooks.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +11 -5
- package/dist/app.js.map +1 -1
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +47 -4
- package/dist/boot.js.map +1 -1
- package/dist/email/index.d.ts +1 -0
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +1 -0
- package/dist/email/index.js.map +1 -1
- package/dist/env.d.ts +9 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +11 -0
- package/dist/env.js.map +1 -1
- package/dist/identity/demo-pass-rules.d.ts.map +1 -1
- package/dist/identity/demo-pass-rules.js +6 -1
- package/dist/identity/demo-pass-rules.js.map +1 -1
- package/dist/identity/hub-reconcile.d.ts +2 -2
- package/dist/identity/hub-reconcile.d.ts.map +1 -1
- package/dist/identity/hub-reconcile.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/pgboss.d.ts +34 -0
- package/dist/jobs/pgboss.d.ts.map +1 -1
- package/dist/jobs/pgboss.js +32 -9
- package/dist/jobs/pgboss.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +11 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-kit.d.ts +2 -0
- package/dist/mcp/tool-kit.d.ts.map +1 -1
- package/dist/mcp/tool-kit.js +5 -1
- package/dist/mcp/tool-kit.js.map +1 -1
- package/dist/mcp/webhooks.d.ts +16 -0
- package/dist/mcp/webhooks.d.ts.map +1 -0
- package/dist/mcp/webhooks.js +226 -0
- package/dist/mcp/webhooks.js.map +1 -0
- package/dist/members/removal.d.ts +10 -2
- package/dist/members/removal.d.ts.map +1 -1
- package/dist/members/removal.js +3 -0
- package/dist/members/removal.js.map +1 -1
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +3 -1
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/scopes.d.ts +9 -1
- package/dist/middleware/scopes.d.ts.map +1 -1
- package/dist/middleware/scopes.js +8 -1
- package/dist/middleware/scopes.js.map +1 -1
- package/dist/middleware/static-assets.d.ts +63 -0
- package/dist/middleware/static-assets.d.ts.map +1 -0
- package/dist/middleware/static-assets.js +125 -0
- package/dist/middleware/static-assets.js.map +1 -0
- package/dist/secret-box.d.ts +10 -0
- package/dist/secret-box.d.ts.map +1 -0
- package/dist/secret-box.js +87 -0
- package/dist/secret-box.js.map +1 -0
- package/dist/testing/helpers.d.ts +8 -0
- package/dist/testing/helpers.d.ts.map +1 -1
- package/dist/testing/helpers.js.map +1 -1
- package/dist/tool-definition.d.ts +24 -0
- package/dist/tool-definition.d.ts.map +1 -1
- package/dist/tool-definition.js.map +1 -1
- package/dist/webhooks/catalog.d.ts +80 -0
- package/dist/webhooks/catalog.d.ts.map +1 -0
- package/dist/webhooks/catalog.js +134 -0
- package/dist/webhooks/catalog.js.map +1 -0
- package/dist/webhooks/index.d.ts +9 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +9 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/jobs.d.ts +12 -0
- package/dist/webhooks/jobs.d.ts.map +1 -0
- package/dist/webhooks/jobs.js +77 -0
- package/dist/webhooks/jobs.js.map +1 -0
- package/dist/webhooks/mail.d.ts +24 -0
- package/dist/webhooks/mail.d.ts.map +1 -0
- package/dist/webhooks/mail.js +21 -0
- package/dist/webhooks/mail.js.map +1 -0
- package/dist/webhooks/match.d.ts +22 -0
- package/dist/webhooks/match.d.ts.map +1 -0
- package/dist/webhooks/match.js +40 -0
- package/dist/webhooks/match.js.map +1 -0
- package/dist/webhooks/safe-fetch.d.ts +94 -0
- package/dist/webhooks/safe-fetch.d.ts.map +1 -0
- package/dist/webhooks/safe-fetch.js +263 -0
- package/dist/webhooks/safe-fetch.js.map +1 -0
- package/dist/webhooks/service.d.ts +245 -0
- package/dist/webhooks/service.d.ts.map +1 -0
- package/dist/webhooks/service.js +861 -0
- package/dist/webhooks/service.js.map +1 -0
- package/dist/webhooks/signing.d.ts +40 -0
- package/dist/webhooks/signing.d.ts.map +1 -0
- package/dist/webhooks/signing.js +108 -0
- package/dist/webhooks/signing.js.map +1 -0
- package/dist/webhooks/tx-send.d.ts +31 -0
- package/dist/webhooks/tx-send.d.ts.map +1 -0
- package/dist/webhooks/tx-send.js +22 -0
- package/dist/webhooks/tx-send.js.map +1 -0
- package/package.json +7 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/create-api.ts +17 -3
- package/src/api/invitations.ts +6 -0
- package/src/api/members.ts +6 -2
- package/src/api/projects.ts +16 -1
- package/src/api/webhooks.ts +205 -0
- package/src/app.ts +17 -4
- package/src/boot.ts +48 -4
- package/src/email/index.ts +1 -0
- package/src/env.ts +11 -0
- package/src/identity/demo-pass-rules.ts +6 -1
- package/src/identity/hub-reconcile.ts +2 -2
- package/src/index.ts +13 -1
- package/src/jobs/index.ts +1 -0
- package/src/jobs/pgboss.ts +71 -9
- package/src/mcp/index.ts +1 -0
- package/src/mcp/server.ts +10 -2
- package/src/mcp/tool-kit.ts +7 -1
- package/src/mcp/webhooks.ts +370 -0
- package/src/members/removal.ts +21 -1
- package/src/middleware/idempotency.ts +3 -1
- package/src/middleware/index.ts +11 -0
- package/src/middleware/scopes.ts +14 -0
- package/src/middleware/static-assets.ts +144 -0
- package/src/secret-box.ts +93 -0
- package/src/testing/helpers.ts +8 -0
- package/src/tool-definition.ts +29 -0
- package/src/webhooks/catalog.ts +205 -0
- package/src/webhooks/index.ts +58 -0
- package/src/webhooks/jobs.ts +92 -0
- package/src/webhooks/mail.ts +48 -0
- package/src/webhooks/match.ts +52 -0
- package/src/webhooks/safe-fetch.ts +335 -0
- package/src/webhooks/service.ts +1089 -0
- package/src/webhooks/signing.ts +132 -0
- package/src/webhooks/tx-send.ts +53 -0
- package/test/host/drizzle/0002_webhooks.sql +49 -0
- package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
- package/test/host/drizzle/meta/_journal.json +8 -1
- package/test/host/identity.ts +6 -1
- package/test/host/minimal-host.ts +2 -1
- package/test/host/minimal-tool.ts +32 -4
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/secret-box.test.ts +124 -0
- package/test/unit/static-assets.test.ts +227 -0
- package/test/unit/tx-send.test.ts +23 -0
- package/test/unit/webhook-catalog.test.ts +151 -0
- package/test/unit/webhook-env.test.ts +60 -0
- package/test/unit/webhook-jobs.test.ts +92 -0
- package/test/unit/webhook-match.test.ts +81 -0
- package/test/unit/webhook-safe-fetch.test.ts +330 -0
- package/test/unit/webhook-signing.test.ts +169 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { endpointWants, matchesEventType, matchesFilters } from '../../src/webhooks/match.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The subscription rule (PRDCT-3482, verifier round 1, G7): an endpoint's
|
|
6
|
+
* event patterns against a type, its filters against the event's subjects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
describe('matchesEventType', () => {
|
|
10
|
+
it('`*` matches every type', () => {
|
|
11
|
+
expect(matchesEventType(['*'], 'thing.created')).toBe(true);
|
|
12
|
+
expect(matchesEventType(['*'], 'webhook.ping')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('a family matches its own types only', () => {
|
|
16
|
+
expect(matchesEventType(['thing.*'], 'thing.created')).toBe(true);
|
|
17
|
+
expect(matchesEventType(['thing.*'], 'thing.deleted')).toBe(true);
|
|
18
|
+
expect(matchesEventType(['thing.*'], 'project.created')).toBe(false);
|
|
19
|
+
// A family is the whole segment before the dot, never a prefix of it.
|
|
20
|
+
expect(matchesEventType(['thing.*'], 'things.created')).toBe(false);
|
|
21
|
+
expect(matchesEventType(['thin.*'], 'thing.created')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('an exact type matches itself only', () => {
|
|
25
|
+
expect(matchesEventType(['thing.created'], 'thing.created')).toBe(true);
|
|
26
|
+
expect(matchesEventType(['thing.created'], 'thing.deleted')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('any of several patterns; none is no match', () => {
|
|
30
|
+
expect(matchesEventType(['project.created', 'thing.deleted'], 'thing.deleted')).toBe(true);
|
|
31
|
+
expect(matchesEventType(['project.created', 'thing.deleted'], 'thing.created')).toBe(false);
|
|
32
|
+
expect(matchesEventType([], 'thing.created')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('matchesFilters', () => {
|
|
37
|
+
it('an empty filter matches everything, an event with no subject included', () => {
|
|
38
|
+
expect(matchesFilters({}, {})).toBe(true);
|
|
39
|
+
expect(matchesFilters({}, { thing: 't1' })).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('a key with no ids is no constraint', () => {
|
|
43
|
+
expect(matchesFilters({ project: [] }, { thing: 't1' })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('OR within one key: any id matches, on a scalar or a list', () => {
|
|
47
|
+
expect(matchesFilters({ thing: ['t1', 't2'] }, { thing: 't2' })).toBe(true);
|
|
48
|
+
expect(matchesFilters({ thing: ['t1', 't2'] }, { thing: 't3' })).toBe(false);
|
|
49
|
+
expect(matchesFilters({ project: ['p9'] }, { project: ['p1', 'p9'] })).toBe(true);
|
|
50
|
+
expect(matchesFilters({ project: ['p9'] }, { project: ['p1', 'p2'] })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('AND across keys: every filtered key must match', () => {
|
|
54
|
+
const filters = { thing: ['t-a'], project: ['p-a'] };
|
|
55
|
+
expect(matchesFilters(filters, { thing: 't-a', project: ['p-a'] })).toBe(true);
|
|
56
|
+
expect(matchesFilters(filters, { thing: 't-a', project: ['p-b'] })).toBe(false);
|
|
57
|
+
expect(matchesFilters(filters, { thing: 't-b', project: ['p-a'] })).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('an event missing a filtered subject does not match', () => {
|
|
61
|
+
expect(matchesFilters({ project: ['p1'] }, { thing: 't1' })).toBe(false);
|
|
62
|
+
expect(matchesFilters({ thing: ['t1'], project: ['p1'] }, { thing: 't1' })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('endpointWants', () => {
|
|
67
|
+
const endpoint = { enabled: true, events: ['thing.*'], filters: { thing: ['t1'] } };
|
|
68
|
+
|
|
69
|
+
it('enabled, the type subscribed and the filters satisfied', () => {
|
|
70
|
+
expect(endpointWants(endpoint, 'thing.created', { thing: 't1' })).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a disabled endpoint wants nothing', () => {
|
|
74
|
+
expect(endpointWants({ ...endpoint, enabled: false }, 'thing.created', { thing: 't1' })).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('the type unsubscribed, or the filters unmet, is no', () => {
|
|
78
|
+
expect(endpointWants(endpoint, 'project.created', { thing: 't1' })).toBe(false);
|
|
79
|
+
expect(endpointWants(endpoint, 'thing.created', { thing: 't2' })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
checkWebhookUrl,
|
|
7
|
+
isPublicAddress,
|
|
8
|
+
safePost,
|
|
9
|
+
WebhookUrlRefusedError,
|
|
10
|
+
type SafeFetchPolicy,
|
|
11
|
+
type SafeTransport
|
|
12
|
+
} from '../../src/webhooks/safe-fetch.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The guarded webhook POST: a POST never reaches the instance's own network
|
|
16
|
+
* unless the operator opened it. Real local servers on 127.0.0.1 stand in
|
|
17
|
+
* for the receiver; the lookup and transport seams stand in for DNS.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CLOSED: SafeFetchPolicy = { allowPrivateNetworks: false, httpsOnly: false };
|
|
21
|
+
const OPEN: SafeFetchPolicy = { allowPrivateNetworks: true, httpsOnly: false };
|
|
22
|
+
const CLOUD: SafeFetchPolicy = { allowPrivateNetworks: false, httpsOnly: true };
|
|
23
|
+
|
|
24
|
+
interface Seen {
|
|
25
|
+
method: string | undefined;
|
|
26
|
+
url: string | undefined;
|
|
27
|
+
headers: http.IncomingHttpHeaders;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seen: Seen[] = [];
|
|
32
|
+
let base = '';
|
|
33
|
+
const server = http.createServer((req, res) => {
|
|
34
|
+
const chunks: Buffer[] = [];
|
|
35
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
seen.push({
|
|
38
|
+
method: req.method,
|
|
39
|
+
url: req.url,
|
|
40
|
+
headers: req.headers,
|
|
41
|
+
body: Buffer.concat(chunks).toString('utf8')
|
|
42
|
+
});
|
|
43
|
+
if (req.url === '/redirect') {
|
|
44
|
+
res.writeHead(302, { location: '/target' });
|
|
45
|
+
res.end('moved');
|
|
46
|
+
} else if (req.url === '/silent') {
|
|
47
|
+
// never answers
|
|
48
|
+
} else if (req.url === '/big') {
|
|
49
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
50
|
+
res.end('x'.repeat(200 * 1024));
|
|
51
|
+
} else {
|
|
52
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
53
|
+
res.end('ok');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
60
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
server.closeAllConnections();
|
|
65
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The URL checks refuse a non-default port, so the local server is reached through the transport seam. */
|
|
69
|
+
function viaLocalServer(): SafeTransport {
|
|
70
|
+
return (url, init, signal) =>
|
|
71
|
+
new Promise((resolve, reject) => {
|
|
72
|
+
const req = http.request(
|
|
73
|
+
`${base}${url.pathname}${url.search}`,
|
|
74
|
+
{ method: init.method, headers: init.headers, signal, agent: false },
|
|
75
|
+
(res) => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: res })
|
|
76
|
+
);
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
req.end(init.body);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function refusal(p: Promise<unknown>): Promise<string> {
|
|
83
|
+
try {
|
|
84
|
+
await p;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
expect(e).toBeInstanceOf(WebhookUrlRefusedError);
|
|
87
|
+
return (e as WebhookUrlRefusedError).code;
|
|
88
|
+
}
|
|
89
|
+
throw new Error('expected a refusal');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function refusedSync(fn: () => unknown): string {
|
|
93
|
+
try {
|
|
94
|
+
fn();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
expect(e).toBeInstanceOf(WebhookUrlRefusedError);
|
|
97
|
+
return (e as WebhookUrlRefusedError).code;
|
|
98
|
+
}
|
|
99
|
+
throw new Error('expected a refusal');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('isPublicAddress', () => {
|
|
103
|
+
it.each([
|
|
104
|
+
['127.0.0.1', false],
|
|
105
|
+
['10.1.2.3', false],
|
|
106
|
+
['172.16.0.1', false],
|
|
107
|
+
['172.31.255.255', false],
|
|
108
|
+
['192.168.1.1', false],
|
|
109
|
+
['169.254.169.254', false],
|
|
110
|
+
['100.64.0.1', false],
|
|
111
|
+
['0.0.0.0', false],
|
|
112
|
+
['224.0.0.1', false],
|
|
113
|
+
['::1', false],
|
|
114
|
+
['::', false],
|
|
115
|
+
['fe80::1', false],
|
|
116
|
+
['fc00::1', false],
|
|
117
|
+
['::ffff:127.0.0.1', false],
|
|
118
|
+
['::ffff:8.8.8.8', true],
|
|
119
|
+
['2002:7f00:1::', false],
|
|
120
|
+
['2001:db8::1', false],
|
|
121
|
+
['8.8.8.8', true],
|
|
122
|
+
['2606:4700::1111', true],
|
|
123
|
+
['nonsense', false]
|
|
124
|
+
])('%s → %s', (ip, expected) => {
|
|
125
|
+
expect(isPublicAddress(ip)).toBe(expected);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('checkWebhookUrl', () => {
|
|
130
|
+
it('refuses a scheme other than http and https', () => {
|
|
131
|
+
expect(refusedSync(() => checkWebhookUrl('ftp://example.com/', CLOSED))).toBe('scheme');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refuses userinfo', () => {
|
|
135
|
+
expect(refusedSync(() => checkWebhookUrl('http://user:pw@example.com/', CLOSED))).toBe('userinfo');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('refuses a port that is not the scheme default', () => {
|
|
139
|
+
expect(refusedSync(() => checkWebhookUrl('http://example.com:8080/', CLOSED))).toBe('port');
|
|
140
|
+
expect(refusedSync(() => checkWebhookUrl('https://example.com:80/', CLOSED))).toBe('port');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('accepts any port once private networks are open (a LAN service on its own port)', () => {
|
|
144
|
+
expect(checkWebhookUrl('http://192.168.1.20:5678/webhook', OPEN).port).toBe('5678');
|
|
145
|
+
expect(checkWebhookUrl('http://example.com:8080/', OPEN).port).toBe('8080');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('takes an explicit default port as the default (the parser drops it, the rule is url.port === "")', () => {
|
|
149
|
+
expect(checkWebhookUrl('http://example.com:80/', CLOSED).href).toBe('http://example.com/');
|
|
150
|
+
expect(checkWebhookUrl('https://example.com:443/', CLOSED).href).toBe('https://example.com/');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('refuses a private literal address by default, accepts it when private networks are open', () => {
|
|
154
|
+
expect(refusedSync(() => checkWebhookUrl('http://127.0.0.1/', CLOSED))).toBe('private_address');
|
|
155
|
+
expect(refusedSync(() => checkWebhookUrl('http://[::1]/', CLOSED))).toBe('private_address');
|
|
156
|
+
expect(refusedSync(() => checkWebhookUrl('http://2130706433/', CLOSED))).toBe('private_address');
|
|
157
|
+
expect(checkWebhookUrl('http://127.0.0.1/', OPEN).hostname).toBe('127.0.0.1');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('refuses http under httpsOnly and accepts https', () => {
|
|
161
|
+
expect(refusedSync(() => checkWebhookUrl('http://example.com/', CLOUD))).toBe('https_required');
|
|
162
|
+
expect(checkWebhookUrl('https://example.com/', CLOUD).href).toBe('https://example.com/');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('refuses what is not a URL', () => {
|
|
166
|
+
expect(refusedSync(() => checkWebhookUrl('not a url', CLOSED))).toBe('malformed');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('safePost against a loopback receiver', () => {
|
|
171
|
+
it('refuses the loopback address by default, and the receiver never sees a request', async () => {
|
|
172
|
+
const before = seen.length;
|
|
173
|
+
// a literal loopback address, refused before any connection
|
|
174
|
+
expect(await refusal(safePost('http://127.0.0.1/hook', {}, '{}', CLOSED))).toBe('private_address');
|
|
175
|
+
// a name resolving to loopback, refused by the DEFAULT transport's socket lookup (no seam
|
|
176
|
+
// transport: the check that runs is the one inside node's connect)
|
|
177
|
+
const asked: string[] = [];
|
|
178
|
+
expect(
|
|
179
|
+
await refusal(
|
|
180
|
+
safePost('http://receiver.test/hook', {}, '{}', CLOSED, {
|
|
181
|
+
lookup: async (host) => {
|
|
182
|
+
asked.push(host);
|
|
183
|
+
return [{ address: '127.0.0.1', family: 4 }];
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
).toBe('private_address');
|
|
188
|
+
expect(asked).toEqual(['receiver.test']);
|
|
189
|
+
expect(seen.length).toBe(before);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('delivers when private networks are open: the method, the headers and the exact body', async () => {
|
|
193
|
+
const body = JSON.stringify({ event: 'thing.created', note: 'déjà vu' });
|
|
194
|
+
const result = await safePost(
|
|
195
|
+
'http://receiver.test/hook',
|
|
196
|
+
{ 'content-type': 'application/json', 'x-signature': 'sig-1', 'Content-Length': '1' },
|
|
197
|
+
body,
|
|
198
|
+
OPEN,
|
|
199
|
+
{ lookup: async () => [{ address: '127.0.0.1', family: 4 }], transport: viaLocalServer() }
|
|
200
|
+
);
|
|
201
|
+
expect(result.status).toBe(200);
|
|
202
|
+
expect(result.bodyText).toBe('ok');
|
|
203
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
204
|
+
const got = seen.at(-1)!;
|
|
205
|
+
expect(got.method).toBe('POST');
|
|
206
|
+
expect(got.url).toBe('/hook');
|
|
207
|
+
expect(got.body).toBe(body);
|
|
208
|
+
expect(got.headers['content-type']).toBe('application/json');
|
|
209
|
+
expect(got.headers['x-signature']).toBe('sig-1');
|
|
210
|
+
expect(got.headers['content-length']).toBe(String(Buffer.byteLength(body)));
|
|
211
|
+
expect(got.headers['user-agent']).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('returns a 302 as its status and never requests the Location', async () => {
|
|
215
|
+
const before = seen.length;
|
|
216
|
+
const result = await safePost('http://receiver.test/redirect', {}, 'x', OPEN, {
|
|
217
|
+
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
|
218
|
+
transport: viaLocalServer()
|
|
219
|
+
});
|
|
220
|
+
expect(result.status).toBe(302);
|
|
221
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
222
|
+
expect(seen.slice(before).map((s) => s.url)).toEqual(['/redirect']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('the REAL transport (no seam) answers a 302 as its status and never requests the Location (verifier round 1, G2)', async () => {
|
|
226
|
+
// A second loopback server is the Location's target: it must see nothing.
|
|
227
|
+
const targetSeen: string[] = [];
|
|
228
|
+
const target = http.createServer((req, res) => {
|
|
229
|
+
targetSeen.push(req.url ?? '');
|
|
230
|
+
res.writeHead(200);
|
|
231
|
+
res.end('followed');
|
|
232
|
+
});
|
|
233
|
+
await new Promise<void>((resolve) => target.listen(0, '127.0.0.1', resolve));
|
|
234
|
+
const targetUrl = `http://127.0.0.1:${(target.address() as AddressInfo).port}/target`;
|
|
235
|
+
const redirector = http.createServer((req, res) => {
|
|
236
|
+
req.resume();
|
|
237
|
+
req.on('end', () => {
|
|
238
|
+
res.writeHead(302, { location: targetUrl });
|
|
239
|
+
res.end('moved');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
await new Promise<void>((resolve) => redirector.listen(0, '127.0.0.1', resolve));
|
|
243
|
+
const redirectorUrl = `http://127.0.0.1:${(redirector.address() as AddressInfo).port}/redirect`;
|
|
244
|
+
try {
|
|
245
|
+
// OPEN lifts the address rule and the port rule: the ephemeral ports are reached directly.
|
|
246
|
+
const result = await safePost(redirectorUrl, {}, 'x', OPEN, { timeoutMs: 2000 });
|
|
247
|
+
expect(result.status).toBe(302);
|
|
248
|
+
expect(result.bodyText).toBe('moved');
|
|
249
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
250
|
+
expect(targetSeen).toEqual([]);
|
|
251
|
+
} finally {
|
|
252
|
+
for (const s of [redirector, target]) {
|
|
253
|
+
s.closeAllConnections();
|
|
254
|
+
await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('a receiver that never answers: the timeout rejects with an abort error', async () => {
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
const err = await safePost('http://receiver.test/silent', {}, 'x', OPEN, {
|
|
262
|
+
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
|
263
|
+
transport: viaLocalServer(),
|
|
264
|
+
timeoutMs: 300
|
|
265
|
+
}).then(
|
|
266
|
+
() => null,
|
|
267
|
+
(e: unknown) => e as Error
|
|
268
|
+
);
|
|
269
|
+
expect(err).not.toBeNull();
|
|
270
|
+
expect(err).not.toBeInstanceOf(WebhookUrlRefusedError);
|
|
271
|
+
expect(['AbortError', 'TimeoutError']).toContain(err!.name);
|
|
272
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('cuts the response body at maxResponseBytes and resolves promptly', async () => {
|
|
276
|
+
const started = Date.now();
|
|
277
|
+
const result = await safePost('http://receiver.test/big', {}, 'x', OPEN, {
|
|
278
|
+
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
|
279
|
+
transport: viaLocalServer(),
|
|
280
|
+
maxResponseBytes: 1024
|
|
281
|
+
});
|
|
282
|
+
expect(result.status).toBe(200);
|
|
283
|
+
expect(result.bodyText.length).toBe(1024);
|
|
284
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('safePost through the lookup seam', () => {
|
|
289
|
+
let transportCalls = 0;
|
|
290
|
+
const transport: SafeTransport = async () => {
|
|
291
|
+
transportCalls++;
|
|
292
|
+
return { status: 200, headers: {}, body: Readable.from([Buffer.from('ok')]) };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
it('refuses a name when ONE of its addresses is private', async () => {
|
|
296
|
+
transportCalls = 0;
|
|
297
|
+
const code = await refusal(
|
|
298
|
+
safePost('https://example.test/hook', {}, '{}', CLOSED, {
|
|
299
|
+
lookup: async () => [
|
|
300
|
+
{ address: '93.184.216.34', family: 4 },
|
|
301
|
+
{ address: '127.0.0.1', family: 4 }
|
|
302
|
+
],
|
|
303
|
+
transport
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
expect(code).toBe('private_address');
|
|
307
|
+
expect(transportCalls).toBe(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('refuses a name that resolves to nothing, even with private networks open', async () => {
|
|
311
|
+
transportCalls = 0;
|
|
312
|
+
for (const policy of [CLOSED, OPEN]) {
|
|
313
|
+
const code = await refusal(
|
|
314
|
+
safePost('https://example.test/hook', {}, '{}', policy, { lookup: async () => [], transport })
|
|
315
|
+
);
|
|
316
|
+
expect(code).toBe('unresolvable');
|
|
317
|
+
}
|
|
318
|
+
expect(transportCalls).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('passes a name whose every address is public', async () => {
|
|
322
|
+
transportCalls = 0;
|
|
323
|
+
const result = await safePost('https://example.test/hook', {}, '{}', CLOUD, {
|
|
324
|
+
lookup: async () => [{ address: '93.184.216.34', family: 4 }],
|
|
325
|
+
transport
|
|
326
|
+
});
|
|
327
|
+
expect(result).toMatchObject({ status: 200, bodyText: 'ok' });
|
|
328
|
+
expect(transportCalls).toBe(1);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
generateWebhookSecret,
|
|
4
|
+
signWebhook,
|
|
5
|
+
SigningError,
|
|
6
|
+
verifyWebhookSignature,
|
|
7
|
+
WEBHOOK_SECRET_PREFIX,
|
|
8
|
+
webhookHeaders,
|
|
9
|
+
webhookSecretKey
|
|
10
|
+
} from '../../src/webhooks/signing.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Standard Webhooks, the symmetric v1 scheme. The vector is the one the
|
|
14
|
+
* reference libraries pin (standard-webhooks/standard-webhooks,
|
|
15
|
+
* libraries/javascript/src/webhook.test.ts and the python twin), so a receiver
|
|
16
|
+
* using any of them verifies what this signs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const VECTOR = {
|
|
20
|
+
secret: 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw',
|
|
21
|
+
id: 'msg_p5jXN8AQM9LWM0D4loKWxJek',
|
|
22
|
+
timestamp: 1614265330,
|
|
23
|
+
body: '{"test": 2432232314}',
|
|
24
|
+
signature: 'v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE='
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const NOW = VECTOR.timestamp;
|
|
28
|
+
|
|
29
|
+
function headersOf(signature: string, id = VECTOR.id, timestamp = String(VECTOR.timestamp)) {
|
|
30
|
+
return { id, timestamp, signature };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('Standard Webhooks signing', () => {
|
|
34
|
+
it("reproduces the reference libraries' vector", () => {
|
|
35
|
+
expect(signWebhook([VECTOR.secret], VECTOR.id, VECTOR.timestamp, VECTOR.body)).toBe(VECTOR.signature);
|
|
36
|
+
expect(
|
|
37
|
+
verifyWebhookSignature(VECTOR.secret, headersOf(VECTOR.signature), VECTOR.body, { nowSeconds: NOW })
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('generates a prefixed secret of 32 base64 characters holding 24 bytes', () => {
|
|
42
|
+
const secret = generateWebhookSecret();
|
|
43
|
+
expect(secret.startsWith(WEBHOOK_SECRET_PREFIX)).toBe(true);
|
|
44
|
+
const encoded = secret.slice(WEBHOOK_SECRET_PREFIX.length);
|
|
45
|
+
expect(encoded).toMatch(/^[A-Za-z0-9+/]{32}$/);
|
|
46
|
+
expect(Buffer.from(encoded, 'base64')).toHaveLength(24);
|
|
47
|
+
expect(webhookSecretKey(secret)).toHaveLength(24);
|
|
48
|
+
expect(generateWebhookSecret()).not.toBe(secret);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('signs with two secrets as two entries, each verifying alone', () => {
|
|
52
|
+
const [a, b] = [generateWebhookSecret(), generateWebhookSecret()];
|
|
53
|
+
const header = signWebhook([a, b], VECTOR.id, NOW, VECTOR.body);
|
|
54
|
+
const entries = header.split(' ');
|
|
55
|
+
expect(entries).toHaveLength(2);
|
|
56
|
+
for (const entry of entries) expect(entry).toMatch(/^v1,[A-Za-z0-9+/]{43}=$/);
|
|
57
|
+
expect(entries[0]).toBe(signWebhook([a], VECTOR.id, NOW, VECTOR.body));
|
|
58
|
+
expect(entries[1]).toBe(signWebhook([b], VECTOR.id, NOW, VECTOR.body));
|
|
59
|
+
for (const secret of [a, b]) {
|
|
60
|
+
expect(verifyWebhookSignature(secret, headersOf(header), VECTOR.body, { nowSeconds: NOW })).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
expect(
|
|
63
|
+
verifyWebhookSignature(generateWebhookSecret(), headersOf(header), VECTOR.body, { nowSeconds: NOW })
|
|
64
|
+
).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('builds the three headers and the content type', () => {
|
|
68
|
+
expect(webhookHeaders([VECTOR.secret], VECTOR.id, VECTOR.timestamp, VECTOR.body)).toEqual({
|
|
69
|
+
'webhook-id': VECTOR.id,
|
|
70
|
+
'webhook-timestamp': '1614265330',
|
|
71
|
+
'webhook-signature': VECTOR.signature,
|
|
72
|
+
'content-type': 'application/json'
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('fails on a changed body, id or timestamp', () => {
|
|
77
|
+
const verify = (headers: ReturnType<typeof headersOf>, body = VECTOR.body) =>
|
|
78
|
+
verifyWebhookSignature(VECTOR.secret, headers, body, { nowSeconds: NOW });
|
|
79
|
+
expect(verify(headersOf(VECTOR.signature), '{"test": 2432232315}')).toBe(false);
|
|
80
|
+
expect(verify(headersOf(VECTOR.signature), `${VECTOR.body} `)).toBe(false);
|
|
81
|
+
expect(verify(headersOf(VECTOR.signature, 'msg_p5jXN8AQM9LWM0D4loKWxJel'))).toBe(false);
|
|
82
|
+
expect(verify(headersOf(VECTOR.signature, VECTOR.id, String(VECTOR.timestamp + 1)))).toBe(false);
|
|
83
|
+
expect(verify(headersOf('v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OA='))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('fails on a timestamp older or newer than the tolerance, passes at its edge', () => {
|
|
87
|
+
const header = headersOf(VECTOR.signature);
|
|
88
|
+
const verifyAt = (nowSeconds: number, toleranceSeconds?: number) =>
|
|
89
|
+
verifyWebhookSignature(VECTOR.secret, header, VECTOR.body, {
|
|
90
|
+
nowSeconds,
|
|
91
|
+
...(toleranceSeconds === undefined ? {} : { toleranceSeconds })
|
|
92
|
+
});
|
|
93
|
+
expect(verifyAt(NOW + 300)).toBe(true);
|
|
94
|
+
expect(verifyAt(NOW - 300)).toBe(true);
|
|
95
|
+
expect(verifyAt(NOW + 301)).toBe(false);
|
|
96
|
+
expect(verifyAt(NOW - 301)).toBe(false);
|
|
97
|
+
expect(verifyAt(NOW + 60, 30)).toBe(false);
|
|
98
|
+
expect(verifyAt(NOW + 60, 90)).toBe(true);
|
|
99
|
+
// The default clock is now: a 2021 timestamp is long stale.
|
|
100
|
+
expect(verifyWebhookSignature(VECTOR.secret, header, VECTOR.body)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('fails on a timestamp that is not whole unix seconds', () => {
|
|
104
|
+
for (const timestamp of ['', 'abc', '1614265330.0', '-1614265330', ' 1614265330', '1614265330000000']) {
|
|
105
|
+
expect(
|
|
106
|
+
verifyWebhookSignature(
|
|
107
|
+
VECTOR.secret,
|
|
108
|
+
headersOf(VECTOR.signature, VECTOR.id, timestamp),
|
|
109
|
+
VECTOR.body,
|
|
110
|
+
{
|
|
111
|
+
nowSeconds: NOW
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
).toBe(false);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('verifies a v1 entry beside a v2 entry, and ignores a lone v2 or a malformed entry', () => {
|
|
119
|
+
const v1 = VECTOR.signature.slice(3);
|
|
120
|
+
const verify = (signature: string) =>
|
|
121
|
+
verifyWebhookSignature(VECTOR.secret, headersOf(signature), VECTOR.body, { nowSeconds: NOW });
|
|
122
|
+
expect(verify(`v2,${v1} ${VECTOR.signature}`)).toBe(true);
|
|
123
|
+
expect(verify(`${VECTOR.signature} v2,AAAA`)).toBe(true);
|
|
124
|
+
expect(verify(`v1a,${v1}`)).toBe(false);
|
|
125
|
+
expect(verify(`v2,${v1}`)).toBe(false);
|
|
126
|
+
expect(verify(v1)).toBe(false);
|
|
127
|
+
expect(verify('')).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws SigningError malformed_secret on a malformed secret', () => {
|
|
131
|
+
const malformed = [
|
|
132
|
+
'MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw', // no prefix
|
|
133
|
+
'whsk_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw', // wrong prefix
|
|
134
|
+
'whsec_', // nothing after the prefix
|
|
135
|
+
'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaS', // a length that is not base64
|
|
136
|
+
'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLa*w', // a character outside the alphabet
|
|
137
|
+
'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLa_w', // base64url, not standard
|
|
138
|
+
'whsec_AAAAAAAAAAAAAAAAAAAAAA==', // 16 bytes, under the spec's 24
|
|
139
|
+
`whsec_${Buffer.alloc(65).toString('base64')}` // over the spec's 64
|
|
140
|
+
];
|
|
141
|
+
for (const secret of malformed) {
|
|
142
|
+
let thrown: unknown;
|
|
143
|
+
try {
|
|
144
|
+
webhookSecretKey(secret);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
thrown = error;
|
|
147
|
+
}
|
|
148
|
+
expect(thrown, secret).toBeInstanceOf(SigningError);
|
|
149
|
+
expect((thrown as SigningError).code).toBe('malformed_secret');
|
|
150
|
+
expect(() => signWebhook([secret], VECTOR.id, NOW, VECTOR.body)).toThrow(SigningError);
|
|
151
|
+
expect(() =>
|
|
152
|
+
verifyWebhookSignature(secret, headersOf(VECTOR.signature), VECTOR.body, { nowSeconds: NOW })
|
|
153
|
+
).toThrow(SigningError);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('refuses to sign with no secret, or with a timestamp that is not whole seconds', () => {
|
|
158
|
+
let thrown: unknown;
|
|
159
|
+
try {
|
|
160
|
+
signWebhook([], VECTOR.id, NOW, VECTOR.body);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
thrown = error;
|
|
163
|
+
}
|
|
164
|
+
expect(thrown).toBeInstanceOf(SigningError);
|
|
165
|
+
expect((thrown as SigningError).code).toBe('no_secret');
|
|
166
|
+
expect(() => signWebhook([VECTOR.secret], VECTOR.id, NOW + 0.5, VECTOR.body)).toThrow(RangeError);
|
|
167
|
+
expect(() => signWebhook([VECTOR.secret], VECTOR.id, -1, VECTOR.body)).toThrow(RangeError);
|
|
168
|
+
});
|
|
169
|
+
});
|