@pylonsync/webhooks 0.3.332 → 0.3.334
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/package.json +3 -3
- package/src/handlers.ts +31 -12
- package/src/manifest.test.ts +25 -0
- package/src/manifest.ts +8 -2
- package/src/safe-request.test.ts +124 -0
- package/src/safe-request.ts +317 -0
- package/src/types.ts +12 -4
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.334",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"types": "src/index.ts",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@pylonsync/sdk": "0.3.
|
|
16
|
-
"@pylonsync/functions": "0.3.
|
|
15
|
+
"@pylonsync/sdk": "0.3.334",
|
|
16
|
+
"@pylonsync/functions": "0.3.334"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"bun-types": "*"
|
package/src/handlers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { action, mutation, query, v } from "@pylonsync/functions";
|
|
2
2
|
|
|
3
3
|
import { signWebhook } from "./signature";
|
|
4
|
+
import { postWebhookSafely } from "./safe-request";
|
|
4
5
|
import type { WebhookConfig } from "./types";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -137,30 +138,29 @@ export function internalHandlers(
|
|
|
137
138
|
secrets: [ep.secret],
|
|
138
139
|
});
|
|
139
140
|
|
|
140
|
-
const headers: Record<string, string> =
|
|
141
|
-
"Content-Type": "application/json",
|
|
142
|
-
"webhook-id": sig.id,
|
|
143
|
-
"webhook-timestamp": String(sig.timestamp),
|
|
144
|
-
"webhook-signature": sig.signature,
|
|
145
|
-
};
|
|
141
|
+
const headers: Record<string, string> = Object.create(null);
|
|
146
142
|
if (ep.headers) {
|
|
147
143
|
try {
|
|
148
144
|
const extra = JSON.parse(ep.headers) as Record<string, string>;
|
|
149
|
-
for (const [k, v] of Object.entries(extra))
|
|
145
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
146
|
+
if (typeof v === "string" && isAllowedCustomHeader(k, v)) {
|
|
147
|
+
headers[k] = v;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
150
|
} catch {
|
|
151
151
|
/* malformed headers — ignore */
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
+
headers["Content-Type"] = "application/json";
|
|
155
|
+
headers["webhook-id"] = sig.id;
|
|
156
|
+
headers["webhook-timestamp"] = String(sig.timestamp);
|
|
157
|
+
headers["webhook-signature"] = sig.signature;
|
|
154
158
|
|
|
155
159
|
let httpStatus = 0;
|
|
156
160
|
let errorMsg: string | undefined;
|
|
157
161
|
let ok = false;
|
|
158
162
|
try {
|
|
159
|
-
const res = await
|
|
160
|
-
method: "POST",
|
|
161
|
-
headers,
|
|
162
|
-
body,
|
|
163
|
-
});
|
|
163
|
+
const res = await postWebhookSafely(ep.url, headers, body);
|
|
164
164
|
httpStatus = res.status;
|
|
165
165
|
ok = res.ok;
|
|
166
166
|
if (!ok) errorMsg = `HTTP ${res.status}`;
|
|
@@ -213,3 +213,22 @@ export function internalHandlers(
|
|
|
213
213
|
}),
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
const RESERVED_HEADERS = new Set([
|
|
218
|
+
"connection",
|
|
219
|
+
"content-length",
|
|
220
|
+
"content-type",
|
|
221
|
+
"host",
|
|
222
|
+
"transfer-encoding",
|
|
223
|
+
"webhook-id",
|
|
224
|
+
"webhook-signature",
|
|
225
|
+
"webhook-timestamp",
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
function isAllowedCustomHeader(name: string, value: string): boolean {
|
|
229
|
+
return (
|
|
230
|
+
/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) &&
|
|
231
|
+
!RESERVED_HEADERS.has(name.toLowerCase()) &&
|
|
232
|
+
!/[\r\n]/.test(value)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildWebhookManifest } from "./manifest";
|
|
4
|
+
|
|
5
|
+
describe("webhook manifest credentials", () => {
|
|
6
|
+
test("always keeps signing secrets and custom headers server-only", () => {
|
|
7
|
+
const endpoint = buildWebhookManifest({}).entities[0];
|
|
8
|
+
|
|
9
|
+
expect(endpoint.fields.secret._def.serverOnly).toBe(true);
|
|
10
|
+
expect(endpoint.fields.headers._def.serverOnly).toBe(true);
|
|
11
|
+
expect(endpoint.fields.secret._def.encrypted).toBeUndefined();
|
|
12
|
+
expect(endpoint.fields.headers._def.encrypted).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("supports explicit at-rest encryption when the deployment has a key", () => {
|
|
16
|
+
const endpoint = buildWebhookManifest({
|
|
17
|
+
encryptCredentials: true,
|
|
18
|
+
}).entities[0];
|
|
19
|
+
|
|
20
|
+
expect(endpoint.fields.secret._def.serverOnly).toBe(true);
|
|
21
|
+
expect(endpoint.fields.headers._def.serverOnly).toBe(true);
|
|
22
|
+
expect(endpoint.fields.secret._def.encrypted).toBe(true);
|
|
23
|
+
expect(endpoint.fields.headers._def.encrypted).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/manifest.ts
CHANGED
|
@@ -18,13 +18,19 @@ export function buildWebhookManifest(
|
|
|
18
18
|
): WebhookManifestFragment {
|
|
19
19
|
const endpointName = cfg.entities?.endpoint ?? "WebhookEndpoint";
|
|
20
20
|
const attemptName = cfg.entities?.attempt ?? "WebhookAttempt";
|
|
21
|
+
const secretField = cfg.encryptCredentials
|
|
22
|
+
? field.string().serverOnly().encrypted()
|
|
23
|
+
: field.string().serverOnly();
|
|
24
|
+
const headersField = cfg.encryptCredentials
|
|
25
|
+
? field.string().serverOnly().encrypted().optional()
|
|
26
|
+
: field.string().serverOnly().optional();
|
|
21
27
|
|
|
22
28
|
const Endpoint = entity(endpointName, {
|
|
23
29
|
applicationId: field.string(),
|
|
24
30
|
url: field.string(),
|
|
25
|
-
secret:
|
|
31
|
+
secret: secretField,
|
|
26
32
|
eventTypes: field.string(), // JSON array
|
|
27
|
-
headers:
|
|
33
|
+
headers: headersField, // JSON
|
|
28
34
|
disabled: field.boolean().optional(),
|
|
29
35
|
createdAt: field.string(),
|
|
30
36
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertPublicDestination,
|
|
5
|
+
isBlockedIp,
|
|
6
|
+
postWebhookSafely,
|
|
7
|
+
} from "./safe-request";
|
|
8
|
+
|
|
9
|
+
const publicDns = async () => [{ address: "93.184.216.34", family: 4 }];
|
|
10
|
+
|
|
11
|
+
describe("webhook egress guard", () => {
|
|
12
|
+
test("blocks non-HTTPS URLs, embedded credentials, and private DNS results", async () => {
|
|
13
|
+
await expect(
|
|
14
|
+
assertPublicDestination(new URL("http://example.com/hook"), publicDns),
|
|
15
|
+
).rejects.toThrow("must use HTTPS");
|
|
16
|
+
await expect(
|
|
17
|
+
assertPublicDestination(new URL("https://user:pass@example.com/hook"), publicDns),
|
|
18
|
+
).rejects.toThrow("credentials are not allowed");
|
|
19
|
+
await expect(
|
|
20
|
+
assertPublicDestination(new URL("https://example.com/hook"), async () => [
|
|
21
|
+
{ address: "169.254.169.254", family: 4 },
|
|
22
|
+
]),
|
|
23
|
+
).rejects.toThrow("non-public address");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("blocks private, link-local, reserved, and mapped addresses", () => {
|
|
27
|
+
for (const address of [
|
|
28
|
+
"127.0.0.1",
|
|
29
|
+
"10.0.0.1",
|
|
30
|
+
"172.16.0.1",
|
|
31
|
+
"192.168.1.1",
|
|
32
|
+
"169.254.169.254",
|
|
33
|
+
"100.64.0.1",
|
|
34
|
+
"0.1.2.3",
|
|
35
|
+
"224.0.0.1",
|
|
36
|
+
"::1",
|
|
37
|
+
"fe80::1",
|
|
38
|
+
"fd12::1",
|
|
39
|
+
"::192.168.1.1",
|
|
40
|
+
"::ffff:169.254.169.254",
|
|
41
|
+
"2001:db8::1",
|
|
42
|
+
]) {
|
|
43
|
+
expect(isBlockedIp(address)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
expect(isBlockedIp("8.8.8.8")).toBe(false);
|
|
46
|
+
expect(isBlockedIp("2606:4700:4700::1111")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("revalidates same-origin redirect hops", async () => {
|
|
50
|
+
const seen: string[] = [];
|
|
51
|
+
let resolutions = 0;
|
|
52
|
+
const result = await postWebhookSafely(
|
|
53
|
+
"https://example.com/first",
|
|
54
|
+
{},
|
|
55
|
+
"{}",
|
|
56
|
+
{
|
|
57
|
+
resolve: async () => {
|
|
58
|
+
resolutions++;
|
|
59
|
+
return publicDns();
|
|
60
|
+
},
|
|
61
|
+
requestOnce: async (url) => {
|
|
62
|
+
seen.push(url.pathname);
|
|
63
|
+
return seen.length === 1
|
|
64
|
+
? { status: 307, location: "/second" }
|
|
65
|
+
: { status: 204 };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual({ status: 204, ok: true });
|
|
71
|
+
expect(seen).toEqual(["/first", "/second"]);
|
|
72
|
+
expect(resolutions).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rejects cross-origin and method-changing redirects", async () => {
|
|
76
|
+
await expect(
|
|
77
|
+
postWebhookSafely("https://example.com/hook", {}, "{}", {
|
|
78
|
+
resolve: publicDns,
|
|
79
|
+
requestOnce: async () => ({
|
|
80
|
+
status: 307,
|
|
81
|
+
location: "https://other.example/hook",
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
).rejects.toThrow("another origin");
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
postWebhookSafely("https://example.com/hook", {}, "{}", {
|
|
88
|
+
resolve: publicDns,
|
|
89
|
+
requestOnce: async () => ({ status: 302, location: "/other" }),
|
|
90
|
+
}),
|
|
91
|
+
).rejects.toThrow("unsafe redirect");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("caps redirect chains", async () => {
|
|
95
|
+
await expect(
|
|
96
|
+
postWebhookSafely("https://example.com/hook", {}, "{}", {
|
|
97
|
+
resolve: publicDns,
|
|
98
|
+
requestOnce: async () => ({ status: 307, location: "/again" }),
|
|
99
|
+
}),
|
|
100
|
+
).rejects.toThrow("redirect limit");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("aborts requests at the delivery deadline", async () => {
|
|
104
|
+
await expect(
|
|
105
|
+
postWebhookSafely("https://example.com/hook", {}, "{}", {
|
|
106
|
+
resolve: publicDns,
|
|
107
|
+
timeoutMs: 5,
|
|
108
|
+
requestOnce: async (_url, { signal }) =>
|
|
109
|
+
new Promise((_resolve, reject) => {
|
|
110
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")));
|
|
111
|
+
}),
|
|
112
|
+
}),
|
|
113
|
+
).rejects.toThrow("timed out");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("applies the delivery deadline while resolving DNS", async () => {
|
|
117
|
+
await expect(
|
|
118
|
+
postWebhookSafely("https://example.com/hook", {}, "{}", {
|
|
119
|
+
resolve: async () => new Promise(() => {}),
|
|
120
|
+
timeoutMs: 5,
|
|
121
|
+
}),
|
|
122
|
+
).rejects.toThrow("timed out");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { request } from "node:https";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6
|
+
const MAX_REDIRECTS = 3;
|
|
7
|
+
|
|
8
|
+
interface ResolvedAddress {
|
|
9
|
+
address: string;
|
|
10
|
+
family: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ResponseHead {
|
|
14
|
+
status: number;
|
|
15
|
+
location?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SafeRequestDependencies {
|
|
19
|
+
resolve?: (hostname: string) => Promise<ResolvedAddress[]>;
|
|
20
|
+
requestOnce?: (
|
|
21
|
+
url: URL,
|
|
22
|
+
init: { headers: Record<string, string>; body: string; signal: AbortSignal },
|
|
23
|
+
) => Promise<ResponseHead>;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST a webhook without allowing the destination to reach local services.
|
|
29
|
+
* Redirects stay on the original origin so credentials cannot be forwarded to
|
|
30
|
+
* a second host. Only 307/308 are followed because they preserve the signed
|
|
31
|
+
* POST body.
|
|
32
|
+
*/
|
|
33
|
+
export async function postWebhookSafely(
|
|
34
|
+
url: string,
|
|
35
|
+
headers: Record<string, string>,
|
|
36
|
+
body: string,
|
|
37
|
+
dependencies: SafeRequestDependencies = {},
|
|
38
|
+
): Promise<{ status: number; ok: boolean }> {
|
|
39
|
+
const resolve = dependencies.resolve ?? resolveHostname;
|
|
40
|
+
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
let target = parseDestination(url);
|
|
46
|
+
const originalOrigin = target.origin;
|
|
47
|
+
let redirects = 0;
|
|
48
|
+
|
|
49
|
+
for (;;) {
|
|
50
|
+
await assertPublicDestination(target, resolve, controller.signal);
|
|
51
|
+
const response = await (dependencies.requestOnce ?? requestOnce)(target, {
|
|
52
|
+
headers,
|
|
53
|
+
body,
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
|
58
|
+
return {
|
|
59
|
+
status: response.status,
|
|
60
|
+
ok: response.status >= 200 && response.status < 300,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (response.status !== 307 && response.status !== 308) {
|
|
64
|
+
throw new Error("Webhook destination returned an unsafe redirect");
|
|
65
|
+
}
|
|
66
|
+
if (!response.location) {
|
|
67
|
+
throw new Error("Webhook destination returned an invalid redirect");
|
|
68
|
+
}
|
|
69
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
70
|
+
throw new Error("Webhook destination exceeded the redirect limit");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const next = parseDestination(new URL(response.location, target).toString());
|
|
74
|
+
if (next.origin !== originalOrigin) {
|
|
75
|
+
throw new Error("Webhook destination redirected to another origin");
|
|
76
|
+
}
|
|
77
|
+
target = next;
|
|
78
|
+
redirects++;
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (controller.signal.aborted) {
|
|
82
|
+
throw new Error("Webhook delivery timed out");
|
|
83
|
+
}
|
|
84
|
+
if (error instanceof SafeDestinationError) throw new Error(error.message);
|
|
85
|
+
if (error instanceof Error && error.message.startsWith("Webhook destination")) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
throw new Error("Webhook delivery request failed");
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function assertPublicDestination(
|
|
95
|
+
target: URL,
|
|
96
|
+
resolve: (hostname: string) => Promise<ResolvedAddress[]> = resolveHostname,
|
|
97
|
+
signal?: AbortSignal,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
if (target.protocol !== "https:") {
|
|
100
|
+
throw new SafeDestinationError("Webhook destinations must use HTTPS");
|
|
101
|
+
}
|
|
102
|
+
if (target.username || target.password) {
|
|
103
|
+
throw new SafeDestinationError("Webhook destination credentials are not allowed");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let addresses: ResolvedAddress[];
|
|
107
|
+
try {
|
|
108
|
+
const resolution = resolve(stripIpv6Brackets(target.hostname));
|
|
109
|
+
addresses = signal ? await rejectOnAbort(resolution, signal) : await resolution;
|
|
110
|
+
} catch {
|
|
111
|
+
throw new SafeDestinationError("Webhook destination could not be resolved");
|
|
112
|
+
}
|
|
113
|
+
if (addresses.length === 0) {
|
|
114
|
+
throw new SafeDestinationError("Webhook destination could not be resolved");
|
|
115
|
+
}
|
|
116
|
+
if (addresses.some(({ address }) => isBlockedIp(address))) {
|
|
117
|
+
throw new SafeDestinationError("Webhook destination resolves to a non-public address");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rejectOnAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
122
|
+
if (signal.aborted) return Promise.reject(new Error("aborted"));
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const onAbort = () => reject(new Error("aborted"));
|
|
125
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
126
|
+
promise.then(
|
|
127
|
+
(value) => {
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
resolve(value);
|
|
130
|
+
},
|
|
131
|
+
(error) => {
|
|
132
|
+
signal.removeEventListener("abort", onAbort);
|
|
133
|
+
reject(error);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isBlockedIp(address: string): boolean {
|
|
140
|
+
const family = isIP(address);
|
|
141
|
+
if (family === 4) return isBlockedIpv4(address);
|
|
142
|
+
if (family !== 6) return true;
|
|
143
|
+
|
|
144
|
+
const bytes = parseIpv6(address);
|
|
145
|
+
if (!bytes) return true;
|
|
146
|
+
if (bytes.every((byte) => byte === 0)) return true; // unspecified
|
|
147
|
+
if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return true;
|
|
148
|
+
if ((bytes[0] & 0xfe) === 0xfc) return true; // unique-local fc00::/7
|
|
149
|
+
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return true; // link-local fe80::/10
|
|
150
|
+
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0xc0) return true; // deprecated site-local fec0::/10
|
|
151
|
+
if (bytes[0] === 0xff) return true; // multicast
|
|
152
|
+
if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) {
|
|
153
|
+
return true; // documentation 2001:db8::/32
|
|
154
|
+
}
|
|
155
|
+
if (bytes.slice(0, 12).every((byte) => byte === 0)) {
|
|
156
|
+
return isBlockedIpv4(bytes.slice(12).join(".")); // IPv4-compatible
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const mappedPrefix = bytes.slice(0, 10).every((byte) => byte === 0);
|
|
160
|
+
if (mappedPrefix && bytes[10] === 0xff && bytes[11] === 0xff) {
|
|
161
|
+
return isBlockedIpv4(bytes.slice(12).join("."));
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function resolveHostname(hostname: string): Promise<ResolvedAddress[]> {
|
|
167
|
+
if (isIP(hostname)) {
|
|
168
|
+
return [{ address: hostname, family: isIP(hostname) }];
|
|
169
|
+
}
|
|
170
|
+
return lookup(hostname, { all: true, verbatim: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseDestination(value: string): URL {
|
|
174
|
+
let target: URL;
|
|
175
|
+
try {
|
|
176
|
+
target = new URL(value);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new SafeDestinationError("Webhook destination URL is invalid");
|
|
179
|
+
}
|
|
180
|
+
if (target.protocol !== "https:") {
|
|
181
|
+
throw new SafeDestinationError("Webhook destinations must use HTTPS");
|
|
182
|
+
}
|
|
183
|
+
if (target.username || target.password) {
|
|
184
|
+
throw new SafeDestinationError("Webhook destination credentials are not allowed");
|
|
185
|
+
}
|
|
186
|
+
return target;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function requestOnce(
|
|
190
|
+
target: URL,
|
|
191
|
+
init: { headers: Record<string, string>; body: string; signal: AbortSignal },
|
|
192
|
+
): Promise<ResponseHead> {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const req = request(
|
|
195
|
+
target,
|
|
196
|
+
{
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: init.headers,
|
|
199
|
+
signal: init.signal,
|
|
200
|
+
lookup(hostname, _options, callback) {
|
|
201
|
+
resolveHostname(hostname)
|
|
202
|
+
.then((addresses) => {
|
|
203
|
+
if (
|
|
204
|
+
addresses.length === 0 ||
|
|
205
|
+
addresses.some(({ address }) => isBlockedIp(address))
|
|
206
|
+
) {
|
|
207
|
+
callback(new Error("unsafe destination"), "", 4);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const selected = addresses[0];
|
|
211
|
+
callback(null, selected.address, selected.family);
|
|
212
|
+
})
|
|
213
|
+
.catch(() =>
|
|
214
|
+
callback(new Error("destination lookup failed"), "", 4),
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
(response) => {
|
|
219
|
+
const head = {
|
|
220
|
+
status: response.statusCode ?? 0,
|
|
221
|
+
location: Array.isArray(response.headers.location)
|
|
222
|
+
? response.headers.location[0]
|
|
223
|
+
: response.headers.location,
|
|
224
|
+
};
|
|
225
|
+
// Delivery semantics depend only on the response head. Close the
|
|
226
|
+
// body immediately so an endpoint cannot retain a worker socket by
|
|
227
|
+
// streaming an endless response after returning a success status.
|
|
228
|
+
response.destroy();
|
|
229
|
+
resolve(head);
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
req.on("error", reject);
|
|
233
|
+
req.end(init.body);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isBlockedIpv4(address: string): boolean {
|
|
238
|
+
const octets = address.split(".").map(Number);
|
|
239
|
+
if (
|
|
240
|
+
octets.length !== 4 ||
|
|
241
|
+
octets.some(
|
|
242
|
+
(part) => !Number.isInteger(part) || part < 0 || part > 255,
|
|
243
|
+
)
|
|
244
|
+
) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
const [a, b, c] = octets;
|
|
248
|
+
return (
|
|
249
|
+
a === 0 ||
|
|
250
|
+
a === 10 ||
|
|
251
|
+
a === 127 ||
|
|
252
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
253
|
+
(a === 169 && b === 254) ||
|
|
254
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
255
|
+
(a === 192 && b === 0 && c === 0) ||
|
|
256
|
+
(a === 192 && b === 168) ||
|
|
257
|
+
(a === 192 && b === 0 && c === 2) ||
|
|
258
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
259
|
+
(a === 198 && b === 51 && c === 100) ||
|
|
260
|
+
(a === 203 && b === 0 && c === 113) ||
|
|
261
|
+
a >= 224
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseIpv6(address: string): number[] | null {
|
|
266
|
+
const normalized = stripIpv6Brackets(address).split("%")[0];
|
|
267
|
+
const halves = normalized.split("::");
|
|
268
|
+
if (halves.length > 2) return null;
|
|
269
|
+
const left = parseIpv6Half(halves[0]);
|
|
270
|
+
const right = halves.length === 2 ? parseIpv6Half(halves[1]) : [];
|
|
271
|
+
if (!left || !right) return null;
|
|
272
|
+
const missing = 8 - left.length - right.length;
|
|
273
|
+
if (
|
|
274
|
+
(halves.length === 1 && missing !== 0) ||
|
|
275
|
+
(halves.length === 2 && missing < 1)
|
|
276
|
+
) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const segments = [...left, ...Array(missing).fill(0), ...right];
|
|
280
|
+
if (segments.length !== 8) return null;
|
|
281
|
+
return segments.flatMap((segment) => [segment >> 8, segment & 0xff]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseIpv6Half(value: string): number[] | null {
|
|
285
|
+
if (!value) return [];
|
|
286
|
+
const parts = value.split(":");
|
|
287
|
+
const segments: number[] = [];
|
|
288
|
+
for (const part of parts) {
|
|
289
|
+
if (part.includes(".")) {
|
|
290
|
+
const octets = part.split(".").map(Number);
|
|
291
|
+
if (
|
|
292
|
+
octets.length !== 4 ||
|
|
293
|
+
octets.some(
|
|
294
|
+
(byte) => !Number.isInteger(byte) || byte < 0 || byte > 255,
|
|
295
|
+
)
|
|
296
|
+
) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
segments.push(
|
|
300
|
+
(octets[0] << 8) | octets[1],
|
|
301
|
+
(octets[2] << 8) | octets[3],
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
|
305
|
+
segments.push(Number.parseInt(part, 16));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return segments;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function stripIpv6Brackets(hostname: string): string {
|
|
312
|
+
return hostname.startsWith("[") && hostname.endsWith("]")
|
|
313
|
+
? hostname.slice(1, -1)
|
|
314
|
+
: hostname;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
class SafeDestinationError extends Error {}
|
package/src/types.ts
CHANGED
|
@@ -31,15 +31,15 @@ export interface WebhookEndpoint {
|
|
|
31
31
|
/** Logical bucket — usually the customer/org id. */
|
|
32
32
|
applicationId: string;
|
|
33
33
|
url: string;
|
|
34
|
-
/** Hex-encoded HMAC secret.
|
|
35
|
-
*
|
|
36
|
-
* is the secret-encryption layer apps wire when they want it. */
|
|
34
|
+
/** Hex-encoded HMAC secret. The plugin manifest always marks this field
|
|
35
|
+
* server-only and optionally encrypts it at rest. */
|
|
37
36
|
secret: string;
|
|
38
37
|
/** Subset of event types this endpoint subscribes to. Empty = all. */
|
|
39
38
|
eventTypes: string[];
|
|
40
39
|
/**
|
|
41
40
|
* Headers to attach on every delivery. Useful for routing /
|
|
42
|
-
* authentication beyond the HMAC signature.
|
|
41
|
+
* authentication beyond the HMAC signature. The plugin manifest always
|
|
42
|
+
* marks this field server-only and optionally encrypts it at rest.
|
|
43
43
|
*/
|
|
44
44
|
headers?: Record<string, string>;
|
|
45
45
|
/** Disabled endpoints don't receive deliveries. */
|
|
@@ -52,6 +52,14 @@ export interface WebhookConfig {
|
|
|
52
52
|
endpoint?: string;
|
|
53
53
|
attempt?: string;
|
|
54
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* Encrypt endpoint secrets and custom headers at rest. Requires a 32-byte
|
|
57
|
+
* `PYLON_ENCRYPTION_KEY`; endpoint writes fail with
|
|
58
|
+
* `ENCRYPTION_NOT_CONFIGURED` when the key is absent. Defaults to `false`
|
|
59
|
+
* so enabling the plugin does not break existing deployments. Credentials
|
|
60
|
+
* remain server-only regardless of this setting.
|
|
61
|
+
*/
|
|
62
|
+
encryptCredentials?: boolean;
|
|
55
63
|
/**
|
|
56
64
|
* Retry schedule (seconds offset from initial attempt). Default
|
|
57
65
|
* matches Svix's published schedule. After exhausting the
|