@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/commander.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;
|
|
@@ -1519,6 +1973,15 @@ function parseMatcherExpression(value, label) {
|
|
|
1519
1973
|
};
|
|
1520
1974
|
}
|
|
1521
1975
|
|
|
1976
|
+
// src/cli-webhook-policy.ts
|
|
1977
|
+
function webhookTargetPolicyFromEnv() {
|
|
1978
|
+
const value = process.env.HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS;
|
|
1979
|
+
if (!value)
|
|
1980
|
+
return;
|
|
1981
|
+
const hosts = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
1982
|
+
return hosts.length > 0 ? { allowPrivateHosts: hosts } : undefined;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1522
1985
|
// src/commander.ts
|
|
1523
1986
|
var DEFAULT_EVENT_LIST_LIMIT = 100;
|
|
1524
1987
|
function parseJsonObject(value, fallback) {
|
|
@@ -1545,7 +2008,7 @@ function parseHeaders(values) {
|
|
|
1545
2008
|
function createClient(options) {
|
|
1546
2009
|
if (options.createClient)
|
|
1547
2010
|
return options.createClient();
|
|
1548
|
-
return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
|
|
2011
|
+
return new EventsClient({ store: new JsonEventsStore(options.dataDir), webhookTargetPolicy: webhookTargetPolicyFromEnv() });
|
|
1549
2012
|
}
|
|
1550
2013
|
function print(value, json, text) {
|
|
1551
2014
|
if (json)
|
|
@@ -1626,6 +2089,8 @@ function registerChannelCommands(program, options) {
|
|
|
1626
2089
|
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
1627
2090
|
}, { honorFilters: actionOptions.honorFilters });
|
|
1628
2091
|
print(result, json, `${result.status}: ${result.channelId}`);
|
|
2092
|
+
if (result.status === "failed")
|
|
2093
|
+
process.exitCode = 1;
|
|
1629
2094
|
} catch (error) {
|
|
1630
2095
|
fail(error, json);
|
|
1631
2096
|
}
|
|
@@ -1715,8 +2180,8 @@ function replaySummary(events, deliveries, nextCursor) {
|
|
|
1715
2180
|
return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
|
|
1716
2181
|
}
|
|
1717
2182
|
export {
|
|
1718
|
-
|
|
1719
|
-
registerChannelCommands,
|
|
2183
|
+
registerEventsCommands,
|
|
1720
2184
|
registerEventCommands,
|
|
1721
|
-
|
|
2185
|
+
registerChannelCommands,
|
|
2186
|
+
DEFAULT_EVENT_LIST_LIMIT
|
|
1722
2187
|
};
|