@hasna/events 0.1.16 → 0.1.18
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/README.md +28 -3
- package/dist/app-event.js +13 -13
- package/dist/catalog.js +12 -12
- package/dist/cli/index.js +552 -54
- package/dist/commander.js +490 -25
- package/dist/durable.js +520 -37
- package/dist/filter.js +2 -2
- package/dist/index.js +539 -80
- package/dist/signing.js +5 -5
- package/dist/ssrf.js +222 -0
- package/dist/storage.js +126 -28
- package/dist/transports.js +359 -5
- package/package.json +7 -3
- package/types/app-home.d.ts +42 -0
- package/types/cli-webhook-policy.d.ts +8 -0
- package/types/durable.d.ts +2 -0
- package/types/index.d.ts +1 -0
- package/types/ssrf.d.ts +59 -0
- package/types/storage.d.ts +2 -2
- package/types/transports.d.ts +23 -0
package/dist/transports.js
CHANGED
|
@@ -33,9 +33,226 @@ function verifyWebhookSignature(secret, timestamp, body, signature, options = {}
|
|
|
33
33
|
return verifyPayloadSignature(secret, timestamp, body, signature);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// src/ssrf.ts
|
|
37
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
38
|
+
import { isIP } from "net";
|
|
39
|
+
var DEFAULT_MAX_REDIRECTS = 5;
|
|
40
|
+
var IPV4_PRIVATE_RANGES = [
|
|
41
|
+
[0, 16777215],
|
|
42
|
+
[167772160, 184549375],
|
|
43
|
+
[1681915904, 1686110207],
|
|
44
|
+
[2130706432, 2147483647],
|
|
45
|
+
[2851995648, 2852061183],
|
|
46
|
+
[2886729728, 2887778303],
|
|
47
|
+
[3221225472, 3221225727],
|
|
48
|
+
[3221225984, 3221226239],
|
|
49
|
+
[3227017984, 3227018239],
|
|
50
|
+
[3232235520, 3232301055],
|
|
51
|
+
[3323068416, 3323199487],
|
|
52
|
+
[3325256704, 3325256959],
|
|
53
|
+
[3405803776, 3405804031],
|
|
54
|
+
[3758096384, 4294967295]
|
|
55
|
+
];
|
|
56
|
+
var IPV6_SPECIAL_PREFIXES = [
|
|
57
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
|
|
58
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
|
|
59
|
+
{ groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
|
|
60
|
+
{ groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
|
|
61
|
+
{ groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
|
|
62
|
+
{ groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
63
|
+
{ groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
|
|
64
|
+
{ groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
|
|
65
|
+
{ groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
66
|
+
{ groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
|
|
67
|
+
{ groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
|
|
68
|
+
{ groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
|
|
69
|
+
{ groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
70
|
+
{ groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
71
|
+
{ groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
|
|
72
|
+
];
|
|
73
|
+
function isPrivateAddress(address) {
|
|
74
|
+
const normalized = stripZoneId(address);
|
|
75
|
+
const version = isIP(normalized);
|
|
76
|
+
if (version === 4) {
|
|
77
|
+
const integer = ipv4ToInt(normalized);
|
|
78
|
+
if (integer === undefined)
|
|
79
|
+
return true;
|
|
80
|
+
return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
|
|
81
|
+
}
|
|
82
|
+
if (version === 6) {
|
|
83
|
+
const groups = ipv6Groups(normalized);
|
|
84
|
+
if (!groups)
|
|
85
|
+
return true;
|
|
86
|
+
for (const prefix of IPV6_SPECIAL_PREFIXES) {
|
|
87
|
+
if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
|
|
88
|
+
continue;
|
|
89
|
+
if (prefix.bits === 96 && groups[5] === 65535) {
|
|
90
|
+
return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
|
|
91
|
+
}
|
|
92
|
+
if (prefix.bits === 16 && groups[0] === 8194) {
|
|
93
|
+
return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
async function resolveWebhookTarget(url, policy = {}) {
|
|
102
|
+
const hostname = normalizeHostname(url.hostname);
|
|
103
|
+
const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
|
|
104
|
+
if (allowlist.includes(hostname)) {
|
|
105
|
+
const version2 = isIP(hostname);
|
|
106
|
+
if (version2 === 4 || version2 === 6) {
|
|
107
|
+
return { hostname, addresses: [hostname] };
|
|
108
|
+
}
|
|
109
|
+
const lookup2 = policy.lookup ?? defaultTargetLookup;
|
|
110
|
+
let resolved2;
|
|
111
|
+
try {
|
|
112
|
+
resolved2 = await lookup2(hostname);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(resolved2) || resolved2.length === 0) {
|
|
117
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
118
|
+
}
|
|
119
|
+
const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
|
|
120
|
+
return { hostname, addresses };
|
|
121
|
+
}
|
|
122
|
+
const version = isIP(hostname);
|
|
123
|
+
if (version === 4 || version === 6) {
|
|
124
|
+
if (isPrivateAddress(hostname)) {
|
|
125
|
+
throw new Error(`Webhook target ${hostname} is a private or special-use address`);
|
|
126
|
+
}
|
|
127
|
+
return { hostname, addresses: [hostname] };
|
|
128
|
+
}
|
|
129
|
+
const lookup = policy.lookup ?? defaultTargetLookup;
|
|
130
|
+
let resolved;
|
|
131
|
+
try {
|
|
132
|
+
resolved = await lookup(hostname);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(resolved) || resolved.length === 0) {
|
|
137
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
138
|
+
}
|
|
139
|
+
const allowed = [];
|
|
140
|
+
for (const entry of resolved) {
|
|
141
|
+
const address = normalizeHostname(entry.address);
|
|
142
|
+
if (isPrivateAddress(address)) {
|
|
143
|
+
if (allowlist.includes(address)) {
|
|
144
|
+
allowed.push(address);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
|
|
148
|
+
}
|
|
149
|
+
allowed.push(address);
|
|
150
|
+
}
|
|
151
|
+
if (allowed.length === 0) {
|
|
152
|
+
throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
|
|
153
|
+
}
|
|
154
|
+
return { hostname, addresses: allowed };
|
|
155
|
+
}
|
|
156
|
+
async function assertWebhookTargetAllowed(url, policy = {}) {
|
|
157
|
+
await resolveWebhookTarget(url, policy);
|
|
158
|
+
}
|
|
159
|
+
function normalizeMaxRedirects(value) {
|
|
160
|
+
if (value === undefined)
|
|
161
|
+
return DEFAULT_MAX_REDIRECTS;
|
|
162
|
+
if (!Number.isInteger(value) || value < 0)
|
|
163
|
+
throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
var defaultTargetLookup = async (hostname) => {
|
|
167
|
+
return dnsLookup(hostname, { all: true, verbatim: false });
|
|
168
|
+
};
|
|
169
|
+
function normalizeHostname(hostname) {
|
|
170
|
+
const lower = hostname.toLowerCase();
|
|
171
|
+
if (lower.startsWith("[") && lower.endsWith("]"))
|
|
172
|
+
return lower.slice(1, -1);
|
|
173
|
+
return lower;
|
|
174
|
+
}
|
|
175
|
+
function stripZoneId(address) {
|
|
176
|
+
const percent = address.indexOf("%");
|
|
177
|
+
return percent === -1 ? address : address.slice(0, percent);
|
|
178
|
+
}
|
|
179
|
+
function ipv4ToInt(address) {
|
|
180
|
+
const parts = address.split(".");
|
|
181
|
+
if (parts.length !== 4)
|
|
182
|
+
return;
|
|
183
|
+
let value = 0;
|
|
184
|
+
for (const part of parts) {
|
|
185
|
+
if (!/^\d{1,3}$/.test(part))
|
|
186
|
+
return;
|
|
187
|
+
const octet = Number(part);
|
|
188
|
+
if (octet > 255)
|
|
189
|
+
return;
|
|
190
|
+
value = value << 8 | octet;
|
|
191
|
+
}
|
|
192
|
+
return value >>> 0;
|
|
193
|
+
}
|
|
194
|
+
function ipv4IntToString(integer) {
|
|
195
|
+
return [
|
|
196
|
+
integer >>> 24 & 255,
|
|
197
|
+
integer >>> 16 & 255,
|
|
198
|
+
integer >>> 8 & 255,
|
|
199
|
+
integer & 255
|
|
200
|
+
].join(".");
|
|
201
|
+
}
|
|
202
|
+
function ipv6Groups(address) {
|
|
203
|
+
const raw = stripZoneId(address);
|
|
204
|
+
const doubleColon = raw.indexOf("::");
|
|
205
|
+
const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
|
|
206
|
+
const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
|
|
207
|
+
const parseGroups = (text) => {
|
|
208
|
+
if (text === "")
|
|
209
|
+
return [];
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const part of text.split(":")) {
|
|
212
|
+
if (part.includes(".")) {
|
|
213
|
+
const v4 = ipv4ToInt(part);
|
|
214
|
+
if (v4 === undefined)
|
|
215
|
+
return;
|
|
216
|
+
out.push(v4 >>> 16 & 65535, v4 & 65535);
|
|
217
|
+
} else {
|
|
218
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part))
|
|
219
|
+
return;
|
|
220
|
+
out.push(parseInt(part, 16));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
};
|
|
225
|
+
const head = parseGroups(headText);
|
|
226
|
+
if (!head)
|
|
227
|
+
return;
|
|
228
|
+
const tail = parseGroups(tailText);
|
|
229
|
+
if (!tail)
|
|
230
|
+
return;
|
|
231
|
+
const total = head.length + tail.length;
|
|
232
|
+
if (doubleColon === -1) {
|
|
233
|
+
return total === 8 ? head : undefined;
|
|
234
|
+
}
|
|
235
|
+
if (total >= 8)
|
|
236
|
+
return;
|
|
237
|
+
return [...head, ...new Array(8 - total).fill(0), ...tail];
|
|
238
|
+
}
|
|
239
|
+
function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
|
|
240
|
+
let remaining = prefixBits;
|
|
241
|
+
for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
|
|
242
|
+
const take = Math.min(16, remaining);
|
|
243
|
+
const mask = 65535 << 16 - take & 65535;
|
|
244
|
+
if ((groups[index] & mask) !== (prefixGroups[index] & mask))
|
|
245
|
+
return false;
|
|
246
|
+
remaining -= take;
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
36
251
|
// src/transports.ts
|
|
37
252
|
import { randomUUID } from "crypto";
|
|
38
253
|
import { spawn } from "child_process";
|
|
254
|
+
import { request as nodeHttpRequest } from "http";
|
|
255
|
+
import { request as nodeHttpsRequest } from "https";
|
|
39
256
|
function now() {
|
|
40
257
|
return new Date().toISOString();
|
|
41
258
|
}
|
|
@@ -66,9 +283,18 @@ function buildWebhookRequest(event, channel, options = {}) {
|
|
|
66
283
|
}
|
|
67
284
|
return { body, headers };
|
|
68
285
|
}
|
|
286
|
+
function normalizeWebhookUrl(raw) {
|
|
287
|
+
const url = new URL(raw);
|
|
288
|
+
if (url.username !== "" || url.password !== "") {
|
|
289
|
+
url.username = "";
|
|
290
|
+
url.password = "";
|
|
291
|
+
}
|
|
292
|
+
return url.toString();
|
|
293
|
+
}
|
|
69
294
|
async function dispatchWebhook(event, channel, options = {}) {
|
|
70
295
|
if (!channel.webhook)
|
|
71
296
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
297
|
+
const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
|
|
72
298
|
const startedAt = now();
|
|
73
299
|
let secret = channel.webhook.secret;
|
|
74
300
|
if (channel.webhook.secretRef) {
|
|
@@ -85,10 +311,14 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
85
311
|
}
|
|
86
312
|
const timestamp = (options.now?.() ?? new Date).toISOString();
|
|
87
313
|
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
|
|
314
|
+
const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
|
|
315
|
+
if (validateTargets) {
|
|
316
|
+
return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
|
|
317
|
+
}
|
|
88
318
|
const controller = new AbortController;
|
|
89
319
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
90
320
|
try {
|
|
91
|
-
const response = await (options.fetchImpl ?? fetch)(
|
|
321
|
+
const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
|
|
92
322
|
method: "POST",
|
|
93
323
|
headers,
|
|
94
324
|
body,
|
|
@@ -116,6 +346,130 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
116
346
|
clearTimeout(timeout);
|
|
117
347
|
}
|
|
118
348
|
}
|
|
349
|
+
function isRedirectStatus(status) {
|
|
350
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
351
|
+
}
|
|
352
|
+
function redirectKeepsBody(status) {
|
|
353
|
+
return status === 307 || status === 308;
|
|
354
|
+
}
|
|
355
|
+
async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
|
|
356
|
+
const isHttps = target.protocol === "https:";
|
|
357
|
+
if (!isHttps && target.protocol !== "http:") {
|
|
358
|
+
throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
|
|
359
|
+
}
|
|
360
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
361
|
+
const port = target.port ? Number(target.port) : defaultPort;
|
|
362
|
+
const requestOptions = {
|
|
363
|
+
hostname: target.hostname,
|
|
364
|
+
port,
|
|
365
|
+
path: `${target.pathname}${target.search}`,
|
|
366
|
+
method,
|
|
367
|
+
headers,
|
|
368
|
+
...tls?.ca ? { ca: tls.ca } : {},
|
|
369
|
+
lookup: (hostname, _options, callback) => {
|
|
370
|
+
const entries = addresses.map((address) => ({
|
|
371
|
+
address,
|
|
372
|
+
family: address.includes(":") ? 6 : 4
|
|
373
|
+
}));
|
|
374
|
+
callback(null, entries);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
|
|
379
|
+
const onAbort = () => {
|
|
380
|
+
const error = new Error("The operation was aborted.");
|
|
381
|
+
error.name = "AbortError";
|
|
382
|
+
request.destroy(error);
|
|
383
|
+
};
|
|
384
|
+
if (signal.aborted)
|
|
385
|
+
onAbort();
|
|
386
|
+
else
|
|
387
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
388
|
+
request.on("error", reject);
|
|
389
|
+
if (body !== undefined)
|
|
390
|
+
request.write(body);
|
|
391
|
+
request.end();
|
|
392
|
+
function onResponse(response) {
|
|
393
|
+
const chunks = [];
|
|
394
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
395
|
+
response.on("error", reject);
|
|
396
|
+
response.on("end", () => {
|
|
397
|
+
const headersRecord = {};
|
|
398
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
399
|
+
if (typeof value === "string")
|
|
400
|
+
headersRecord[name] = value;
|
|
401
|
+
else if (Array.isArray(value))
|
|
402
|
+
headersRecord[name] = value.join(", ");
|
|
403
|
+
}
|
|
404
|
+
resolve(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
async function dispatchValidatedWebhook(event, channel, input) {
|
|
410
|
+
const { body, headers, startedAt, options } = input;
|
|
411
|
+
const webhook = channel.webhook;
|
|
412
|
+
if (!webhook)
|
|
413
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
414
|
+
const policy = options.webhookTargetPolicy ?? {};
|
|
415
|
+
const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
|
|
416
|
+
const controller = new AbortController;
|
|
417
|
+
const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
|
|
418
|
+
try {
|
|
419
|
+
let target = new URL(normalizeWebhookUrl(webhook.url));
|
|
420
|
+
let requestHeaders = headers;
|
|
421
|
+
let method = "POST";
|
|
422
|
+
let requestBody = body;
|
|
423
|
+
let redirectsFollowed = 0;
|
|
424
|
+
for (;; ) {
|
|
425
|
+
const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
|
|
426
|
+
throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
|
|
427
|
+
});
|
|
428
|
+
const response = options.fetchImpl ? await options.fetchImpl(target, {
|
|
429
|
+
method,
|
|
430
|
+
headers: requestHeaders,
|
|
431
|
+
body: requestBody,
|
|
432
|
+
signal: controller.signal,
|
|
433
|
+
redirect: "manual"
|
|
434
|
+
}) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
|
|
435
|
+
const location = response.headers.get("location");
|
|
436
|
+
if (isRedirectStatus(response.status) && location) {
|
|
437
|
+
if (redirectsFollowed >= maxRedirects) {
|
|
438
|
+
return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
|
|
439
|
+
}
|
|
440
|
+
redirectsFollowed += 1;
|
|
441
|
+
const next = new URL(location, target);
|
|
442
|
+
target = next;
|
|
443
|
+
if (!redirectKeepsBody(response.status)) {
|
|
444
|
+
method = "GET";
|
|
445
|
+
requestBody = undefined;
|
|
446
|
+
requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
|
|
447
|
+
}
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const responseBody = truncate(await response.text());
|
|
451
|
+
return {
|
|
452
|
+
attempt: 1,
|
|
453
|
+
status: response.ok ? "success" : "failed",
|
|
454
|
+
startedAt,
|
|
455
|
+
completedAt: now(),
|
|
456
|
+
responseStatus: response.status,
|
|
457
|
+
responseBody,
|
|
458
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return {
|
|
463
|
+
attempt: 1,
|
|
464
|
+
status: "failed",
|
|
465
|
+
startedAt,
|
|
466
|
+
completedAt: now(),
|
|
467
|
+
error: error instanceof Error ? error.message : String(error)
|
|
468
|
+
};
|
|
469
|
+
} finally {
|
|
470
|
+
clearTimeout(timeout);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
119
473
|
function failedAttempt(startedAt, error) {
|
|
120
474
|
return {
|
|
121
475
|
attempt: 1,
|
|
@@ -214,9 +568,9 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
214
568
|
};
|
|
215
569
|
}
|
|
216
570
|
export {
|
|
217
|
-
|
|
218
|
-
createDeliveryResult,
|
|
219
|
-
dispatchChannel,
|
|
571
|
+
dispatchWebhook,
|
|
220
572
|
dispatchCommand,
|
|
221
|
-
|
|
573
|
+
dispatchChannel,
|
|
574
|
+
createDeliveryResult,
|
|
575
|
+
buildWebhookRequest
|
|
222
576
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/events",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "Shared event envelopes, local channels, replay, and delivery transports for Hasna open-source apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"types": "./types/index.d.ts",
|
|
15
15
|
"import": "./dist/index.js"
|
|
16
16
|
},
|
|
17
|
+
"./sdk": {
|
|
18
|
+
"types": "./types/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
17
21
|
"./storage": {
|
|
18
22
|
"types": "./types/storage.d.ts",
|
|
19
23
|
"import": "./dist/storage.js"
|
|
@@ -72,7 +76,7 @@
|
|
|
72
76
|
],
|
|
73
77
|
"scripts": {
|
|
74
78
|
"build": "bun run build:runtime && bun run build:types",
|
|
75
|
-
"build:runtime": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts src/signing.ts src/filter.ts src/transports.ts src/types.ts src/commander.ts src/catalog.ts src/app-event.ts src/durable.ts src/durable-worker.ts --root src --outdir dist --target bun && bun build src/durable-spool.ts --root src --outdir dist --target node",
|
|
79
|
+
"build:runtime": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts src/signing.ts src/filter.ts src/transports.ts src/ssrf.ts src/types.ts src/commander.ts src/catalog.ts src/app-event.ts src/durable.ts src/durable-worker.ts --root src --outdir dist --target bun && bun build src/durable-spool.ts --root src --outdir dist --target node",
|
|
76
80
|
"build:types": "rm -rf types && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir types",
|
|
77
81
|
"generated-artifacts:check": "bun run build && test -z \"$(git status --porcelain --untracked-files=all -- dist types)\"",
|
|
78
82
|
"contract:check": "contracts repo-conformance .",
|
|
@@ -113,7 +117,7 @@
|
|
|
113
117
|
},
|
|
114
118
|
"devDependencies": {
|
|
115
119
|
"@hasna/contracts": "0.8.5",
|
|
116
|
-
"@types/bun": "
|
|
120
|
+
"@types/bun": "1.3.14",
|
|
117
121
|
"typescript": "^5.7.3"
|
|
118
122
|
}
|
|
119
123
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type PathKind = "config" | "data" | "state" | "cache";
|
|
2
|
+
export interface PathsResolverOptions {
|
|
3
|
+
app: string;
|
|
4
|
+
internal?: boolean;
|
|
5
|
+
platform?: string;
|
|
6
|
+
home?: string;
|
|
7
|
+
env?: Record<string, string | undefined>;
|
|
8
|
+
}
|
|
9
|
+
export declare function dataDir(options: PathsResolverOptions): string;
|
|
10
|
+
/** Env var names for the exact-app data-home overrides (preserved, highest precedence). */
|
|
11
|
+
export declare const HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
12
|
+
export declare const HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
13
|
+
/** The primary store file — its existence at a home marks the store as physically located there. */
|
|
14
|
+
export declare const EVENTS_STORE_SENTINEL_FILE = "events.json";
|
|
15
|
+
/** The effective user home, mirroring the pre-existing events resolution (`HOME` || `USERPROFILE` || `os.homedir()`). */
|
|
16
|
+
export declare function effectiveHome(): string;
|
|
17
|
+
/** Pre-XDG default home: ~/.hasna/events. */
|
|
18
|
+
export declare function legacyHomeDir(): string;
|
|
19
|
+
/**
|
|
20
|
+
* The @hasna/paths-resolved data home for events (XDG / macOS home layout).
|
|
21
|
+
* The `home` is injected so the resolver follows the same home the legacy path
|
|
22
|
+
* does (`$HOME`-first, matching the pre-existing resolution).
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolverHome(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the resolver (XDG) home should be adopted as the store home. The
|
|
27
|
+
* resolver home is adopted only when the operator has set `HASNA_DATA_HOME`
|
|
28
|
+
* (the data-kind override — a deliberate opt-in to the XDG layout) or the
|
|
29
|
+
* store has already been physically migrated there (`events.json` exists). A
|
|
30
|
+
* machine that only redirects another kind must NOT have its data home moved,
|
|
31
|
+
* and a live store at the legacy home must never become invisible on upgrade.
|
|
32
|
+
*/
|
|
33
|
+
export declare function adoptResolverHome(resolved: string, env?: NodeJS.ProcessEnv): boolean;
|
|
34
|
+
/** The exact-app override root (`HASNA_EVENTS_DIR` wins over `HASNA_EVENTS_HOME`), when set. Empty values are treated as unset. */
|
|
35
|
+
export declare function exactEventsHome(): string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Effective events data home: an exact-app override (`HASNA_EVENTS_DIR`, then
|
|
38
|
+
* the legacy `HASNA_EVENTS_HOME` fallback) wins unconditionally; otherwise the
|
|
39
|
+
* resolver (XDG) data home once adopted; otherwise the legacy `~/.hasna/events`
|
|
40
|
+
* default.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getEventsHome(): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI-only administrator opt-in for intentional private webhook ingress.
|
|
3
|
+
* The transport still requires an exact hostname/IP allowlist match and pins
|
|
4
|
+
* the validated address. SDK clients do not read this setting automatically.
|
|
5
|
+
*/
|
|
6
|
+
export declare function webhookTargetPolicyFromEnv(): {
|
|
7
|
+
allowPrivateHosts: string[];
|
|
8
|
+
} | undefined;
|
package/types/durable.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface DurableSpoolImportResult {
|
|
|
35
35
|
imported: number;
|
|
36
36
|
deduped: number;
|
|
37
37
|
queued: number;
|
|
38
|
+
/** Records quarantined because they were malformed or identity-mismatched. */
|
|
39
|
+
quarantined: number;
|
|
38
40
|
}
|
|
39
41
|
export interface DurableRetryDeadOptions {
|
|
40
42
|
eventId?: string;
|
package/types/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./storage.js";
|
|
|
7
7
|
export * from "./filter.js";
|
|
8
8
|
export * from "./signing.js";
|
|
9
9
|
export * from "./transports.js";
|
|
10
|
+
export * from "./ssrf.js";
|
|
10
11
|
export * from "./catalog.js";
|
|
11
12
|
export * from "./app-event.js";
|
|
12
13
|
export { redactPaths, redactSensitiveKeys } from "./redaction.js";
|
package/types/ssrf.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook-target SSRF guard for the durable delivery transport.
|
|
3
|
+
*
|
|
4
|
+
* The durable webhook transport must default-deny private and special-use
|
|
5
|
+
* targets (IPv4/IPv6), refuse redirects that would reach a private target,
|
|
6
|
+
* and prevent a DNS-rebinding window between validation and connection. The
|
|
7
|
+
* connection is pinned to the validated address at the transport level (the
|
|
8
|
+
* original URL hostname is preserved for TLS SNI and hostname verification,
|
|
9
|
+
* so a certificate issued for the hostname verifies normally).
|
|
10
|
+
*
|
|
11
|
+
* A narrow, admin-controlled allowlist (`allowPrivateHosts`) permits
|
|
12
|
+
* intentional private ingress such as a loopback receiver on the same machine.
|
|
13
|
+
* Allowlisted hostnames are still resolved and pinned, so the rebinding window
|
|
14
|
+
* stays closed for them as well.
|
|
15
|
+
*/
|
|
16
|
+
export interface LookupAddress {
|
|
17
|
+
address: string;
|
|
18
|
+
family: number;
|
|
19
|
+
}
|
|
20
|
+
export type TargetLookup = (hostname: string) => Promise<LookupAddress[]>;
|
|
21
|
+
export interface WebhookTargetPolicy {
|
|
22
|
+
/**
|
|
23
|
+
* Admin-controlled allowlist of private hostnames or IP addresses that
|
|
24
|
+
* intentional private webhook ingress may target (for example a loopback
|
|
25
|
+
* receiver on the same machine). Exact match only, case-insensitive for
|
|
26
|
+
* hostnames. Defaults to none.
|
|
27
|
+
*/
|
|
28
|
+
allowPrivateHosts?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Maximum redirect hops followed. Every hop is revalidated against the same
|
|
31
|
+
* policy. Defaults to 5.
|
|
32
|
+
*/
|
|
33
|
+
maxRedirects?: number;
|
|
34
|
+
/** Injectable hostname resolver, used by tests. Defaults to dns.promises.lookup. */
|
|
35
|
+
lookup?: TargetLookup;
|
|
36
|
+
}
|
|
37
|
+
export declare const DEFAULT_MAX_REDIRECTS = 5;
|
|
38
|
+
export interface ResolvedWebhookTarget {
|
|
39
|
+
hostname: string;
|
|
40
|
+
/** Validated public addresses the connection may be pinned to. */
|
|
41
|
+
addresses: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when the address is a private, loopback, link-local, multicast, or
|
|
45
|
+
* otherwise special-use address that a webhook must not reach by default.
|
|
46
|
+
* Unparsable or non-IP input fails closed (treated as private).
|
|
47
|
+
*/
|
|
48
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves and validates a webhook target. Returns the validated public
|
|
51
|
+
* addresses (which the caller pins the connection to), or throws with a
|
|
52
|
+
* bounded reason when the target is private, unresolvable, empty, or mixed
|
|
53
|
+
* with a private answer. The narrow admin allowlist admits exact private
|
|
54
|
+
* hostnames and addresses.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveWebhookTarget(url: URL, policy?: WebhookTargetPolicy): Promise<ResolvedWebhookTarget>;
|
|
57
|
+
/** Validates a webhook target URL against the SSRF policy, throwing on rejection. */
|
|
58
|
+
export declare function assertWebhookTargetAllowed(url: URL, policy?: WebhookTargetPolicy): Promise<void>;
|
|
59
|
+
export declare function normalizeMaxRedirects(value: number | undefined): number;
|
package/types/storage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { HASNA_EVENTS_DIR_ENV, HASNA_EVENTS_HOME_ENV } from "./app-home.js";
|
|
1
2
|
import type { ChannelConfig, DeliveryResult, EventAppendOptions, EventAppendResult, EventEnvelope, EventPage, EventPageOptions, EventsStatus, EventsStoreRuntime, StoredEventsData } from "./types.js";
|
|
2
|
-
export
|
|
3
|
-
export declare const HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
3
|
+
export { HASNA_EVENTS_DIR_ENV, HASNA_EVENTS_HOME_ENV };
|
|
4
4
|
export declare const LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
|
|
5
5
|
export declare const DEFAULT_EVENT_PAGE_LIMIT = 100;
|
|
6
6
|
export declare const MAX_EVENT_PAGE_LIMIT = 1000;
|
package/types/transports.d.ts
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import type { ChannelConfig, DeliveryAttempt, DeliveryResult, EventEnvelope } from "./types.js";
|
|
2
|
+
import { type WebhookTargetPolicy } from "./ssrf.js";
|
|
3
|
+
export interface TransportTlsOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Override the certificate authorities used by the pinned native transport
|
|
6
|
+
* (for example a private PKI or a self-signed test certificate). Ignored on
|
|
7
|
+
* an injected `fetchImpl` path, where the operator owns TLS. Defaults to the
|
|
8
|
+
* runtime's standard CA store.
|
|
9
|
+
*/
|
|
10
|
+
ca?: string | Buffer | Array<string | Buffer>;
|
|
11
|
+
}
|
|
2
12
|
export interface TransportDispatchOptions {
|
|
3
13
|
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
4
14
|
secretResolver?: WebhookSecretResolver;
|
|
5
15
|
now?: () => Date;
|
|
16
|
+
/**
|
|
17
|
+
* TLS options for the pinned native transport (the default, non-injected
|
|
18
|
+
* fetch path). `ca` overrides the trusted certificate authorities.
|
|
19
|
+
*/
|
|
20
|
+
tls?: TransportTlsOptions;
|
|
21
|
+
/**
|
|
22
|
+
* Webhook-target SSRF policy. When provided, the durable webhook transport
|
|
23
|
+
* validates every target (and every redirect hop) against it. When omitted,
|
|
24
|
+
* the guard still applies on the default `fetch` path and is deferred to an
|
|
25
|
+
* injected `fetchImpl` (the operator who injects a fetch implementation owns
|
|
26
|
+
* the network boundary).
|
|
27
|
+
*/
|
|
28
|
+
webhookTargetPolicy?: WebhookTargetPolicy;
|
|
6
29
|
}
|
|
7
30
|
export type WebhookSecretResolver = (reference: string) => string | undefined | Promise<string | undefined>;
|
|
8
31
|
export interface BuildWebhookRequestOptions {
|