@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/durable.js
CHANGED
|
@@ -99,16 +99,114 @@ function channelMatchesEvent(channel, event) {
|
|
|
99
99
|
// src/storage.ts
|
|
100
100
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
101
101
|
import { Buffer as Buffer2 } from "buffer";
|
|
102
|
+
import { existsSync as existsSync2 } from "fs";
|
|
103
|
+
import { join as join2 } from "path";
|
|
104
|
+
|
|
105
|
+
// src/app-home.ts
|
|
102
106
|
import { existsSync } from "fs";
|
|
103
107
|
import { homedir } from "os";
|
|
104
|
-
import { join } from "path";
|
|
108
|
+
import { join, resolve } from "path";
|
|
109
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
110
|
+
import { join as pathsResolverJoin } from "path";
|
|
111
|
+
var PATHS_RESOLVER_KIND_ENV = {
|
|
112
|
+
config: "HASNA_CONFIG_HOME",
|
|
113
|
+
data: "HASNA_DATA_HOME",
|
|
114
|
+
state: "HASNA_STATE_HOME",
|
|
115
|
+
cache: "HASNA_CACHE_HOME"
|
|
116
|
+
};
|
|
117
|
+
var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
118
|
+
function pathsResolverAssertApp(app) {
|
|
119
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
120
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
121
|
+
}
|
|
122
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
123
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function pathsResolverAssertKind(kind) {
|
|
127
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
128
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function pathsResolverBaseDir(kind, options) {
|
|
132
|
+
pathsResolverAssertKind(kind);
|
|
133
|
+
const env = options.env ?? process.env;
|
|
134
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
135
|
+
if (typeof override === "string" && override.length > 0)
|
|
136
|
+
return override;
|
|
137
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
138
|
+
const platform = options.platform ?? process.platform;
|
|
139
|
+
if (platform === "darwin") {
|
|
140
|
+
switch (kind) {
|
|
141
|
+
case "config":
|
|
142
|
+
case "data":
|
|
143
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
144
|
+
case "cache":
|
|
145
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
146
|
+
case "state":
|
|
147
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
switch (kind) {
|
|
151
|
+
case "config":
|
|
152
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
153
|
+
case "data":
|
|
154
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
155
|
+
case "state":
|
|
156
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
157
|
+
case "cache":
|
|
158
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function pathsResolverResolve(kind, options) {
|
|
162
|
+
pathsResolverAssertApp(options.app);
|
|
163
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
164
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
165
|
+
}
|
|
166
|
+
function dataDir(options) {
|
|
167
|
+
return pathsResolverResolve("data", options);
|
|
168
|
+
}
|
|
105
169
|
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
106
170
|
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
171
|
+
var EVENTS_STORE_SENTINEL_FILE = "events.json";
|
|
172
|
+
function effectiveHome() {
|
|
173
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
|
|
174
|
+
}
|
|
175
|
+
function legacyHomeDir() {
|
|
176
|
+
return join(effectiveHome(), ".hasna", "events");
|
|
177
|
+
}
|
|
178
|
+
function resolverHome() {
|
|
179
|
+
return dataDir({ app: "events", home: effectiveHome() || undefined });
|
|
180
|
+
}
|
|
181
|
+
function adoptResolverHome(resolved, env = process.env) {
|
|
182
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
183
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
184
|
+
return true;
|
|
185
|
+
return existsSync(join(resolved, EVENTS_STORE_SENTINEL_FILE));
|
|
186
|
+
}
|
|
187
|
+
function exactEventsHome() {
|
|
188
|
+
const dir = process.env[HASNA_EVENTS_DIR_ENV];
|
|
189
|
+
if (dir && dir.trim())
|
|
190
|
+
return dir.trim();
|
|
191
|
+
const home = process.env[HASNA_EVENTS_HOME_ENV];
|
|
192
|
+
if (home && home.trim())
|
|
193
|
+
return home.trim();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
function getEventsHome() {
|
|
197
|
+
const exact = exactEventsHome();
|
|
198
|
+
if (exact)
|
|
199
|
+
return resolve(exact);
|
|
200
|
+
const resolved = resolverHome();
|
|
201
|
+
return adoptResolverHome(resolved) ? resolve(resolved) : resolve(legacyHomeDir());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/storage.ts
|
|
107
205
|
var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
|
|
108
206
|
var DEFAULT_EVENT_PAGE_LIMIT = 100;
|
|
109
207
|
var MAX_EVENT_PAGE_LIMIT = 1000;
|
|
110
208
|
function getEventsDataDir(override) {
|
|
111
|
-
return override ||
|
|
209
|
+
return override || getEventsHome();
|
|
112
210
|
}
|
|
113
211
|
function getActiveEventsDirEnv() {
|
|
114
212
|
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
@@ -124,12 +222,12 @@ class JsonEventsStore {
|
|
|
124
222
|
channelsPath;
|
|
125
223
|
eventsPath;
|
|
126
224
|
deliveriesPath;
|
|
127
|
-
constructor(
|
|
128
|
-
this.dataDir =
|
|
129
|
-
this.runtime = localJsonRuntime(
|
|
130
|
-
this.channelsPath =
|
|
131
|
-
this.eventsPath =
|
|
132
|
-
this.deliveriesPath =
|
|
225
|
+
constructor(dataDir2 = getEventsDataDir()) {
|
|
226
|
+
this.dataDir = dataDir2;
|
|
227
|
+
this.runtime = localJsonRuntime(dataDir2);
|
|
228
|
+
this.channelsPath = join2(dataDir2, "channels.json");
|
|
229
|
+
this.eventsPath = join2(dataDir2, "events.json");
|
|
230
|
+
this.deliveriesPath = join2(dataDir2, "deliveries.json");
|
|
133
231
|
}
|
|
134
232
|
async init() {
|
|
135
233
|
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
@@ -246,7 +344,7 @@ class JsonEventsStore {
|
|
|
246
344
|
};
|
|
247
345
|
}
|
|
248
346
|
async ensureArrayFile(path) {
|
|
249
|
-
if (!
|
|
347
|
+
if (!existsSync2(path)) {
|
|
250
348
|
await writeFile(path, `[]
|
|
251
349
|
`, { encoding: "utf-8", mode: 384 });
|
|
252
350
|
}
|
|
@@ -276,7 +374,7 @@ class JsonEventsStore {
|
|
|
276
374
|
});
|
|
277
375
|
}
|
|
278
376
|
}
|
|
279
|
-
function localJsonRuntime(
|
|
377
|
+
function localJsonRuntime(dataDir2 = getEventsDataDir()) {
|
|
280
378
|
return {
|
|
281
379
|
mode: "local-files",
|
|
282
380
|
name: "json-events-store",
|
|
@@ -289,7 +387,7 @@ function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
|
289
387
|
durable: true,
|
|
290
388
|
idempotency: "best-effort-local",
|
|
291
389
|
replayCursors: true,
|
|
292
|
-
description: `Local JSON files in ${
|
|
390
|
+
description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
293
391
|
};
|
|
294
392
|
}
|
|
295
393
|
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
@@ -353,8 +451,8 @@ function assertCursorFilter(name, cursorValue, optionValue) {
|
|
|
353
451
|
function findEventByIdentity(events, identity) {
|
|
354
452
|
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
355
453
|
}
|
|
356
|
-
async function getEventsStatus(
|
|
357
|
-
const store = new JsonEventsStore(
|
|
454
|
+
async function getEventsStatus(dataDir2) {
|
|
455
|
+
const store = new JsonEventsStore(dataDir2);
|
|
358
456
|
await store.init();
|
|
359
457
|
const [channels, events, deliveries] = await Promise.all([
|
|
360
458
|
store.listChannels(),
|
|
@@ -396,9 +494,9 @@ async function getEventsStatus(dataDir) {
|
|
|
396
494
|
}
|
|
397
495
|
};
|
|
398
496
|
}
|
|
399
|
-
function statusFile(
|
|
400
|
-
const path =
|
|
401
|
-
return { path, exists:
|
|
497
|
+
function statusFile(dataDir2, fileName, records) {
|
|
498
|
+
const path = join2(dataDir2, fileName);
|
|
499
|
+
return { path, exists: existsSync2(path), records };
|
|
402
500
|
}
|
|
403
501
|
|
|
404
502
|
// src/signing.ts
|
|
@@ -435,9 +533,226 @@ function verifyWebhookSignature(secret, timestamp, body, signature, options = {}
|
|
|
435
533
|
return verifyPayloadSignature(secret, timestamp, body, signature);
|
|
436
534
|
}
|
|
437
535
|
|
|
536
|
+
// src/ssrf.ts
|
|
537
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
538
|
+
import { isIP } from "net";
|
|
539
|
+
var DEFAULT_MAX_REDIRECTS = 5;
|
|
540
|
+
var IPV4_PRIVATE_RANGES = [
|
|
541
|
+
[0, 16777215],
|
|
542
|
+
[167772160, 184549375],
|
|
543
|
+
[1681915904, 1686110207],
|
|
544
|
+
[2130706432, 2147483647],
|
|
545
|
+
[2851995648, 2852061183],
|
|
546
|
+
[2886729728, 2887778303],
|
|
547
|
+
[3221225472, 3221225727],
|
|
548
|
+
[3221225984, 3221226239],
|
|
549
|
+
[3227017984, 3227018239],
|
|
550
|
+
[3232235520, 3232301055],
|
|
551
|
+
[3323068416, 3323199487],
|
|
552
|
+
[3325256704, 3325256959],
|
|
553
|
+
[3405803776, 3405804031],
|
|
554
|
+
[3758096384, 4294967295]
|
|
555
|
+
];
|
|
556
|
+
var IPV6_SPECIAL_PREFIXES = [
|
|
557
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
|
|
558
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
|
|
559
|
+
{ groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
|
|
560
|
+
{ groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
|
|
561
|
+
{ groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
|
|
562
|
+
{ groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
563
|
+
{ groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
|
|
564
|
+
{ groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
|
|
565
|
+
{ groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
566
|
+
{ groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
|
|
567
|
+
{ groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
|
|
568
|
+
{ groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
|
|
569
|
+
{ groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
570
|
+
{ groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
571
|
+
{ groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
|
|
572
|
+
];
|
|
573
|
+
function isPrivateAddress(address) {
|
|
574
|
+
const normalized = stripZoneId(address);
|
|
575
|
+
const version = isIP(normalized);
|
|
576
|
+
if (version === 4) {
|
|
577
|
+
const integer = ipv4ToInt(normalized);
|
|
578
|
+
if (integer === undefined)
|
|
579
|
+
return true;
|
|
580
|
+
return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
|
|
581
|
+
}
|
|
582
|
+
if (version === 6) {
|
|
583
|
+
const groups = ipv6Groups(normalized);
|
|
584
|
+
if (!groups)
|
|
585
|
+
return true;
|
|
586
|
+
for (const prefix of IPV6_SPECIAL_PREFIXES) {
|
|
587
|
+
if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
|
|
588
|
+
continue;
|
|
589
|
+
if (prefix.bits === 96 && groups[5] === 65535) {
|
|
590
|
+
return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
|
|
591
|
+
}
|
|
592
|
+
if (prefix.bits === 16 && groups[0] === 8194) {
|
|
593
|
+
return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
async function resolveWebhookTarget(url, policy = {}) {
|
|
602
|
+
const hostname = normalizeHostname(url.hostname);
|
|
603
|
+
const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
|
|
604
|
+
if (allowlist.includes(hostname)) {
|
|
605
|
+
const version2 = isIP(hostname);
|
|
606
|
+
if (version2 === 4 || version2 === 6) {
|
|
607
|
+
return { hostname, addresses: [hostname] };
|
|
608
|
+
}
|
|
609
|
+
const lookup2 = policy.lookup ?? defaultTargetLookup;
|
|
610
|
+
let resolved2;
|
|
611
|
+
try {
|
|
612
|
+
resolved2 = await lookup2(hostname);
|
|
613
|
+
} catch {
|
|
614
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
615
|
+
}
|
|
616
|
+
if (!Array.isArray(resolved2) || resolved2.length === 0) {
|
|
617
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
618
|
+
}
|
|
619
|
+
const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
|
|
620
|
+
return { hostname, addresses };
|
|
621
|
+
}
|
|
622
|
+
const version = isIP(hostname);
|
|
623
|
+
if (version === 4 || version === 6) {
|
|
624
|
+
if (isPrivateAddress(hostname)) {
|
|
625
|
+
throw new Error(`Webhook target ${hostname} is a private or special-use address`);
|
|
626
|
+
}
|
|
627
|
+
return { hostname, addresses: [hostname] };
|
|
628
|
+
}
|
|
629
|
+
const lookup = policy.lookup ?? defaultTargetLookup;
|
|
630
|
+
let resolved;
|
|
631
|
+
try {
|
|
632
|
+
resolved = await lookup(hostname);
|
|
633
|
+
} catch {
|
|
634
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
635
|
+
}
|
|
636
|
+
if (!Array.isArray(resolved) || resolved.length === 0) {
|
|
637
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
638
|
+
}
|
|
639
|
+
const allowed = [];
|
|
640
|
+
for (const entry of resolved) {
|
|
641
|
+
const address = normalizeHostname(entry.address);
|
|
642
|
+
if (isPrivateAddress(address)) {
|
|
643
|
+
if (allowlist.includes(address)) {
|
|
644
|
+
allowed.push(address);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
|
|
648
|
+
}
|
|
649
|
+
allowed.push(address);
|
|
650
|
+
}
|
|
651
|
+
if (allowed.length === 0) {
|
|
652
|
+
throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
|
|
653
|
+
}
|
|
654
|
+
return { hostname, addresses: allowed };
|
|
655
|
+
}
|
|
656
|
+
async function assertWebhookTargetAllowed(url, policy = {}) {
|
|
657
|
+
await resolveWebhookTarget(url, policy);
|
|
658
|
+
}
|
|
659
|
+
function normalizeMaxRedirects(value) {
|
|
660
|
+
if (value === undefined)
|
|
661
|
+
return DEFAULT_MAX_REDIRECTS;
|
|
662
|
+
if (!Number.isInteger(value) || value < 0)
|
|
663
|
+
throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
|
|
664
|
+
return value;
|
|
665
|
+
}
|
|
666
|
+
var defaultTargetLookup = async (hostname) => {
|
|
667
|
+
return dnsLookup(hostname, { all: true, verbatim: false });
|
|
668
|
+
};
|
|
669
|
+
function normalizeHostname(hostname) {
|
|
670
|
+
const lower = hostname.toLowerCase();
|
|
671
|
+
if (lower.startsWith("[") && lower.endsWith("]"))
|
|
672
|
+
return lower.slice(1, -1);
|
|
673
|
+
return lower;
|
|
674
|
+
}
|
|
675
|
+
function stripZoneId(address) {
|
|
676
|
+
const percent = address.indexOf("%");
|
|
677
|
+
return percent === -1 ? address : address.slice(0, percent);
|
|
678
|
+
}
|
|
679
|
+
function ipv4ToInt(address) {
|
|
680
|
+
const parts = address.split(".");
|
|
681
|
+
if (parts.length !== 4)
|
|
682
|
+
return;
|
|
683
|
+
let value = 0;
|
|
684
|
+
for (const part of parts) {
|
|
685
|
+
if (!/^\d{1,3}$/.test(part))
|
|
686
|
+
return;
|
|
687
|
+
const octet = Number(part);
|
|
688
|
+
if (octet > 255)
|
|
689
|
+
return;
|
|
690
|
+
value = value << 8 | octet;
|
|
691
|
+
}
|
|
692
|
+
return value >>> 0;
|
|
693
|
+
}
|
|
694
|
+
function ipv4IntToString(integer) {
|
|
695
|
+
return [
|
|
696
|
+
integer >>> 24 & 255,
|
|
697
|
+
integer >>> 16 & 255,
|
|
698
|
+
integer >>> 8 & 255,
|
|
699
|
+
integer & 255
|
|
700
|
+
].join(".");
|
|
701
|
+
}
|
|
702
|
+
function ipv6Groups(address) {
|
|
703
|
+
const raw = stripZoneId(address);
|
|
704
|
+
const doubleColon = raw.indexOf("::");
|
|
705
|
+
const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
|
|
706
|
+
const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
|
|
707
|
+
const parseGroups = (text) => {
|
|
708
|
+
if (text === "")
|
|
709
|
+
return [];
|
|
710
|
+
const out = [];
|
|
711
|
+
for (const part of text.split(":")) {
|
|
712
|
+
if (part.includes(".")) {
|
|
713
|
+
const v4 = ipv4ToInt(part);
|
|
714
|
+
if (v4 === undefined)
|
|
715
|
+
return;
|
|
716
|
+
out.push(v4 >>> 16 & 65535, v4 & 65535);
|
|
717
|
+
} else {
|
|
718
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part))
|
|
719
|
+
return;
|
|
720
|
+
out.push(parseInt(part, 16));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return out;
|
|
724
|
+
};
|
|
725
|
+
const head = parseGroups(headText);
|
|
726
|
+
if (!head)
|
|
727
|
+
return;
|
|
728
|
+
const tail = parseGroups(tailText);
|
|
729
|
+
if (!tail)
|
|
730
|
+
return;
|
|
731
|
+
const total = head.length + tail.length;
|
|
732
|
+
if (doubleColon === -1) {
|
|
733
|
+
return total === 8 ? head : undefined;
|
|
734
|
+
}
|
|
735
|
+
if (total >= 8)
|
|
736
|
+
return;
|
|
737
|
+
return [...head, ...new Array(8 - total).fill(0), ...tail];
|
|
738
|
+
}
|
|
739
|
+
function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
|
|
740
|
+
let remaining = prefixBits;
|
|
741
|
+
for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
|
|
742
|
+
const take = Math.min(16, remaining);
|
|
743
|
+
const mask = 65535 << 16 - take & 65535;
|
|
744
|
+
if ((groups[index] & mask) !== (prefixGroups[index] & mask))
|
|
745
|
+
return false;
|
|
746
|
+
remaining -= take;
|
|
747
|
+
}
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
|
|
438
751
|
// src/transports.ts
|
|
439
752
|
import { randomUUID } from "crypto";
|
|
440
753
|
import { spawn } from "child_process";
|
|
754
|
+
import { request as nodeHttpRequest } from "http";
|
|
755
|
+
import { request as nodeHttpsRequest } from "https";
|
|
441
756
|
function now() {
|
|
442
757
|
return new Date().toISOString();
|
|
443
758
|
}
|
|
@@ -468,9 +783,18 @@ function buildWebhookRequest(event, channel, options = {}) {
|
|
|
468
783
|
}
|
|
469
784
|
return { body, headers };
|
|
470
785
|
}
|
|
786
|
+
function normalizeWebhookUrl(raw) {
|
|
787
|
+
const url = new URL(raw);
|
|
788
|
+
if (url.username !== "" || url.password !== "") {
|
|
789
|
+
url.username = "";
|
|
790
|
+
url.password = "";
|
|
791
|
+
}
|
|
792
|
+
return url.toString();
|
|
793
|
+
}
|
|
471
794
|
async function dispatchWebhook(event, channel, options = {}) {
|
|
472
795
|
if (!channel.webhook)
|
|
473
796
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
797
|
+
const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
|
|
474
798
|
const startedAt = now();
|
|
475
799
|
let secret = channel.webhook.secret;
|
|
476
800
|
if (channel.webhook.secretRef) {
|
|
@@ -487,10 +811,14 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
487
811
|
}
|
|
488
812
|
const timestamp = (options.now?.() ?? new Date).toISOString();
|
|
489
813
|
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
|
|
814
|
+
const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
|
|
815
|
+
if (validateTargets) {
|
|
816
|
+
return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
|
|
817
|
+
}
|
|
490
818
|
const controller = new AbortController;
|
|
491
819
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
492
820
|
try {
|
|
493
|
-
const response = await (options.fetchImpl ?? fetch)(
|
|
821
|
+
const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
|
|
494
822
|
method: "POST",
|
|
495
823
|
headers,
|
|
496
824
|
body,
|
|
@@ -518,6 +846,130 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
518
846
|
clearTimeout(timeout);
|
|
519
847
|
}
|
|
520
848
|
}
|
|
849
|
+
function isRedirectStatus(status) {
|
|
850
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
851
|
+
}
|
|
852
|
+
function redirectKeepsBody(status) {
|
|
853
|
+
return status === 307 || status === 308;
|
|
854
|
+
}
|
|
855
|
+
async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
|
|
856
|
+
const isHttps = target.protocol === "https:";
|
|
857
|
+
if (!isHttps && target.protocol !== "http:") {
|
|
858
|
+
throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
|
|
859
|
+
}
|
|
860
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
861
|
+
const port = target.port ? Number(target.port) : defaultPort;
|
|
862
|
+
const requestOptions = {
|
|
863
|
+
hostname: target.hostname,
|
|
864
|
+
port,
|
|
865
|
+
path: `${target.pathname}${target.search}`,
|
|
866
|
+
method,
|
|
867
|
+
headers,
|
|
868
|
+
...tls?.ca ? { ca: tls.ca } : {},
|
|
869
|
+
lookup: (hostname, _options, callback) => {
|
|
870
|
+
const entries = addresses.map((address) => ({
|
|
871
|
+
address,
|
|
872
|
+
family: address.includes(":") ? 6 : 4
|
|
873
|
+
}));
|
|
874
|
+
callback(null, entries);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
return new Promise((resolve2, reject) => {
|
|
878
|
+
const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
|
|
879
|
+
const onAbort = () => {
|
|
880
|
+
const error = new Error("The operation was aborted.");
|
|
881
|
+
error.name = "AbortError";
|
|
882
|
+
request.destroy(error);
|
|
883
|
+
};
|
|
884
|
+
if (signal.aborted)
|
|
885
|
+
onAbort();
|
|
886
|
+
else
|
|
887
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
888
|
+
request.on("error", reject);
|
|
889
|
+
if (body !== undefined)
|
|
890
|
+
request.write(body);
|
|
891
|
+
request.end();
|
|
892
|
+
function onResponse(response) {
|
|
893
|
+
const chunks = [];
|
|
894
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
895
|
+
response.on("error", reject);
|
|
896
|
+
response.on("end", () => {
|
|
897
|
+
const headersRecord = {};
|
|
898
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
899
|
+
if (typeof value === "string")
|
|
900
|
+
headersRecord[name] = value;
|
|
901
|
+
else if (Array.isArray(value))
|
|
902
|
+
headersRecord[name] = value.join(", ");
|
|
903
|
+
}
|
|
904
|
+
resolve2(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
async function dispatchValidatedWebhook(event, channel, input) {
|
|
910
|
+
const { body, headers, startedAt, options } = input;
|
|
911
|
+
const webhook = channel.webhook;
|
|
912
|
+
if (!webhook)
|
|
913
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
914
|
+
const policy = options.webhookTargetPolicy ?? {};
|
|
915
|
+
const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
|
|
916
|
+
const controller = new AbortController;
|
|
917
|
+
const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
|
|
918
|
+
try {
|
|
919
|
+
let target = new URL(normalizeWebhookUrl(webhook.url));
|
|
920
|
+
let requestHeaders = headers;
|
|
921
|
+
let method = "POST";
|
|
922
|
+
let requestBody = body;
|
|
923
|
+
let redirectsFollowed = 0;
|
|
924
|
+
for (;; ) {
|
|
925
|
+
const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
|
|
926
|
+
throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
|
|
927
|
+
});
|
|
928
|
+
const response = options.fetchImpl ? await options.fetchImpl(target, {
|
|
929
|
+
method,
|
|
930
|
+
headers: requestHeaders,
|
|
931
|
+
body: requestBody,
|
|
932
|
+
signal: controller.signal,
|
|
933
|
+
redirect: "manual"
|
|
934
|
+
}) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
|
|
935
|
+
const location = response.headers.get("location");
|
|
936
|
+
if (isRedirectStatus(response.status) && location) {
|
|
937
|
+
if (redirectsFollowed >= maxRedirects) {
|
|
938
|
+
return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
|
|
939
|
+
}
|
|
940
|
+
redirectsFollowed += 1;
|
|
941
|
+
const next = new URL(location, target);
|
|
942
|
+
target = next;
|
|
943
|
+
if (!redirectKeepsBody(response.status)) {
|
|
944
|
+
method = "GET";
|
|
945
|
+
requestBody = undefined;
|
|
946
|
+
requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
|
|
947
|
+
}
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const responseBody = truncate(await response.text());
|
|
951
|
+
return {
|
|
952
|
+
attempt: 1,
|
|
953
|
+
status: response.ok ? "success" : "failed",
|
|
954
|
+
startedAt,
|
|
955
|
+
completedAt: now(),
|
|
956
|
+
responseStatus: response.status,
|
|
957
|
+
responseBody,
|
|
958
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
} catch (error) {
|
|
962
|
+
return {
|
|
963
|
+
attempt: 1,
|
|
964
|
+
status: "failed",
|
|
965
|
+
startedAt,
|
|
966
|
+
completedAt: now(),
|
|
967
|
+
error: error instanceof Error ? error.message : String(error)
|
|
968
|
+
};
|
|
969
|
+
} finally {
|
|
970
|
+
clearTimeout(timeout);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
521
973
|
function failedAttempt(startedAt, error) {
|
|
522
974
|
return {
|
|
523
975
|
attempt: 1,
|
|
@@ -546,7 +998,7 @@ async function dispatchCommand(event, channel) {
|
|
|
546
998
|
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
547
999
|
HASNA_EVENT_JSON: eventJson
|
|
548
1000
|
};
|
|
549
|
-
return new Promise((
|
|
1001
|
+
return new Promise((resolve2) => {
|
|
550
1002
|
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
551
1003
|
cwd: channel.command.cwd,
|
|
552
1004
|
env,
|
|
@@ -564,7 +1016,7 @@ async function dispatchCommand(event, channel) {
|
|
|
564
1016
|
});
|
|
565
1017
|
child.on("error", (error) => {
|
|
566
1018
|
clearTimeout(timeout);
|
|
567
|
-
|
|
1019
|
+
resolve2({
|
|
568
1020
|
attempt: 1,
|
|
569
1021
|
status: "failed",
|
|
570
1022
|
startedAt,
|
|
@@ -577,7 +1029,7 @@ async function dispatchCommand(event, channel) {
|
|
|
577
1029
|
child.on("close", (code, signal) => {
|
|
578
1030
|
clearTimeout(timeout);
|
|
579
1031
|
const success = code === 0;
|
|
580
|
-
|
|
1032
|
+
resolve2({
|
|
581
1033
|
attempt: 1,
|
|
582
1034
|
status: success ? "success" : "failed",
|
|
583
1035
|
startedAt,
|
|
@@ -1229,7 +1681,9 @@ class EventsClient {
|
|
|
1229
1681
|
this.transportOptions = {
|
|
1230
1682
|
fetchImpl: options.fetchImpl,
|
|
1231
1683
|
secretResolver: options.secretResolver,
|
|
1232
|
-
now: options.now
|
|
1684
|
+
now: options.now,
|
|
1685
|
+
tls: options.tls,
|
|
1686
|
+
webhookTargetPolicy: options.webhookTargetPolicy
|
|
1233
1687
|
};
|
|
1234
1688
|
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
1235
1689
|
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
@@ -1453,15 +1907,17 @@ import { createHash, randomUUID as randomUUID3 } from "crypto";
|
|
|
1453
1907
|
import {
|
|
1454
1908
|
chmodSync,
|
|
1455
1909
|
closeSync,
|
|
1456
|
-
existsSync as
|
|
1910
|
+
existsSync as existsSync3,
|
|
1457
1911
|
fsyncSync,
|
|
1458
1912
|
mkdirSync,
|
|
1459
1913
|
openSync,
|
|
1460
1914
|
readdirSync,
|
|
1461
1915
|
readFileSync,
|
|
1462
|
-
|
|
1916
|
+
renameSync,
|
|
1917
|
+
unlinkSync,
|
|
1918
|
+
writeFileSync
|
|
1463
1919
|
} from "fs";
|
|
1464
|
-
import { join as
|
|
1920
|
+
import { basename, join as join3 } from "path";
|
|
1465
1921
|
var DURABLE_SCHEMA_VERSION = 1;
|
|
1466
1922
|
var MAX_RETRY_ATTEMPTS = 1000;
|
|
1467
1923
|
var MAX_RETRY_DELAY_MS = 365 * 24 * 60 * 60 * 1000;
|
|
@@ -1604,12 +2060,14 @@ class DurableEventsBroker {
|
|
|
1604
2060
|
if (!options.dataDir)
|
|
1605
2061
|
throw new Error("DurableEventsBroker requires dataDir");
|
|
1606
2062
|
this.dataDir = options.dataDir;
|
|
1607
|
-
this.databasePath =
|
|
2063
|
+
this.databasePath = join3(options.dataDir, options.databaseName ?? "events.sqlite");
|
|
1608
2064
|
this.now = options.now ?? (() => new Date);
|
|
1609
2065
|
this.transportOptions = {
|
|
1610
2066
|
fetchImpl: options.fetchImpl,
|
|
1611
2067
|
secretResolver: options.secretResolver ?? defaultWebhookSecretResolver,
|
|
1612
|
-
now: this.now
|
|
2068
|
+
now: this.now,
|
|
2069
|
+
tls: options.tls,
|
|
2070
|
+
webhookTargetPolicy: options.webhookTargetPolicy
|
|
1613
2071
|
};
|
|
1614
2072
|
mkdirSync(this.dataDir, { recursive: true, mode: 448 });
|
|
1615
2073
|
chmodSync(this.dataDir, 448);
|
|
@@ -1746,24 +2204,29 @@ class DurableEventsBroker {
|
|
|
1746
2204
|
return summary;
|
|
1747
2205
|
}
|
|
1748
2206
|
importSpool(options = {}) {
|
|
1749
|
-
const inboxDir =
|
|
1750
|
-
if (!
|
|
1751
|
-
return { scanned: 0, imported: 0, deduped: 0, queued: 0 };
|
|
2207
|
+
const inboxDir = join3(this.dataDir, "spool", "inbox");
|
|
2208
|
+
if (!existsSync3(inboxDir))
|
|
2209
|
+
return { scanned: 0, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
|
|
1752
2210
|
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1753
2211
|
const names = readdirSync(inboxDir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, limit);
|
|
1754
|
-
const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0 };
|
|
2212
|
+
const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
|
|
1755
2213
|
for (const name of names) {
|
|
1756
|
-
const path =
|
|
2214
|
+
const path = join3(inboxDir, name);
|
|
1757
2215
|
let event;
|
|
1758
2216
|
try {
|
|
1759
2217
|
event = parseSpoolEnvelope(readFileSync(path, "utf8"));
|
|
1760
2218
|
} catch (error) {
|
|
1761
2219
|
if (isNodeError(error, "ENOENT"))
|
|
1762
2220
|
continue;
|
|
1763
|
-
|
|
2221
|
+
quarantineSpoolRecord(this.dataDir, path, "malformed");
|
|
2222
|
+
result.quarantined += 1;
|
|
2223
|
+
continue;
|
|
2224
|
+
}
|
|
2225
|
+
if (spoolFileName(event) !== name) {
|
|
2226
|
+
quarantineSpoolRecord(this.dataDir, path, "identity-mismatch");
|
|
2227
|
+
result.quarantined += 1;
|
|
2228
|
+
continue;
|
|
1764
2229
|
}
|
|
1765
|
-
if (spoolFileName(event) !== name)
|
|
1766
|
-
throw new Error("Durable event spool filename does not match its identity");
|
|
1767
2230
|
const enqueued = this.enqueue(event);
|
|
1768
2231
|
if (enqueued.deduped)
|
|
1769
2232
|
result.deduped += 1;
|
|
@@ -2088,7 +2551,7 @@ class DurableEventsBroker {
|
|
|
2088
2551
|
}
|
|
2089
2552
|
secureDatabaseFiles() {
|
|
2090
2553
|
for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
|
|
2091
|
-
if (!
|
|
2554
|
+
if (!existsSync3(path))
|
|
2092
2555
|
continue;
|
|
2093
2556
|
chmodSync(path, 384);
|
|
2094
2557
|
}
|
|
@@ -2219,6 +2682,26 @@ function syncDirectory(path) {
|
|
|
2219
2682
|
closeSync(descriptor);
|
|
2220
2683
|
}
|
|
2221
2684
|
}
|
|
2685
|
+
function quarantineSpoolRecord(dataDir2, path, reason) {
|
|
2686
|
+
const spoolDir = join3(dataDir2, "spool");
|
|
2687
|
+
const quarantineDir = join3(spoolDir, "quarantine");
|
|
2688
|
+
mkdirSync(quarantineDir, { recursive: true, mode: 448 });
|
|
2689
|
+
chmodSync(spoolDir, 448);
|
|
2690
|
+
chmodSync(quarantineDir, 448);
|
|
2691
|
+
const name = basename(path);
|
|
2692
|
+
const base = name.replace(/\.json$/, "");
|
|
2693
|
+
const suffix = `${Date.now()}-${randomUUID3().slice(0, 8)}`;
|
|
2694
|
+
const destination = join3(quarantineDir, `${base}.${suffix}.json`);
|
|
2695
|
+
renameSync(path, destination);
|
|
2696
|
+
const metadata = {
|
|
2697
|
+
quarantinedAt: new Date().toISOString(),
|
|
2698
|
+
originalName: name,
|
|
2699
|
+
reason
|
|
2700
|
+
};
|
|
2701
|
+
writeFileSync(join3(quarantineDir, `${base}.${suffix}.meta.json`), `${JSON.stringify(metadata, null, 2)}
|
|
2702
|
+
`, { mode: 384 });
|
|
2703
|
+
syncDirectory(quarantineDir);
|
|
2704
|
+
}
|
|
2222
2705
|
function isNodeError(error, code) {
|
|
2223
2706
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
2224
2707
|
}
|
|
@@ -2227,6 +2710,6 @@ function spoolFileName(event) {
|
|
|
2227
2710
|
return `${createHash("sha256").update(identity, "utf8").digest("hex")}.json`;
|
|
2228
2711
|
}
|
|
2229
2712
|
export {
|
|
2230
|
-
|
|
2231
|
-
|
|
2713
|
+
defaultWebhookSecretResolver,
|
|
2714
|
+
DurableEventsBroker
|
|
2232
2715
|
};
|