@antasphere/chassis-server 1.0.2 → 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.
- 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/cli-package.d.ts +58 -0
- package/dist/api/cli-package.d.ts.map +1 -0
- package/dist/api/cli-package.js +83 -0
- package/dist/api/cli-package.js.map +1 -0
- 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 +33 -4
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -0
- package/dist/api/index.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/boot.d.ts.map +1 -1
- package/dist/boot.js +47 -4
- package/dist/boot.js.map +1 -1
- package/dist/cli-package/index.d.ts +44 -0
- package/dist/cli-package/index.d.ts.map +1 -0
- package/dist/cli-package/index.js +60 -0
- package/dist/cli-package/index.js.map +1 -0
- 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 +11 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +24 -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/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/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 +11 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/cli-package.ts +151 -0
- package/src/api/create-api.ts +42 -4
- package/src/api/index.ts +1 -0
- 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/boot.ts +48 -4
- package/src/cli-package/index.ts +88 -0
- package/src/email/index.ts +1 -0
- package/src/env.ts +25 -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/scopes.ts +14 -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/cli-package.test.ts +403 -0
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/cli-package.test.ts +93 -0
- package/test/unit/secret-box.test.ts +124 -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,52 @@
|
|
|
1
|
+
import type { WebhookSubjectValue } from './catalog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The subscription rule, pure and unit-tested (`webhook-match.test.ts`): an
|
|
5
|
+
* endpoint's `events` are matched against an event's type, its `filters`
|
|
6
|
+
* against the event's subjects. The chassis never knows what a subject IS,
|
|
7
|
+
* only that it is a key with one or several ids.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** `*` matches every type; `family.*` matches the family; a type matches itself. */
|
|
11
|
+
export function matchesEventType(patterns: readonly string[], type: string): boolean {
|
|
12
|
+
const family = type.slice(0, type.indexOf('.'));
|
|
13
|
+
for (const pattern of patterns) {
|
|
14
|
+
if (pattern === '*' || pattern === type) return true;
|
|
15
|
+
if (pattern.endsWith('.*') && pattern.slice(0, -2) === family) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* AND across the filter's keys, OR within a key's ids. An event that does not
|
|
22
|
+
* carry a filtered subject does not match (a filter on `project` excludes an
|
|
23
|
+
* event with no project). An empty filter matches everything.
|
|
24
|
+
*/
|
|
25
|
+
export function matchesFilters(
|
|
26
|
+
filters: Readonly<Record<string, readonly string[]>>,
|
|
27
|
+
subjects: Readonly<Record<string, WebhookSubjectValue>>
|
|
28
|
+
): boolean {
|
|
29
|
+
for (const [key, ids] of Object.entries(filters)) {
|
|
30
|
+
if (ids.length === 0) continue;
|
|
31
|
+
const value = subjects[key];
|
|
32
|
+
if (value === undefined) return false;
|
|
33
|
+
const values = typeof value === 'string' ? [value] : value;
|
|
34
|
+
if (!values.some((v) => ids.includes(v))) return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether an endpoint takes this event: enabled, the type subscribed, the filters satisfied. */
|
|
40
|
+
export function endpointWants(
|
|
41
|
+
endpoint: {
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
events: readonly string[];
|
|
44
|
+
filters: Readonly<Record<string, readonly string[]>>;
|
|
45
|
+
},
|
|
46
|
+
type: string,
|
|
47
|
+
subjects: Readonly<Record<string, WebhookSubjectValue>>
|
|
48
|
+
): boolean {
|
|
49
|
+
return (
|
|
50
|
+
endpoint.enabled && matchesEventType(endpoint.events, type) && matchesFilters(endpoint.filters, subjects)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { promises as dns } from 'node:dns';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { isIP, type LookupFunction } from 'node:net';
|
|
5
|
+
import { addAbortSignal, type Readable } from 'node:stream';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The guarded outbound POST the webhook delivery worker uses. A webhook's
|
|
9
|
+
* address is somebody else's choice, so the one rule that is not negotiable:
|
|
10
|
+
* a POST never reaches the instance's own network unless the operator opened
|
|
11
|
+
* it (`allowPrivateNetworks`, a self-hoster's LAN).
|
|
12
|
+
*
|
|
13
|
+
* - Up front, before any request (`checkWebhookUrl`): `http:`/`https:` only
|
|
14
|
+
* (`https:` alone under `httpsOnly`), the scheme's default port only, no
|
|
15
|
+
* userinfo, a hostname, and a literal IP host must be public.
|
|
16
|
+
* - A name is resolved with ALL its addresses, and the connection is refused
|
|
17
|
+
* (`WebhookUrlRefusedError`) when ANY of them is not public. The check is
|
|
18
|
+
* the socket's own `lookup`, so the address connected to IS the one
|
|
19
|
+
* checked: no gap for a name that answers differently the second time.
|
|
20
|
+
* - No redirect is ever followed: a 3xx comes back as its status.
|
|
21
|
+
* - One timeout over the whole call (resolution, connection, the body read).
|
|
22
|
+
* - The response body is read up to `maxResponseBytes`, then the stream is
|
|
23
|
+
* destroyed.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type WebhookUrlRefusal =
|
|
27
|
+
'scheme' | 'port' | 'userinfo' | 'private_address' | 'https_required' | 'unresolvable' | 'malformed';
|
|
28
|
+
|
|
29
|
+
const REFUSAL_MESSAGES: Record<WebhookUrlRefusal, string> = {
|
|
30
|
+
scheme: 'the webhook URL must use http: or https:',
|
|
31
|
+
port: "the webhook URL must use its scheme's default port",
|
|
32
|
+
userinfo: 'the webhook URL must not carry a user name or a password',
|
|
33
|
+
private_address: 'the webhook URL resolves to an address that is not on the public internet',
|
|
34
|
+
https_required: 'the webhook URL must use https:',
|
|
35
|
+
unresolvable: 'the webhook URL names a host that resolves to no address',
|
|
36
|
+
malformed: 'the webhook URL is not a valid URL'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A webhook URL the guard refused (a scheme, a port, userinfo, an address). */
|
|
40
|
+
export class WebhookUrlRefusedError extends Error {
|
|
41
|
+
readonly code: WebhookUrlRefusal;
|
|
42
|
+
constructor(code: WebhookUrlRefusal, message?: string) {
|
|
43
|
+
super(message ?? REFUSAL_MESSAGES[code]);
|
|
44
|
+
this.name = 'WebhookUrlRefusedError';
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SafeFetchPolicy {
|
|
50
|
+
/** Private, loopback, link-local and other non-public addresses are reachable (a self-hoster's LAN). Default false. */
|
|
51
|
+
allowPrivateNetworks: boolean;
|
|
52
|
+
/** Only https: URLs are accepted (the cloud edition). Default false. */
|
|
53
|
+
httpsOnly: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface LookupAddress {
|
|
57
|
+
address: string;
|
|
58
|
+
family: number;
|
|
59
|
+
}
|
|
60
|
+
/** Every address a name resolves to (`dns.lookup(host, { all: true })` by default). */
|
|
61
|
+
export type SafeLookup = (hostname: string) => Promise<LookupAddress[]>;
|
|
62
|
+
|
|
63
|
+
export interface SafeFetchResponse {
|
|
64
|
+
status: number;
|
|
65
|
+
headers: Record<string, string | string[] | undefined>;
|
|
66
|
+
body: Readable;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One POST, no redirect followed. The default speaks `node:http`/`node:https`
|
|
70
|
+
* with the guarded lookup in the socket; a test hands its own, and the
|
|
71
|
+
* address check then runs before it.
|
|
72
|
+
*/
|
|
73
|
+
export type SafeTransport = (
|
|
74
|
+
url: URL,
|
|
75
|
+
init: { method: 'POST'; headers: Record<string, string>; body: string },
|
|
76
|
+
signal: AbortSignal
|
|
77
|
+
) => Promise<SafeFetchResponse>;
|
|
78
|
+
|
|
79
|
+
export interface SafeFetchOptions {
|
|
80
|
+
lookup?: SafeLookup;
|
|
81
|
+
transport?: SafeTransport;
|
|
82
|
+
/** Over the whole call. Default 10 000. */
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
/** The response body is cut there. Default 65 536. */
|
|
85
|
+
maxResponseBytes?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SafeFetchResult {
|
|
89
|
+
status: number;
|
|
90
|
+
/** the response body, utf8, cut at maxResponseBytes */
|
|
91
|
+
bodyText: string;
|
|
92
|
+
durationMs: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
96
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 65_536;
|
|
97
|
+
|
|
98
|
+
// ── The address check ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function ipv4(ip: string): number[] | null {
|
|
101
|
+
const parts = ip.split('.');
|
|
102
|
+
if (parts.length !== 4) return null;
|
|
103
|
+
const bytes = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
|
104
|
+
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isPublicV4(b: number[]): boolean {
|
|
108
|
+
const [a, c] = [b[0]!, b[1]!];
|
|
109
|
+
if (a === 0 || a === 10 || a === 127) return false; // this network, private, loopback
|
|
110
|
+
if (a === 100 && c >= 64 && c <= 127) return false; // carrier-grade NAT
|
|
111
|
+
if (a === 169 && c === 254) return false; // link-local, the cloud metadata address
|
|
112
|
+
if (a === 172 && c >= 16 && c <= 31) return false; // private
|
|
113
|
+
if (a === 192 && c === 0 && b[2] === 0) return false; // IETF protocol assignments
|
|
114
|
+
if (a === 192 && c === 0 && b[2] === 2) return false; // documentation
|
|
115
|
+
if (a === 192 && c === 168) return false; // private
|
|
116
|
+
if (a === 198 && (c === 18 || c === 19)) return false; // benchmarking
|
|
117
|
+
if (a === 198 && c === 51 && b[2] === 100) return false; // documentation
|
|
118
|
+
if (a === 203 && c === 0 && b[2] === 113) return false; // documentation
|
|
119
|
+
return a < 224; // multicast, reserved, broadcast
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The eight 16-bit groups of an IPv6 address (a trailing dotted IPv4 folded in), or null. */
|
|
123
|
+
function ipv6(ip: string): number[] | null {
|
|
124
|
+
let s = ip.toLowerCase().replace(/%.*$/, '');
|
|
125
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
|
|
126
|
+
if (dotted) {
|
|
127
|
+
const v4 = ipv4(dotted[1]!);
|
|
128
|
+
if (!v4) return null;
|
|
129
|
+
s = `${s.slice(0, -dotted[1]!.length)}${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`;
|
|
130
|
+
}
|
|
131
|
+
const halves = s.split('::');
|
|
132
|
+
if (halves.length > 2) return null;
|
|
133
|
+
const parse = (h: string) => (h === '' ? [] : h.split(':'));
|
|
134
|
+
const head = parse(halves[0]!);
|
|
135
|
+
const tail = halves.length === 2 ? parse(halves[1]!) : [];
|
|
136
|
+
const fill = 8 - head.length - tail.length;
|
|
137
|
+
if (halves.length === 2 ? fill < 1 : fill !== 0) return null;
|
|
138
|
+
const groups = [...head, ...Array<string>(halves.length === 2 ? fill : 0).fill('0'), ...tail];
|
|
139
|
+
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
|
|
140
|
+
return groups.map((g) => Number.parseInt(g, 16));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether an address is on the public internet: false for loopback, private,
|
|
145
|
+
* link-local (the metadata address), carrier-grade NAT, multicast, reserved,
|
|
146
|
+
* and every IPv6 outside global unicast (`2000::/3`): `::`, `::1`, `fc00::/7`,
|
|
147
|
+
* `fe80::/10`, `ff00::/8`. An IPv4-mapped (`::ffff:a.b.c.d`) or a 6to4
|
|
148
|
+
* (`2002::/16`) address is judged by the IPv4 inside it. Unparseable: false.
|
|
149
|
+
*/
|
|
150
|
+
export function isPublicAddress(ip: string): boolean {
|
|
151
|
+
if (isIP(ip) === 4) {
|
|
152
|
+
const b = ipv4(ip);
|
|
153
|
+
return b !== null && isPublicV4(b);
|
|
154
|
+
}
|
|
155
|
+
const g = ipv6(ip);
|
|
156
|
+
if (!g) return false;
|
|
157
|
+
const embedded = (hi: number, lo: number) => isPublicV4([hi >> 8, hi & 255, lo >> 8, lo & 255]);
|
|
158
|
+
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) return embedded(g[6]!, g[7]!);
|
|
159
|
+
if ((g[0]! & 0xe000) !== 0x2000) return false; // not global unicast
|
|
160
|
+
if (g[0] === 0x2002) return embedded(g[1]!, g[2]!); // 6to4
|
|
161
|
+
if (g[0] === 0x2001 && (g[1] === 0 || g[1] === 0xdb8)) return false; // Teredo, documentation
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The URL's host without the brackets of an IPv6 literal. */
|
|
166
|
+
function bareHost(hostname: string): string {
|
|
167
|
+
return hostname.replace(/^\[|\]$/g, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The checks that need no lookup: `http:`/`https:` only (`https:` alone under
|
|
172
|
+
* `httpsOnly`), the scheme's default port only (`url.port === ''`, so an
|
|
173
|
+
* explicit default port, which the URL parser drops, passes), no userinfo, a
|
|
174
|
+
* non-empty hostname, a literal IP public unless `allowPrivateNetworks`.
|
|
175
|
+
* Throws `WebhookUrlRefusedError`; returns the parsed URL.
|
|
176
|
+
*/
|
|
177
|
+
export function checkWebhookUrl(url: string, policy: SafeFetchPolicy): URL {
|
|
178
|
+
let parsed: URL;
|
|
179
|
+
try {
|
|
180
|
+
parsed = new URL(url);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new WebhookUrlRefusedError('malformed');
|
|
183
|
+
}
|
|
184
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new WebhookUrlRefusedError('scheme');
|
|
185
|
+
if (policy.httpsOnly && parsed.protocol !== 'https:') throw new WebhookUrlRefusedError('https_required');
|
|
186
|
+
if (parsed.username || parsed.password) throw new WebhookUrlRefusedError('userinfo');
|
|
187
|
+
// The scheme's default port only on the public internet; an operator who
|
|
188
|
+
// opened private networks addresses services on their own ports (an n8n on
|
|
189
|
+
// 5678), so the port rule lifts with the address rule.
|
|
190
|
+
if (parsed.port !== '' && !policy.allowPrivateNetworks) throw new WebhookUrlRefusedError('port');
|
|
191
|
+
const host = bareHost(parsed.hostname);
|
|
192
|
+
if (host === '') throw new WebhookUrlRefusedError('malformed');
|
|
193
|
+
if (isIP(host) && !policy.allowPrivateNetworks && !isPublicAddress(host)) {
|
|
194
|
+
throw new WebhookUrlRefusedError('private_address');
|
|
195
|
+
}
|
|
196
|
+
return parsed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The address to connect to, once EVERY address the name resolves to is
|
|
201
|
+
* public (or the policy opens private networks); an empty resolution is
|
|
202
|
+
* `unresolvable`. A failing resolver's own error passes through.
|
|
203
|
+
*/
|
|
204
|
+
async function checkedAddress(
|
|
205
|
+
hostname: string,
|
|
206
|
+
policy: SafeFetchPolicy,
|
|
207
|
+
lookup: SafeLookup
|
|
208
|
+
): Promise<LookupAddress> {
|
|
209
|
+
const host = bareHost(hostname);
|
|
210
|
+
const family = isIP(host);
|
|
211
|
+
if (family) {
|
|
212
|
+
if (!policy.allowPrivateNetworks && !isPublicAddress(host)) {
|
|
213
|
+
throw new WebhookUrlRefusedError('private_address');
|
|
214
|
+
}
|
|
215
|
+
return { address: host, family };
|
|
216
|
+
}
|
|
217
|
+
const all = await lookup(host);
|
|
218
|
+
if (all.length === 0) throw new WebhookUrlRefusedError('unresolvable');
|
|
219
|
+
if (!policy.allowPrivateNetworks && !all.every((a) => isPublicAddress(a.address))) {
|
|
220
|
+
throw new WebhookUrlRefusedError('private_address');
|
|
221
|
+
}
|
|
222
|
+
return all[0]!;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The promise, or the signal's reason once it aborts first. */
|
|
226
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
227
|
+
if (signal.aborted) return Promise.reject(signal.reason as Error);
|
|
228
|
+
return new Promise<T>((resolve, reject) => {
|
|
229
|
+
const onAbort = () => reject(signal.reason as Error);
|
|
230
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
231
|
+
promise.then(
|
|
232
|
+
(v) => {
|
|
233
|
+
signal.removeEventListener('abort', onAbort);
|
|
234
|
+
resolve(v);
|
|
235
|
+
},
|
|
236
|
+
(e: unknown) => {
|
|
237
|
+
signal.removeEventListener('abort', onAbort);
|
|
238
|
+
reject(e as Error);
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** At most `max` bytes of the body; the rest is never read. */
|
|
245
|
+
async function readCapped(body: Readable, max: number): Promise<Buffer> {
|
|
246
|
+
const chunks: Buffer[] = [];
|
|
247
|
+
let size = 0;
|
|
248
|
+
for await (const chunk of body) {
|
|
249
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
|
250
|
+
if (size + buf.length >= max) {
|
|
251
|
+
chunks.push(buf.subarray(0, max - size));
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
chunks.push(buf);
|
|
255
|
+
size += buf.length;
|
|
256
|
+
}
|
|
257
|
+
return Buffer.concat(chunks);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function nodeTransport(policy: SafeFetchPolicy, resolve: SafeLookup): SafeTransport {
|
|
261
|
+
return (url, init, signal) => {
|
|
262
|
+
const lookup: LookupFunction = (hostname, options, callback) => {
|
|
263
|
+
checkedAddress(hostname, policy, resolve).then(
|
|
264
|
+
(first) => {
|
|
265
|
+
if (options.all) callback(null, [first]);
|
|
266
|
+
else callback(null, first.address, first.family);
|
|
267
|
+
},
|
|
268
|
+
(e: Error) => callback(e as NodeJS.ErrnoException, '', 0)
|
|
269
|
+
);
|
|
270
|
+
};
|
|
271
|
+
const request = url.protocol === 'https:' ? https.request : http.request;
|
|
272
|
+
return new Promise((done, fail) => {
|
|
273
|
+
const req = request(
|
|
274
|
+
url,
|
|
275
|
+
{ method: init.method, headers: init.headers, lookup, signal, agent: false },
|
|
276
|
+
(res) => done({ status: res.statusCode ?? 0, headers: res.headers, body: res })
|
|
277
|
+
);
|
|
278
|
+
req.on('error', fail);
|
|
279
|
+
req.end(init.body);
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* One POST, no redirect followed (a 3xx comes back as its status), the address
|
|
286
|
+
* check inside the socket's own lookup (ALL resolved addresses must be public
|
|
287
|
+
* unless `allowPrivateNetworks`; an empty resolution is `unresolvable`),
|
|
288
|
+
* `AbortSignal.timeout(timeoutMs)` over the whole call, the response body read
|
|
289
|
+
* up to `maxResponseBytes` then the stream destroyed. Rejects with
|
|
290
|
+
* `WebhookUrlRefusedError` on a refused address, with the transport's error
|
|
291
|
+
* (ECONNREFUSED, the timeout's AbortError…) otherwise. Sets no `user-agent`:
|
|
292
|
+
* that is the caller's.
|
|
293
|
+
*/
|
|
294
|
+
export async function safePost(
|
|
295
|
+
url: string,
|
|
296
|
+
headers: Record<string, string>,
|
|
297
|
+
body: string,
|
|
298
|
+
policy: SafeFetchPolicy,
|
|
299
|
+
options: SafeFetchOptions = {}
|
|
300
|
+
): Promise<SafeFetchResult> {
|
|
301
|
+
const started = performance.now();
|
|
302
|
+
const target = checkWebhookUrl(url, policy);
|
|
303
|
+
const lookup: SafeLookup = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
|
|
304
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
305
|
+
const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
306
|
+
|
|
307
|
+
const sent: Record<string, string> = {};
|
|
308
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
309
|
+
if (name.toLowerCase() !== 'content-length') sent[name] = value;
|
|
310
|
+
}
|
|
311
|
+
sent['content-length'] = String(Buffer.byteLength(body, 'utf8'));
|
|
312
|
+
const init = { method: 'POST' as const, headers: sent, body };
|
|
313
|
+
|
|
314
|
+
let transport: SafeTransport;
|
|
315
|
+
if (options.transport) {
|
|
316
|
+
// The test seam: the same check the socket's lookup makes, made first.
|
|
317
|
+
await abortable(checkedAddress(target.hostname, policy, lookup), signal);
|
|
318
|
+
transport = options.transport;
|
|
319
|
+
} else {
|
|
320
|
+
transport = nodeTransport(policy, lookup);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const res = await abortable(transport(target, init, signal), signal);
|
|
324
|
+
try {
|
|
325
|
+
addAbortSignal(signal, res.body);
|
|
326
|
+
const bytes = await readCapped(res.body, maxBytes);
|
|
327
|
+
return {
|
|
328
|
+
status: res.status,
|
|
329
|
+
bodyText: bytes.toString('utf8'),
|
|
330
|
+
durationMs: Math.round(performance.now() - started)
|
|
331
|
+
};
|
|
332
|
+
} finally {
|
|
333
|
+
res.body.destroy();
|
|
334
|
+
}
|
|
335
|
+
}
|