@hasna/events 0.1.15 → 0.1.17
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 +16 -5
- package/dist/cli/index.js +554 -53
- package/dist/commander.js +481 -20
- package/dist/durable.js +524 -34
- package/dist/index.js +486 -20
- package/dist/ssrf.js +222 -0
- package/dist/storage.js +120 -15
- package/dist/transports.js +355 -1
- package/fixtures/hasna.app_event.v1.json +2 -2
- package/package.json +7 -2
- package/types/app-home.d.ts +33 -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/index.js
CHANGED
|
@@ -99,16 +99,121 @@ 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 join3 } from "path";
|
|
104
|
+
|
|
105
|
+
// src/app-home.ts
|
|
102
106
|
import { existsSync } from "fs";
|
|
107
|
+
import { homedir as homedir2 } from "os";
|
|
108
|
+
import { join as join2, resolve } from "path";
|
|
109
|
+
|
|
110
|
+
// ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
|
|
103
111
|
import { homedir } from "os";
|
|
104
112
|
import { join } from "path";
|
|
113
|
+
var KIND_ENV = {
|
|
114
|
+
config: "HASNA_CONFIG_HOME",
|
|
115
|
+
data: "HASNA_DATA_HOME",
|
|
116
|
+
state: "HASNA_STATE_HOME",
|
|
117
|
+
cache: "HASNA_CACHE_HOME"
|
|
118
|
+
};
|
|
119
|
+
var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
120
|
+
function assertApp(app) {
|
|
121
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
122
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
123
|
+
}
|
|
124
|
+
if (!APP_SLUG_RE.test(app)) {
|
|
125
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function envOf(options) {
|
|
129
|
+
return options.env ?? process.env;
|
|
130
|
+
}
|
|
131
|
+
function envValue(options, kind) {
|
|
132
|
+
const value = envOf(options)[KIND_ENV[kind]];
|
|
133
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
134
|
+
}
|
|
135
|
+
function isMacOS(platform) {
|
|
136
|
+
return platform === "darwin";
|
|
137
|
+
}
|
|
138
|
+
function baseDir(kind, options) {
|
|
139
|
+
const override = envValue(options, kind);
|
|
140
|
+
if (override)
|
|
141
|
+
return override;
|
|
142
|
+
const home = options.home ?? homedir();
|
|
143
|
+
const platform = options.platform ?? process.platform;
|
|
144
|
+
if (isMacOS(platform)) {
|
|
145
|
+
switch (kind) {
|
|
146
|
+
case "config":
|
|
147
|
+
case "data":
|
|
148
|
+
return join(home, "Library", "Application Support", "Hasna");
|
|
149
|
+
case "cache":
|
|
150
|
+
return join(home, "Library", "Caches", "Hasna");
|
|
151
|
+
case "state":
|
|
152
|
+
return join(home, "Library", "Logs", "Hasna");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
switch (kind) {
|
|
156
|
+
case "config":
|
|
157
|
+
return join(home, ".config", "hasna");
|
|
158
|
+
case "data":
|
|
159
|
+
return join(home, ".local", "share", "hasna");
|
|
160
|
+
case "state":
|
|
161
|
+
return join(home, ".local", "state", "hasna");
|
|
162
|
+
case "cache":
|
|
163
|
+
return join(home, ".cache", "hasna");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function resolvePath(kind, options) {
|
|
167
|
+
assertApp(options.app);
|
|
168
|
+
const appSegment = options.internal === true ? join("internal", options.app) : options.app;
|
|
169
|
+
return join(baseDir(kind, options), appSegment);
|
|
170
|
+
}
|
|
171
|
+
function dataDir(options) {
|
|
172
|
+
return resolvePath("data", options);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/app-home.ts
|
|
105
176
|
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
106
177
|
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
178
|
+
var EVENTS_STORE_SENTINEL_FILE = "events.json";
|
|
179
|
+
function effectiveHome() {
|
|
180
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
181
|
+
}
|
|
182
|
+
function legacyHomeDir() {
|
|
183
|
+
return join2(effectiveHome(), ".hasna", "events");
|
|
184
|
+
}
|
|
185
|
+
function resolverHome() {
|
|
186
|
+
return dataDir({ app: "events", home: effectiveHome() || undefined });
|
|
187
|
+
}
|
|
188
|
+
function adoptResolverHome(resolved, env = process.env) {
|
|
189
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
190
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
191
|
+
return true;
|
|
192
|
+
return existsSync(join2(resolved, EVENTS_STORE_SENTINEL_FILE));
|
|
193
|
+
}
|
|
194
|
+
function exactEventsHome() {
|
|
195
|
+
const dir = process.env[HASNA_EVENTS_DIR_ENV];
|
|
196
|
+
if (dir && dir.trim())
|
|
197
|
+
return dir.trim();
|
|
198
|
+
const home = process.env[HASNA_EVENTS_HOME_ENV];
|
|
199
|
+
if (home && home.trim())
|
|
200
|
+
return home.trim();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
function getEventsHome() {
|
|
204
|
+
const exact = exactEventsHome();
|
|
205
|
+
if (exact)
|
|
206
|
+
return resolve(exact);
|
|
207
|
+
const resolved = resolverHome();
|
|
208
|
+
return adoptResolverHome(resolved) ? resolve(resolved) : resolve(legacyHomeDir());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/storage.ts
|
|
107
212
|
var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
|
|
108
213
|
var DEFAULT_EVENT_PAGE_LIMIT = 100;
|
|
109
214
|
var MAX_EVENT_PAGE_LIMIT = 1000;
|
|
110
215
|
function getEventsDataDir(override) {
|
|
111
|
-
return override ||
|
|
216
|
+
return override || getEventsHome();
|
|
112
217
|
}
|
|
113
218
|
function getActiveEventsDirEnv() {
|
|
114
219
|
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
@@ -124,12 +229,12 @@ class JsonEventsStore {
|
|
|
124
229
|
channelsPath;
|
|
125
230
|
eventsPath;
|
|
126
231
|
deliveriesPath;
|
|
127
|
-
constructor(
|
|
128
|
-
this.dataDir =
|
|
129
|
-
this.runtime = localJsonRuntime(
|
|
130
|
-
this.channelsPath =
|
|
131
|
-
this.eventsPath =
|
|
132
|
-
this.deliveriesPath =
|
|
232
|
+
constructor(dataDir2 = getEventsDataDir()) {
|
|
233
|
+
this.dataDir = dataDir2;
|
|
234
|
+
this.runtime = localJsonRuntime(dataDir2);
|
|
235
|
+
this.channelsPath = join3(dataDir2, "channels.json");
|
|
236
|
+
this.eventsPath = join3(dataDir2, "events.json");
|
|
237
|
+
this.deliveriesPath = join3(dataDir2, "deliveries.json");
|
|
133
238
|
}
|
|
134
239
|
async init() {
|
|
135
240
|
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
@@ -246,7 +351,7 @@ class JsonEventsStore {
|
|
|
246
351
|
};
|
|
247
352
|
}
|
|
248
353
|
async ensureArrayFile(path) {
|
|
249
|
-
if (!
|
|
354
|
+
if (!existsSync2(path)) {
|
|
250
355
|
await writeFile(path, `[]
|
|
251
356
|
`, { encoding: "utf-8", mode: 384 });
|
|
252
357
|
}
|
|
@@ -276,7 +381,7 @@ class JsonEventsStore {
|
|
|
276
381
|
});
|
|
277
382
|
}
|
|
278
383
|
}
|
|
279
|
-
function localJsonRuntime(
|
|
384
|
+
function localJsonRuntime(dataDir2 = getEventsDataDir()) {
|
|
280
385
|
return {
|
|
281
386
|
mode: "local-files",
|
|
282
387
|
name: "json-events-store",
|
|
@@ -289,7 +394,7 @@ function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
|
289
394
|
durable: true,
|
|
290
395
|
idempotency: "best-effort-local",
|
|
291
396
|
replayCursors: true,
|
|
292
|
-
description: `Local JSON files in ${
|
|
397
|
+
description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
293
398
|
};
|
|
294
399
|
}
|
|
295
400
|
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
@@ -353,8 +458,8 @@ function assertCursorFilter(name, cursorValue, optionValue) {
|
|
|
353
458
|
function findEventByIdentity(events, identity) {
|
|
354
459
|
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
355
460
|
}
|
|
356
|
-
async function getEventsStatus(
|
|
357
|
-
const store = new JsonEventsStore(
|
|
461
|
+
async function getEventsStatus(dataDir2) {
|
|
462
|
+
const store = new JsonEventsStore(dataDir2);
|
|
358
463
|
await store.init();
|
|
359
464
|
const [channels, events, deliveries] = await Promise.all([
|
|
360
465
|
store.listChannels(),
|
|
@@ -396,9 +501,9 @@ async function getEventsStatus(dataDir) {
|
|
|
396
501
|
}
|
|
397
502
|
};
|
|
398
503
|
}
|
|
399
|
-
function statusFile(
|
|
400
|
-
const path =
|
|
401
|
-
return { path, exists:
|
|
504
|
+
function statusFile(dataDir2, fileName, records) {
|
|
505
|
+
const path = join3(dataDir2, fileName);
|
|
506
|
+
return { path, exists: existsSync2(path), records };
|
|
402
507
|
}
|
|
403
508
|
|
|
404
509
|
// src/signing.ts
|
|
@@ -435,9 +540,226 @@ function verifyWebhookSignature(secret, timestamp, body, signature, options = {}
|
|
|
435
540
|
return verifyPayloadSignature(secret, timestamp, body, signature);
|
|
436
541
|
}
|
|
437
542
|
|
|
543
|
+
// src/ssrf.ts
|
|
544
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
545
|
+
import { isIP } from "net";
|
|
546
|
+
var DEFAULT_MAX_REDIRECTS = 5;
|
|
547
|
+
var IPV4_PRIVATE_RANGES = [
|
|
548
|
+
[0, 16777215],
|
|
549
|
+
[167772160, 184549375],
|
|
550
|
+
[1681915904, 1686110207],
|
|
551
|
+
[2130706432, 2147483647],
|
|
552
|
+
[2851995648, 2852061183],
|
|
553
|
+
[2886729728, 2887778303],
|
|
554
|
+
[3221225472, 3221225727],
|
|
555
|
+
[3221225984, 3221226239],
|
|
556
|
+
[3227017984, 3227018239],
|
|
557
|
+
[3232235520, 3232301055],
|
|
558
|
+
[3323068416, 3323199487],
|
|
559
|
+
[3325256704, 3325256959],
|
|
560
|
+
[3405803776, 3405804031],
|
|
561
|
+
[3758096384, 4294967295]
|
|
562
|
+
];
|
|
563
|
+
var IPV6_SPECIAL_PREFIXES = [
|
|
564
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
|
|
565
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
|
|
566
|
+
{ groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
|
|
567
|
+
{ groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
|
|
568
|
+
{ groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
|
|
569
|
+
{ groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
570
|
+
{ groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
|
|
571
|
+
{ groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
|
|
572
|
+
{ groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
573
|
+
{ groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
|
|
574
|
+
{ groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
|
|
575
|
+
{ groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
|
|
576
|
+
{ groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
577
|
+
{ groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
578
|
+
{ groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
|
|
579
|
+
];
|
|
580
|
+
function isPrivateAddress(address) {
|
|
581
|
+
const normalized = stripZoneId(address);
|
|
582
|
+
const version = isIP(normalized);
|
|
583
|
+
if (version === 4) {
|
|
584
|
+
const integer = ipv4ToInt(normalized);
|
|
585
|
+
if (integer === undefined)
|
|
586
|
+
return true;
|
|
587
|
+
return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
|
|
588
|
+
}
|
|
589
|
+
if (version === 6) {
|
|
590
|
+
const groups = ipv6Groups(normalized);
|
|
591
|
+
if (!groups)
|
|
592
|
+
return true;
|
|
593
|
+
for (const prefix of IPV6_SPECIAL_PREFIXES) {
|
|
594
|
+
if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
|
|
595
|
+
continue;
|
|
596
|
+
if (prefix.bits === 96 && groups[5] === 65535) {
|
|
597
|
+
return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
|
|
598
|
+
}
|
|
599
|
+
if (prefix.bits === 16 && groups[0] === 8194) {
|
|
600
|
+
return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
async function resolveWebhookTarget(url, policy = {}) {
|
|
609
|
+
const hostname = normalizeHostname(url.hostname);
|
|
610
|
+
const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
|
|
611
|
+
if (allowlist.includes(hostname)) {
|
|
612
|
+
const version2 = isIP(hostname);
|
|
613
|
+
if (version2 === 4 || version2 === 6) {
|
|
614
|
+
return { hostname, addresses: [hostname] };
|
|
615
|
+
}
|
|
616
|
+
const lookup2 = policy.lookup ?? defaultTargetLookup;
|
|
617
|
+
let resolved2;
|
|
618
|
+
try {
|
|
619
|
+
resolved2 = await lookup2(hostname);
|
|
620
|
+
} catch {
|
|
621
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
622
|
+
}
|
|
623
|
+
if (!Array.isArray(resolved2) || resolved2.length === 0) {
|
|
624
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
625
|
+
}
|
|
626
|
+
const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
|
|
627
|
+
return { hostname, addresses };
|
|
628
|
+
}
|
|
629
|
+
const version = isIP(hostname);
|
|
630
|
+
if (version === 4 || version === 6) {
|
|
631
|
+
if (isPrivateAddress(hostname)) {
|
|
632
|
+
throw new Error(`Webhook target ${hostname} is a private or special-use address`);
|
|
633
|
+
}
|
|
634
|
+
return { hostname, addresses: [hostname] };
|
|
635
|
+
}
|
|
636
|
+
const lookup = policy.lookup ?? defaultTargetLookup;
|
|
637
|
+
let resolved;
|
|
638
|
+
try {
|
|
639
|
+
resolved = await lookup(hostname);
|
|
640
|
+
} catch {
|
|
641
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
642
|
+
}
|
|
643
|
+
if (!Array.isArray(resolved) || resolved.length === 0) {
|
|
644
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
645
|
+
}
|
|
646
|
+
const allowed = [];
|
|
647
|
+
for (const entry of resolved) {
|
|
648
|
+
const address = normalizeHostname(entry.address);
|
|
649
|
+
if (isPrivateAddress(address)) {
|
|
650
|
+
if (allowlist.includes(address)) {
|
|
651
|
+
allowed.push(address);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
|
|
655
|
+
}
|
|
656
|
+
allowed.push(address);
|
|
657
|
+
}
|
|
658
|
+
if (allowed.length === 0) {
|
|
659
|
+
throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
|
|
660
|
+
}
|
|
661
|
+
return { hostname, addresses: allowed };
|
|
662
|
+
}
|
|
663
|
+
async function assertWebhookTargetAllowed(url, policy = {}) {
|
|
664
|
+
await resolveWebhookTarget(url, policy);
|
|
665
|
+
}
|
|
666
|
+
function normalizeMaxRedirects(value) {
|
|
667
|
+
if (value === undefined)
|
|
668
|
+
return DEFAULT_MAX_REDIRECTS;
|
|
669
|
+
if (!Number.isInteger(value) || value < 0)
|
|
670
|
+
throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
|
|
671
|
+
return value;
|
|
672
|
+
}
|
|
673
|
+
var defaultTargetLookup = async (hostname) => {
|
|
674
|
+
return dnsLookup(hostname, { all: true, verbatim: false });
|
|
675
|
+
};
|
|
676
|
+
function normalizeHostname(hostname) {
|
|
677
|
+
const lower = hostname.toLowerCase();
|
|
678
|
+
if (lower.startsWith("[") && lower.endsWith("]"))
|
|
679
|
+
return lower.slice(1, -1);
|
|
680
|
+
return lower;
|
|
681
|
+
}
|
|
682
|
+
function stripZoneId(address) {
|
|
683
|
+
const percent = address.indexOf("%");
|
|
684
|
+
return percent === -1 ? address : address.slice(0, percent);
|
|
685
|
+
}
|
|
686
|
+
function ipv4ToInt(address) {
|
|
687
|
+
const parts = address.split(".");
|
|
688
|
+
if (parts.length !== 4)
|
|
689
|
+
return;
|
|
690
|
+
let value = 0;
|
|
691
|
+
for (const part of parts) {
|
|
692
|
+
if (!/^\d{1,3}$/.test(part))
|
|
693
|
+
return;
|
|
694
|
+
const octet = Number(part);
|
|
695
|
+
if (octet > 255)
|
|
696
|
+
return;
|
|
697
|
+
value = value << 8 | octet;
|
|
698
|
+
}
|
|
699
|
+
return value >>> 0;
|
|
700
|
+
}
|
|
701
|
+
function ipv4IntToString(integer) {
|
|
702
|
+
return [
|
|
703
|
+
integer >>> 24 & 255,
|
|
704
|
+
integer >>> 16 & 255,
|
|
705
|
+
integer >>> 8 & 255,
|
|
706
|
+
integer & 255
|
|
707
|
+
].join(".");
|
|
708
|
+
}
|
|
709
|
+
function ipv6Groups(address) {
|
|
710
|
+
const raw = stripZoneId(address);
|
|
711
|
+
const doubleColon = raw.indexOf("::");
|
|
712
|
+
const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
|
|
713
|
+
const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
|
|
714
|
+
const parseGroups = (text) => {
|
|
715
|
+
if (text === "")
|
|
716
|
+
return [];
|
|
717
|
+
const out = [];
|
|
718
|
+
for (const part of text.split(":")) {
|
|
719
|
+
if (part.includes(".")) {
|
|
720
|
+
const v4 = ipv4ToInt(part);
|
|
721
|
+
if (v4 === undefined)
|
|
722
|
+
return;
|
|
723
|
+
out.push(v4 >>> 16 & 65535, v4 & 65535);
|
|
724
|
+
} else {
|
|
725
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part))
|
|
726
|
+
return;
|
|
727
|
+
out.push(parseInt(part, 16));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
};
|
|
732
|
+
const head = parseGroups(headText);
|
|
733
|
+
if (!head)
|
|
734
|
+
return;
|
|
735
|
+
const tail = parseGroups(tailText);
|
|
736
|
+
if (!tail)
|
|
737
|
+
return;
|
|
738
|
+
const total = head.length + tail.length;
|
|
739
|
+
if (doubleColon === -1) {
|
|
740
|
+
return total === 8 ? head : undefined;
|
|
741
|
+
}
|
|
742
|
+
if (total >= 8)
|
|
743
|
+
return;
|
|
744
|
+
return [...head, ...new Array(8 - total).fill(0), ...tail];
|
|
745
|
+
}
|
|
746
|
+
function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
|
|
747
|
+
let remaining = prefixBits;
|
|
748
|
+
for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
|
|
749
|
+
const take = Math.min(16, remaining);
|
|
750
|
+
const mask = 65535 << 16 - take & 65535;
|
|
751
|
+
if ((groups[index] & mask) !== (prefixGroups[index] & mask))
|
|
752
|
+
return false;
|
|
753
|
+
remaining -= take;
|
|
754
|
+
}
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
438
758
|
// src/transports.ts
|
|
439
759
|
import { randomUUID } from "crypto";
|
|
440
760
|
import { spawn } from "child_process";
|
|
761
|
+
import { request as nodeHttpRequest } from "http";
|
|
762
|
+
import { request as nodeHttpsRequest } from "https";
|
|
441
763
|
function now() {
|
|
442
764
|
return new Date().toISOString();
|
|
443
765
|
}
|
|
@@ -468,9 +790,18 @@ function buildWebhookRequest(event, channel, options = {}) {
|
|
|
468
790
|
}
|
|
469
791
|
return { body, headers };
|
|
470
792
|
}
|
|
793
|
+
function normalizeWebhookUrl(raw) {
|
|
794
|
+
const url = new URL(raw);
|
|
795
|
+
if (url.username !== "" || url.password !== "") {
|
|
796
|
+
url.username = "";
|
|
797
|
+
url.password = "";
|
|
798
|
+
}
|
|
799
|
+
return url.toString();
|
|
800
|
+
}
|
|
471
801
|
async function dispatchWebhook(event, channel, options = {}) {
|
|
472
802
|
if (!channel.webhook)
|
|
473
803
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
804
|
+
const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
|
|
474
805
|
const startedAt = now();
|
|
475
806
|
let secret = channel.webhook.secret;
|
|
476
807
|
if (channel.webhook.secretRef) {
|
|
@@ -487,10 +818,14 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
487
818
|
}
|
|
488
819
|
const timestamp = (options.now?.() ?? new Date).toISOString();
|
|
489
820
|
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
|
|
821
|
+
const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
|
|
822
|
+
if (validateTargets) {
|
|
823
|
+
return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
|
|
824
|
+
}
|
|
490
825
|
const controller = new AbortController;
|
|
491
826
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
492
827
|
try {
|
|
493
|
-
const response = await (options.fetchImpl ?? fetch)(
|
|
828
|
+
const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
|
|
494
829
|
method: "POST",
|
|
495
830
|
headers,
|
|
496
831
|
body,
|
|
@@ -518,6 +853,130 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
518
853
|
clearTimeout(timeout);
|
|
519
854
|
}
|
|
520
855
|
}
|
|
856
|
+
function isRedirectStatus(status) {
|
|
857
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
858
|
+
}
|
|
859
|
+
function redirectKeepsBody(status) {
|
|
860
|
+
return status === 307 || status === 308;
|
|
861
|
+
}
|
|
862
|
+
async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
|
|
863
|
+
const isHttps = target.protocol === "https:";
|
|
864
|
+
if (!isHttps && target.protocol !== "http:") {
|
|
865
|
+
throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
|
|
866
|
+
}
|
|
867
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
868
|
+
const port = target.port ? Number(target.port) : defaultPort;
|
|
869
|
+
const requestOptions = {
|
|
870
|
+
hostname: target.hostname,
|
|
871
|
+
port,
|
|
872
|
+
path: `${target.pathname}${target.search}`,
|
|
873
|
+
method,
|
|
874
|
+
headers,
|
|
875
|
+
...tls?.ca ? { ca: tls.ca } : {},
|
|
876
|
+
lookup: (hostname, _options, callback) => {
|
|
877
|
+
const entries = addresses.map((address) => ({
|
|
878
|
+
address,
|
|
879
|
+
family: address.includes(":") ? 6 : 4
|
|
880
|
+
}));
|
|
881
|
+
callback(null, entries);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
return new Promise((resolve2, reject) => {
|
|
885
|
+
const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
|
|
886
|
+
const onAbort = () => {
|
|
887
|
+
const error = new Error("The operation was aborted.");
|
|
888
|
+
error.name = "AbortError";
|
|
889
|
+
request.destroy(error);
|
|
890
|
+
};
|
|
891
|
+
if (signal.aborted)
|
|
892
|
+
onAbort();
|
|
893
|
+
else
|
|
894
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
895
|
+
request.on("error", reject);
|
|
896
|
+
if (body !== undefined)
|
|
897
|
+
request.write(body);
|
|
898
|
+
request.end();
|
|
899
|
+
function onResponse(response) {
|
|
900
|
+
const chunks = [];
|
|
901
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
902
|
+
response.on("error", reject);
|
|
903
|
+
response.on("end", () => {
|
|
904
|
+
const headersRecord = {};
|
|
905
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
906
|
+
if (typeof value === "string")
|
|
907
|
+
headersRecord[name] = value;
|
|
908
|
+
else if (Array.isArray(value))
|
|
909
|
+
headersRecord[name] = value.join(", ");
|
|
910
|
+
}
|
|
911
|
+
resolve2(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
async function dispatchValidatedWebhook(event, channel, input) {
|
|
917
|
+
const { body, headers, startedAt, options } = input;
|
|
918
|
+
const webhook = channel.webhook;
|
|
919
|
+
if (!webhook)
|
|
920
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
921
|
+
const policy = options.webhookTargetPolicy ?? {};
|
|
922
|
+
const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
|
|
923
|
+
const controller = new AbortController;
|
|
924
|
+
const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
|
|
925
|
+
try {
|
|
926
|
+
let target = new URL(normalizeWebhookUrl(webhook.url));
|
|
927
|
+
let requestHeaders = headers;
|
|
928
|
+
let method = "POST";
|
|
929
|
+
let requestBody = body;
|
|
930
|
+
let redirectsFollowed = 0;
|
|
931
|
+
for (;; ) {
|
|
932
|
+
const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
|
|
933
|
+
throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
|
|
934
|
+
});
|
|
935
|
+
const response = options.fetchImpl ? await options.fetchImpl(target, {
|
|
936
|
+
method,
|
|
937
|
+
headers: requestHeaders,
|
|
938
|
+
body: requestBody,
|
|
939
|
+
signal: controller.signal,
|
|
940
|
+
redirect: "manual"
|
|
941
|
+
}) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
|
|
942
|
+
const location = response.headers.get("location");
|
|
943
|
+
if (isRedirectStatus(response.status) && location) {
|
|
944
|
+
if (redirectsFollowed >= maxRedirects) {
|
|
945
|
+
return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
|
|
946
|
+
}
|
|
947
|
+
redirectsFollowed += 1;
|
|
948
|
+
const next = new URL(location, target);
|
|
949
|
+
target = next;
|
|
950
|
+
if (!redirectKeepsBody(response.status)) {
|
|
951
|
+
method = "GET";
|
|
952
|
+
requestBody = undefined;
|
|
953
|
+
requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
|
|
954
|
+
}
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const responseBody = truncate(await response.text());
|
|
958
|
+
return {
|
|
959
|
+
attempt: 1,
|
|
960
|
+
status: response.ok ? "success" : "failed",
|
|
961
|
+
startedAt,
|
|
962
|
+
completedAt: now(),
|
|
963
|
+
responseStatus: response.status,
|
|
964
|
+
responseBody,
|
|
965
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
} catch (error) {
|
|
969
|
+
return {
|
|
970
|
+
attempt: 1,
|
|
971
|
+
status: "failed",
|
|
972
|
+
startedAt,
|
|
973
|
+
completedAt: now(),
|
|
974
|
+
error: error instanceof Error ? error.message : String(error)
|
|
975
|
+
};
|
|
976
|
+
} finally {
|
|
977
|
+
clearTimeout(timeout);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
521
980
|
function failedAttempt(startedAt, error) {
|
|
522
981
|
return {
|
|
523
982
|
attempt: 1,
|
|
@@ -546,7 +1005,7 @@ async function dispatchCommand(event, channel) {
|
|
|
546
1005
|
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
547
1006
|
HASNA_EVENT_JSON: eventJson
|
|
548
1007
|
};
|
|
549
|
-
return new Promise((
|
|
1008
|
+
return new Promise((resolve2) => {
|
|
550
1009
|
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
551
1010
|
cwd: channel.command.cwd,
|
|
552
1011
|
env,
|
|
@@ -564,7 +1023,7 @@ async function dispatchCommand(event, channel) {
|
|
|
564
1023
|
});
|
|
565
1024
|
child.on("error", (error) => {
|
|
566
1025
|
clearTimeout(timeout);
|
|
567
|
-
|
|
1026
|
+
resolve2({
|
|
568
1027
|
attempt: 1,
|
|
569
1028
|
status: "failed",
|
|
570
1029
|
startedAt,
|
|
@@ -577,7 +1036,7 @@ async function dispatchCommand(event, channel) {
|
|
|
577
1036
|
child.on("close", (code, signal) => {
|
|
578
1037
|
clearTimeout(timeout);
|
|
579
1038
|
const success = code === 0;
|
|
580
|
-
|
|
1039
|
+
resolve2({
|
|
581
1040
|
attempt: 1,
|
|
582
1041
|
status: success ? "success" : "failed",
|
|
583
1042
|
startedAt,
|
|
@@ -1229,7 +1688,9 @@ class EventsClient {
|
|
|
1229
1688
|
this.transportOptions = {
|
|
1230
1689
|
fetchImpl: options.fetchImpl,
|
|
1231
1690
|
secretResolver: options.secretResolver,
|
|
1232
|
-
now: options.now
|
|
1691
|
+
now: options.now,
|
|
1692
|
+
tls: options.tls,
|
|
1693
|
+
webhookTargetPolicy: options.webhookTargetPolicy
|
|
1233
1694
|
};
|
|
1234
1695
|
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
1235
1696
|
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
@@ -1459,13 +1920,16 @@ export {
|
|
|
1459
1920
|
signPayload,
|
|
1460
1921
|
sanitizeChannelsForOutput,
|
|
1461
1922
|
sanitizeChannelForOutput,
|
|
1923
|
+
resolveWebhookTarget,
|
|
1462
1924
|
registerDistributionEventTypes,
|
|
1463
1925
|
redactSensitiveKeys,
|
|
1464
1926
|
redactPaths,
|
|
1927
|
+
normalizeMaxRedirects,
|
|
1465
1928
|
normalizeEventPageLimit,
|
|
1466
1929
|
matchString,
|
|
1467
1930
|
localJsonRuntime,
|
|
1468
1931
|
isTimestampWithinTolerance,
|
|
1932
|
+
isPrivateAddress,
|
|
1469
1933
|
getEventsStatus,
|
|
1470
1934
|
getEventsDataDir,
|
|
1471
1935
|
getActiveEventsDirEnv,
|
|
@@ -1482,6 +1946,7 @@ export {
|
|
|
1482
1946
|
channelMatchesEvent,
|
|
1483
1947
|
buildWebhookRequest,
|
|
1484
1948
|
buildSignatureBase,
|
|
1949
|
+
assertWebhookTargetAllowed,
|
|
1485
1950
|
assertAppEventV1ReplaySafe,
|
|
1486
1951
|
assertAppEventV1,
|
|
1487
1952
|
appEventV1ToEventInput,
|
|
@@ -1498,6 +1963,7 @@ export {
|
|
|
1498
1963
|
DISTRIBUTION_EVENT_TYPES,
|
|
1499
1964
|
DISTRIBUTION_EVENT_CONTRACT_SCHEMAS,
|
|
1500
1965
|
DEFAULT_SIGNATURE_TOLERANCE_MS,
|
|
1966
|
+
DEFAULT_MAX_REDIRECTS,
|
|
1501
1967
|
DEFAULT_EVENT_PAGE_LIMIT,
|
|
1502
1968
|
AppEventValidationError,
|
|
1503
1969
|
AppEventReplaySafetyError,
|