@daloyjs/core 0.36.0 → 0.38.0
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/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +57 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +39 -5
- package/dist/index.js +19 -2
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +61 -7
- package/dist/security.js +75 -8
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +79 -3
package/dist/waf.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAF-lite signature/anomaly inbound-inspection middleware (OWASP CRS-lite).
|
|
3
|
+
*
|
|
4
|
+
* DaloyJS deliberately leaves a full Web Application Firewall to the operator's
|
|
5
|
+
* edge (CDN / reverse proxy / ModSecurity CRS). {@link waf} is **not** that — it
|
|
6
|
+
* is a first-party, opt-in, *defense-in-depth* layer for teams that do not have
|
|
7
|
+
* an edge WAF, wiring the framework's high-confidence injection signatures
|
|
8
|
+
* (SQLi, XSS, NoSQL-operator injection, command injection) into a single scored
|
|
9
|
+
* inbound-inspection pass with per-rule enable/disable and a block-or-log mode.
|
|
10
|
+
*
|
|
11
|
+
* Each enabled rule contributes an **anomaly score** when it matches anywhere in
|
|
12
|
+
* the inspected surface (the decoded URL path, the raw + decoded query string,
|
|
13
|
+
* an optional header allowlist, and the validated request body). When a
|
|
14
|
+
* request's total score reaches {@link WafOptions.blockThreshold}, the request is
|
|
15
|
+
* either rejected with a generic `403` (block mode, the default) or merely
|
|
16
|
+
* reported to {@link WafOptions.onMatch} (log mode) so operators can tune rules
|
|
17
|
+
* against real traffic before enforcing.
|
|
18
|
+
*
|
|
19
|
+
* Design notes / secure-by-default posture:
|
|
20
|
+
* - The `403` body is intentionally generic — it never tells the attacker which
|
|
21
|
+
* signature fired. Rule detail is delivered server-side via `onMatch` only.
|
|
22
|
+
* - Body inspection covers the **validated** body (`ctx.body`), so it composes
|
|
23
|
+
* with the framework's schema-first contract. Routes without a body schema are
|
|
24
|
+
* not body-inspected (their body is never parsed); inspect their inputs with a
|
|
25
|
+
* schema to bring them under coverage.
|
|
26
|
+
* - Header inspection is **opt-in** (off by default) because header values
|
|
27
|
+
* (notably `User-Agent` / `Cookie`) carry parentheses and punctuation that can
|
|
28
|
+
* trip signatures; enable it with an explicit allowlist.
|
|
29
|
+
* - Scanning is bounded: per-value length and total node-count caps keep a
|
|
30
|
+
* hostile or huge payload from turning inspection into CPU-DoS.
|
|
31
|
+
* - Signatures are curated for **high confidence / low false-positive rate**;
|
|
32
|
+
* this is a complement to, not a replacement for, input schemas and parameter
|
|
33
|
+
* binding. Start in `"log"` mode, watch `onMatch`, then switch to `"block"`.
|
|
34
|
+
*
|
|
35
|
+
* The middleware is dependency-free and runtime-portable. It inspects the built
|
|
36
|
+
* context in the {@link "./types.js".Hooks.beforeHandle} phase (so query, params,
|
|
37
|
+
* headers, and the validated body are all available) and reuses
|
|
38
|
+
* {@link "./security.js".hasMongoOperatorKeys} for structural NoSQL-operator
|
|
39
|
+
* detection. Register it with `app.use(waf())`.
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
* @since 0.37.0
|
|
43
|
+
*/
|
|
44
|
+
import { ForbiddenError } from "./errors.js";
|
|
45
|
+
import { hasMongoOperatorKeys } from "./security.js";
|
|
46
|
+
import { readRemoteAddress } from "./conn-info.js";
|
|
47
|
+
/** The four built-in rule categories, in stable order. */
|
|
48
|
+
const ALL_RULE_IDS = Object.freeze([
|
|
49
|
+
"sqli",
|
|
50
|
+
"xss",
|
|
51
|
+
"nosqli",
|
|
52
|
+
"cmdi",
|
|
53
|
+
]);
|
|
54
|
+
/** Default anomaly score contributed by each rule when it matches. */
|
|
55
|
+
const DEFAULT_RULE_SCORE = 5;
|
|
56
|
+
/** Default total anomaly score at which a request is blocked / reported. */
|
|
57
|
+
const DEFAULT_BLOCK_THRESHOLD = 5;
|
|
58
|
+
/** Default cap on the length of any single string value that is scanned. */
|
|
59
|
+
const DEFAULT_MAX_VALUE_LENGTH = 8192;
|
|
60
|
+
/** Default cap on the number of body nodes walked during inspection. */
|
|
61
|
+
const DEFAULT_MAX_BODY_NODES = 10_000;
|
|
62
|
+
/**
|
|
63
|
+
* Curated, high-confidence signatures per rule. Patterns are deliberately
|
|
64
|
+
* conservative (anchored on injection-specific tokens) to keep the
|
|
65
|
+
* false-positive rate low; this is a defense-in-depth complement to schemas,
|
|
66
|
+
* not an exhaustive ModSecurity CRS.
|
|
67
|
+
*/
|
|
68
|
+
const SQLI_SIGNATURES = Object.freeze([
|
|
69
|
+
/\bUNION\b[\s\S]{0,40}?\bSELECT\b/i,
|
|
70
|
+
/\b(?:OR|AND)\b\s+['"]?\d+['"]?\s*=\s*['"]?\d+/i,
|
|
71
|
+
/'\s*(?:OR|AND)\s+'?[\w]+'?\s*=\s*'?[\w]+/i,
|
|
72
|
+
/;\s*(?:DROP|DELETE|INSERT|UPDATE|TRUNCATE|ALTER|CREATE)\b/i,
|
|
73
|
+
/\b(?:SLEEP|BENCHMARK|PG_SLEEP)\s*\(/i,
|
|
74
|
+
/\bWAITFOR\s+DELAY\b/i,
|
|
75
|
+
/\bINFORMATION_SCHEMA\b/i,
|
|
76
|
+
/\bxp_cmdshell\b/i,
|
|
77
|
+
/\b(?:LOAD_FILE|OUTFILE|DUMPFILE)\b/i,
|
|
78
|
+
]);
|
|
79
|
+
const XSS_SIGNATURES = Object.freeze([
|
|
80
|
+
/<script[\s\S]{0,40}?>/i,
|
|
81
|
+
/<\/script\s*>/i,
|
|
82
|
+
/javascript:\s*\S/i,
|
|
83
|
+
/\bon(?:error|load|click|mouseover|focus|submit|toggle|animationstart)\s*=/i,
|
|
84
|
+
/<iframe[\s>]/i,
|
|
85
|
+
/<img[\s\S]{0,80}?\bonerror\s*=/i,
|
|
86
|
+
/<svg[\s\S]{0,40}?\bonload\s*=/i,
|
|
87
|
+
/<body[\s\S]{0,40}?\bonload\s*=/i,
|
|
88
|
+
/\bdocument\.cookie\b/i,
|
|
89
|
+
]);
|
|
90
|
+
const NOSQLI_SIGNATURES = Object.freeze([
|
|
91
|
+
/\$(?:ne|gt|gte|lt|lte|in|nin|where|regex|exists|elemMatch|expr|function|or|and|not)\b/i,
|
|
92
|
+
/\{\s*"?\$\w+/,
|
|
93
|
+
]);
|
|
94
|
+
const CMDI_SIGNATURES = Object.freeze([
|
|
95
|
+
/[;&|]\s*(?:cat|ls|rm|zsh|python|perl|ruby|php|powershell|pwsh|whoami|id|uname|chmod|chown|kill|nslookup|ping|nc|ncat|bash|sh|wget|curl)\b/i,
|
|
96
|
+
/\$\([\s\S]{0,60}?\)/,
|
|
97
|
+
/`[^`]{1,60}`/,
|
|
98
|
+
/\|\s*(?:nc|ncat|bash|sh|wget|curl)\b/i,
|
|
99
|
+
/&&\s*(?:cat|ls|rm|whoami|id|wget|curl)\b/i,
|
|
100
|
+
/\/(?:etc\/passwd|etc\/shadow|bin\/sh|bin\/bash)\b/i,
|
|
101
|
+
]);
|
|
102
|
+
const SIGNATURES = Object.freeze({
|
|
103
|
+
sqli: SQLI_SIGNATURES,
|
|
104
|
+
xss: XSS_SIGNATURES,
|
|
105
|
+
nosqli: NOSQLI_SIGNATURES,
|
|
106
|
+
cmdi: CMDI_SIGNATURES,
|
|
107
|
+
});
|
|
108
|
+
function assertPositiveInteger(value, label) {
|
|
109
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
110
|
+
throw new TypeError(`waf(): \`${label}\` must be a positive integer, received ${String(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the per-rule config into the active rule set, applying defaults and
|
|
115
|
+
* overrides and validating any custom scores.
|
|
116
|
+
*/
|
|
117
|
+
function resolveRules(overrides) {
|
|
118
|
+
const resolved = [];
|
|
119
|
+
for (const ruleId of ALL_RULE_IDS) {
|
|
120
|
+
const override = overrides?.[ruleId];
|
|
121
|
+
let enabled = true;
|
|
122
|
+
let score = DEFAULT_RULE_SCORE;
|
|
123
|
+
if (override === false) {
|
|
124
|
+
enabled = false;
|
|
125
|
+
}
|
|
126
|
+
else if (override === true || override === undefined) {
|
|
127
|
+
// keep defaults
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
enabled = override.enabled ?? true;
|
|
131
|
+
if (override.score !== undefined) {
|
|
132
|
+
if (!Number.isFinite(override.score) || override.score <= 0) {
|
|
133
|
+
throw new TypeError(`waf(): \`rules.${ruleId}.score\` must be a positive number, received ${String(override.score)}`);
|
|
134
|
+
}
|
|
135
|
+
score = override.score;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (enabled)
|
|
139
|
+
resolved.push({ ruleId, score, signatures: SIGNATURES[ruleId] });
|
|
140
|
+
}
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
143
|
+
/** Truncate a value to `maxLen` for safe inclusion in a server-side log sample. */
|
|
144
|
+
function sample(value) {
|
|
145
|
+
const trimmed = value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
|
146
|
+
// Strip control characters so a log sink can't be tricked by embedded
|
|
147
|
+
// newlines / escapes carried straight from the attacker's payload.
|
|
148
|
+
return trimmed.replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
149
|
+
}
|
|
150
|
+
/** Best-effort URL-decode; return the original string if decoding throws. */
|
|
151
|
+
function safeDecode(value) {
|
|
152
|
+
try {
|
|
153
|
+
return decodeURIComponent(value);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Collect up to `maxNodes` string values from a parsed body value (object /
|
|
161
|
+
* array / scalar), each truncated to `maxValueLength`. Depth and node count are
|
|
162
|
+
* bounded so a hostile payload cannot turn inspection into CPU-DoS. Prototype
|
|
163
|
+
* keys are never followed (only own enumerable properties are walked).
|
|
164
|
+
*/
|
|
165
|
+
function collectBodyStrings(root, maxNodes, maxValueLength) {
|
|
166
|
+
const out = [];
|
|
167
|
+
const stack = [root];
|
|
168
|
+
let visited = 0;
|
|
169
|
+
while (stack.length > 0 && visited < maxNodes) {
|
|
170
|
+
const node = stack.pop();
|
|
171
|
+
visited++;
|
|
172
|
+
if (typeof node === "string") {
|
|
173
|
+
out.push(node.length > maxValueLength ? node.slice(0, maxValueLength) : node);
|
|
174
|
+
}
|
|
175
|
+
else if (Array.isArray(node)) {
|
|
176
|
+
for (let i = node.length - 1; i >= 0; i--)
|
|
177
|
+
stack.push(node[i]);
|
|
178
|
+
}
|
|
179
|
+
else if (node && typeof node === "object") {
|
|
180
|
+
// Also scan own string keys — an injected `$where` can hide in a key.
|
|
181
|
+
for (const key of Object.keys(node)) {
|
|
182
|
+
out.push(key.length > maxValueLength ? key.slice(0, maxValueLength) : key);
|
|
183
|
+
stack.push(node[key]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Run every enabled rule's signatures against a single value. Records the first
|
|
191
|
+
* location/sample per rule into `matches` and accumulates the rule's score into
|
|
192
|
+
* `scored` (so a rule contributes its score at most once per request).
|
|
193
|
+
*/
|
|
194
|
+
function scanValue(value, location, rules, scored) {
|
|
195
|
+
for (const rule of rules) {
|
|
196
|
+
if (scored.has(rule.ruleId))
|
|
197
|
+
continue;
|
|
198
|
+
for (const signature of rule.signatures) {
|
|
199
|
+
if (signature.test(value)) {
|
|
200
|
+
scored.set(rule.ruleId, {
|
|
201
|
+
ruleId: rule.ruleId,
|
|
202
|
+
score: rule.score,
|
|
203
|
+
location,
|
|
204
|
+
sample: sample(value),
|
|
205
|
+
});
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Build an opt-in, scored WAF-lite inbound-inspection middleware.
|
|
213
|
+
*
|
|
214
|
+
* Inspects the decoded path, query string, an optional header allowlist, and the
|
|
215
|
+
* validated body for SQLi / XSS / NoSQL-operator / command-injection signatures.
|
|
216
|
+
* When the summed anomaly score of the rules that fire reaches
|
|
217
|
+
* {@link WafOptions.blockThreshold}, the request is rejected with a generic `403`
|
|
218
|
+
* (block mode) or reported to {@link WafOptions.onMatch} (log mode).
|
|
219
|
+
*
|
|
220
|
+
* ```ts
|
|
221
|
+
* import { App, waf } from "@daloyjs/core";
|
|
222
|
+
*
|
|
223
|
+
* const app = new App();
|
|
224
|
+
*
|
|
225
|
+
* // Start in log mode to tune against real traffic, then switch to block.
|
|
226
|
+
* app.use(waf({
|
|
227
|
+
* mode: "log",
|
|
228
|
+
* onMatch: (e) => logger.warn({ waf: e }, "waf detection"),
|
|
229
|
+
* }));
|
|
230
|
+
* ```
|
|
231
|
+
*
|
|
232
|
+
* @param opts - Mode, per-rule overrides, threshold, inspection surface, and the `onMatch` hook.
|
|
233
|
+
* @returns A {@link "./types.js".Hooks} bundle exposing only a `beforeHandle` hook.
|
|
234
|
+
* @throws {TypeError} At construction when an option is invalid.
|
|
235
|
+
* @throws {ForbiddenError} Per request, in block mode, when a request is flagged.
|
|
236
|
+
* @since 0.37.0
|
|
237
|
+
*/
|
|
238
|
+
export function waf(opts = {}) {
|
|
239
|
+
const mode = opts.mode ?? "block";
|
|
240
|
+
if (mode !== "block" && mode !== "log") {
|
|
241
|
+
throw new TypeError(`waf(): \`mode\` must be "block" or "log", received ${String(mode)}`);
|
|
242
|
+
}
|
|
243
|
+
const blockThreshold = opts.blockThreshold ?? DEFAULT_BLOCK_THRESHOLD;
|
|
244
|
+
if (!Number.isFinite(blockThreshold) || blockThreshold <= 0) {
|
|
245
|
+
throw new TypeError(`waf(): \`blockThreshold\` must be a positive number, received ${String(blockThreshold)}`);
|
|
246
|
+
}
|
|
247
|
+
const maxValueLength = opts.maxValueLength ?? DEFAULT_MAX_VALUE_LENGTH;
|
|
248
|
+
assertPositiveInteger(maxValueLength, "maxValueLength");
|
|
249
|
+
const maxBodyNodes = opts.maxBodyNodes ?? DEFAULT_MAX_BODY_NODES;
|
|
250
|
+
assertPositiveInteger(maxBodyNodes, "maxBodyNodes");
|
|
251
|
+
const rules = resolveRules(opts.rules);
|
|
252
|
+
const nosqliRule = rules.find((r) => r.ruleId === "nosqli");
|
|
253
|
+
const inspectPath = opts.inspect?.path ?? true;
|
|
254
|
+
const inspectQuery = opts.inspect?.query ?? true;
|
|
255
|
+
const inspectBody = opts.inspect?.body ?? true;
|
|
256
|
+
const headerAllowlist = (opts.inspect?.headers ?? []).map((h) => h.toLowerCase());
|
|
257
|
+
const onMatch = opts.onMatch;
|
|
258
|
+
return {
|
|
259
|
+
beforeHandle(ctx) {
|
|
260
|
+
// Nothing enabled — pay nothing.
|
|
261
|
+
if (rules.length === 0)
|
|
262
|
+
return;
|
|
263
|
+
const scored = new Map();
|
|
264
|
+
const url = new URL(ctx.request.url);
|
|
265
|
+
if (inspectPath) {
|
|
266
|
+
scanValue(safeDecode(url.pathname), "path", rules, scored);
|
|
267
|
+
}
|
|
268
|
+
if (inspectQuery && url.search.length > 1) {
|
|
269
|
+
// Scan both the raw query string and a best-effort decoded form so an
|
|
270
|
+
// encoded payload (`%27%20OR%201=1`) is caught after normalization.
|
|
271
|
+
const raw = url.search.slice(1);
|
|
272
|
+
scanValue(raw, "query", rules, scored);
|
|
273
|
+
const decoded = safeDecode(raw);
|
|
274
|
+
if (decoded !== raw)
|
|
275
|
+
scanValue(decoded, "query", rules, scored);
|
|
276
|
+
}
|
|
277
|
+
if (headerAllowlist.length > 0) {
|
|
278
|
+
for (const name of headerAllowlist) {
|
|
279
|
+
const value = ctx.request.headers.get(name);
|
|
280
|
+
if (value)
|
|
281
|
+
scanValue(value, "header", rules, scored);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (inspectBody && ctx.body !== undefined && ctx.body !== null) {
|
|
285
|
+
// Structural NoSQL-operator detection on the parsed body — catches
|
|
286
|
+
// `{"password": {"$ne": null}}` even though no string value matches.
|
|
287
|
+
if (nosqliRule &&
|
|
288
|
+
!scored.has("nosqli") &&
|
|
289
|
+
typeof ctx.body === "object" &&
|
|
290
|
+
hasMongoOperatorKeys(ctx.body)) {
|
|
291
|
+
scored.set("nosqli", {
|
|
292
|
+
ruleId: "nosqli",
|
|
293
|
+
score: nosqliRule.score,
|
|
294
|
+
location: "body",
|
|
295
|
+
sample: "$-prefixed operator key",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (typeof ctx.body === "string") {
|
|
299
|
+
scanValue(ctx.body.length > maxValueLength
|
|
300
|
+
? ctx.body.slice(0, maxValueLength)
|
|
301
|
+
: ctx.body, "body", rules, scored);
|
|
302
|
+
}
|
|
303
|
+
else if (typeof ctx.body === "object") {
|
|
304
|
+
const strings = collectBodyStrings(ctx.body, maxBodyNodes, maxValueLength);
|
|
305
|
+
for (const value of strings)
|
|
306
|
+
scanValue(value, "body", rules, scored);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (scored.size === 0)
|
|
310
|
+
return;
|
|
311
|
+
let total = 0;
|
|
312
|
+
for (const match of scored.values())
|
|
313
|
+
total += match.score;
|
|
314
|
+
if (total < blockThreshold)
|
|
315
|
+
return;
|
|
316
|
+
const matches = Array.from(scored.values());
|
|
317
|
+
const event = {
|
|
318
|
+
mode,
|
|
319
|
+
action: mode === "block" ? "blocked" : "logged",
|
|
320
|
+
method: ctx.request.method,
|
|
321
|
+
path: url.pathname,
|
|
322
|
+
clientIp: readRemoteAddress(ctx),
|
|
323
|
+
score: total,
|
|
324
|
+
threshold: blockThreshold,
|
|
325
|
+
matches,
|
|
326
|
+
};
|
|
327
|
+
onMatch?.(event);
|
|
328
|
+
if (mode === "block") {
|
|
329
|
+
// Generic detail — never disclose which signature fired to the client.
|
|
330
|
+
throw new ForbiddenError("Request blocked by security policy");
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound webhook delivery — the sending counterpart to the inbound
|
|
3
|
+
* {@link verifyWebhookSignature} / {@link signWebhookPayload} helpers.
|
|
4
|
+
*
|
|
5
|
+
* Where the inbound helpers answer *“is this webhook I received
|
|
6
|
+
* authentic?”*, this module answers *“how do I reliably and
|
|
7
|
+
* securely deliver a webhook to someone else?”* — the three things a
|
|
8
|
+
* production webhook sender needs:
|
|
9
|
+
*
|
|
10
|
+
* - **Signed delivery with timestamped signatures.** Every request carries
|
|
11
|
+
* an HMAC signature computed over `"<timestamp>.<body>"` (the Stripe /
|
|
12
|
+
* Standard Webhooks convention), plus an idempotency id and a timestamp
|
|
13
|
+
* header, so the receiver can authenticate the payload and reject
|
|
14
|
+
* replays with {@link verifyWebhookSignature}.
|
|
15
|
+
* - **Retry with backoff.** Transient failures (network errors, timeouts,
|
|
16
|
+
* `408` / `429` / `5xx`) are retried with exponential backoff and full
|
|
17
|
+
* jitter, honouring a `Retry-After` header. The signature is computed
|
|
18
|
+
* **once** so every retry carries the same id and signature — the
|
|
19
|
+
* receiver can dedupe on the id.
|
|
20
|
+
* - **Dead-letter semantics.** When every attempt is exhausted (or the
|
|
21
|
+
* upstream returns a permanent `4xx`), the failed delivery is handed to
|
|
22
|
+
* a {@link WebhookDeadLetterSink} for later inspection or replay instead
|
|
23
|
+
* of being silently dropped.
|
|
24
|
+
*
|
|
25
|
+
* Delivery is **SSRF-hardened by default**: the transport defaults to
|
|
26
|
+
* {@link fetchGuard}, so a webhook URL that resolves to cloud-metadata or
|
|
27
|
+
* an internal address is refused before any bytes are sent. Pass your own
|
|
28
|
+
* `fetch` (e.g. `fetchGuard({ allowPrivate: true })` or a
|
|
29
|
+
* {@link resilientFetch}) to change that posture deliberately.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createWebhookSender, MemoryWebhookDeadLetterSink } from "@daloyjs/core";
|
|
33
|
+
*
|
|
34
|
+
* const deadLetters = new MemoryWebhookDeadLetterSink();
|
|
35
|
+
* const send = createWebhookSender({ secret: process.env.WEBHOOK_SECRET!, deadLetter: deadLetters });
|
|
36
|
+
*
|
|
37
|
+
* const result = await send({
|
|
38
|
+
* url: "https://example.com/hooks",
|
|
39
|
+
* eventType: "invoice.paid",
|
|
40
|
+
* payload: { id: "in_123", amount: 4200 },
|
|
41
|
+
* });
|
|
42
|
+
* if (!result.ok) {
|
|
43
|
+
* // result.deadLettered === true; inspect deadLetters.list()
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @module
|
|
48
|
+
* @since 0.37.0
|
|
49
|
+
*/
|
|
50
|
+
import { type WebhookHmacAlgorithm } from "./security.js";
|
|
51
|
+
/**
|
|
52
|
+
* A single webhook event to deliver. The `payload` is signed and sent as
|
|
53
|
+
* the request body; everything else shapes the request and the signature
|
|
54
|
+
* headers.
|
|
55
|
+
*
|
|
56
|
+
* @since 0.37.0
|
|
57
|
+
*/
|
|
58
|
+
export interface WebhookEvent {
|
|
59
|
+
/** Absolute `http(s)` URL of the receiver. */
|
|
60
|
+
url: string;
|
|
61
|
+
/**
|
|
62
|
+
* The event body. An object or array is JSON-serialised; a `string` is
|
|
63
|
+
* sent verbatim; a `Uint8Array` is sent as raw bytes. The signature is
|
|
64
|
+
* always computed over the exact bytes sent.
|
|
65
|
+
*/
|
|
66
|
+
payload: unknown;
|
|
67
|
+
/**
|
|
68
|
+
* Optional event type (e.g. `"invoice.paid"`), emitted as a header and
|
|
69
|
+
* recorded on the dead letter. Purely informational.
|
|
70
|
+
*/
|
|
71
|
+
eventType?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Stable idempotency id, emitted in the id header so the receiver can
|
|
74
|
+
* dedupe retries. A random UUID is generated when omitted.
|
|
75
|
+
*/
|
|
76
|
+
id?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Extra request headers merged in **after** the signature headers, so
|
|
79
|
+
* they cannot overwrite the id / timestamp / signature headers.
|
|
80
|
+
*/
|
|
81
|
+
headers?: Record<string, string>;
|
|
82
|
+
/**
|
|
83
|
+
* Override the `Content-Type` header. Defaults to `application/json`
|
|
84
|
+
* for objects / strings and `application/octet-stream` for bytes.
|
|
85
|
+
*/
|
|
86
|
+
contentType?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A failed delivery handed to a {@link WebhookDeadLetterSink} after every
|
|
90
|
+
* attempt is exhausted. Carries enough context to inspect, alert on, or
|
|
91
|
+
* replay the delivery later.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.37.0
|
|
94
|
+
*/
|
|
95
|
+
export interface WebhookDeadLetter {
|
|
96
|
+
/** The idempotency id used for the delivery. */
|
|
97
|
+
id: string;
|
|
98
|
+
/** The receiver URL. */
|
|
99
|
+
url: string;
|
|
100
|
+
/** The event type, when one was supplied. */
|
|
101
|
+
eventType?: string;
|
|
102
|
+
/** The exact body bytes that were signed and sent. */
|
|
103
|
+
payload: Uint8Array;
|
|
104
|
+
/** The `Content-Type` that was sent. */
|
|
105
|
+
contentType: string;
|
|
106
|
+
/** Total number of attempts made before giving up. */
|
|
107
|
+
attempts: number;
|
|
108
|
+
/** The last HTTP status seen, when the final failure was a response. */
|
|
109
|
+
lastStatus?: number;
|
|
110
|
+
/** The last error message, when the final failure was a thrown error. */
|
|
111
|
+
lastError?: string;
|
|
112
|
+
/** The Unix-seconds timestamp bound into the signature. */
|
|
113
|
+
timestamp: number;
|
|
114
|
+
/** Wall-clock time (ms since epoch) the delivery was dead-lettered. */
|
|
115
|
+
failedAt: number;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A sink that receives {@link WebhookDeadLetter}s for permanently-failed
|
|
119
|
+
* deliveries. Implement this to persist to a queue, database, or alerting
|
|
120
|
+
* pipeline. `add` may be async; the sender awaits it.
|
|
121
|
+
*
|
|
122
|
+
* @since 0.37.0
|
|
123
|
+
*/
|
|
124
|
+
export interface WebhookDeadLetterSink {
|
|
125
|
+
add(letter: WebhookDeadLetter): void | Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* An in-memory, bounded {@link WebhookDeadLetterSink} suitable for tests
|
|
129
|
+
* and single-process apps. Holds the most recent `capacity` dead letters
|
|
130
|
+
* (default `1000`) in a ring buffer; older entries are evicted.
|
|
131
|
+
*
|
|
132
|
+
* @since 0.37.0
|
|
133
|
+
*/
|
|
134
|
+
export declare class MemoryWebhookDeadLetterSink implements WebhookDeadLetterSink {
|
|
135
|
+
#private;
|
|
136
|
+
constructor(capacity?: number);
|
|
137
|
+
/** Append a dead letter, evicting the oldest if at capacity. */
|
|
138
|
+
add(letter: WebhookDeadLetter): void;
|
|
139
|
+
/** A snapshot of the currently-held dead letters, oldest first. */
|
|
140
|
+
list(): readonly WebhookDeadLetter[];
|
|
141
|
+
/** Remove and return every held dead letter (e.g. for a replay sweep). */
|
|
142
|
+
drain(): WebhookDeadLetter[];
|
|
143
|
+
/** The number of dead letters currently held. */
|
|
144
|
+
get size(): number;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Per-attempt telemetry passed to {@link WebhookSenderOptions.onAttempt}.
|
|
148
|
+
*
|
|
149
|
+
* @since 0.37.0
|
|
150
|
+
*/
|
|
151
|
+
export interface WebhookAttempt {
|
|
152
|
+
/** The idempotency id of the delivery. */
|
|
153
|
+
id: string;
|
|
154
|
+
/** 1-based attempt number. */
|
|
155
|
+
attempt: number;
|
|
156
|
+
/** The HTTP status, when the attempt produced a response. */
|
|
157
|
+
status?: number;
|
|
158
|
+
/** The error, when the attempt threw (network error / timeout). */
|
|
159
|
+
error?: unknown;
|
|
160
|
+
/** Whether the sender will retry after this attempt. */
|
|
161
|
+
willRetry: boolean;
|
|
162
|
+
/** The backoff delay (ms) before the next attempt, when retrying. */
|
|
163
|
+
delayMs?: number;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The outcome of a {@link createWebhookSender} delivery. Never throws for
|
|
167
|
+
* an ordinary delivery failure — inspect `ok` / `deadLettered` instead.
|
|
168
|
+
*
|
|
169
|
+
* @since 0.37.0
|
|
170
|
+
*/
|
|
171
|
+
export interface WebhookDeliveryResult {
|
|
172
|
+
/** `true` when the receiver returned a 2xx response. */
|
|
173
|
+
ok: boolean;
|
|
174
|
+
/** The idempotency id used for the delivery. */
|
|
175
|
+
id: string;
|
|
176
|
+
/** The event type, when one was supplied. */
|
|
177
|
+
eventType?: string;
|
|
178
|
+
/** Total number of attempts made. */
|
|
179
|
+
attempts: number;
|
|
180
|
+
/** The final HTTP status, when the last attempt produced a response. */
|
|
181
|
+
status?: number;
|
|
182
|
+
/** The final response object, when the last attempt produced one. */
|
|
183
|
+
response?: Response;
|
|
184
|
+
/** The final error, when the last attempt threw. */
|
|
185
|
+
error?: unknown;
|
|
186
|
+
/** Whether the failed delivery was handed to the dead-letter sink. */
|
|
187
|
+
deadLettered: boolean;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Configuration for {@link createWebhookSender}. Only `secret` is
|
|
191
|
+
* required; every other field has a production-safe default.
|
|
192
|
+
*
|
|
193
|
+
* @since 0.37.0
|
|
194
|
+
*/
|
|
195
|
+
export interface WebhookSenderOptions {
|
|
196
|
+
/** HMAC secret used to sign every delivery. */
|
|
197
|
+
secret: string | Uint8Array;
|
|
198
|
+
/** HMAC digest. Default `"sha256"`. */
|
|
199
|
+
algorithm?: WebhookHmacAlgorithm;
|
|
200
|
+
/**
|
|
201
|
+
* Transport. Defaults to {@link fetchGuard} so webhook URLs that resolve
|
|
202
|
+
* to internal / cloud-metadata addresses are refused (SSRF defence).
|
|
203
|
+
* Pass your own to relax or extend that posture.
|
|
204
|
+
*/
|
|
205
|
+
fetch?: typeof fetch;
|
|
206
|
+
/** Maximum total attempts (first try + retries). Default `5`. */
|
|
207
|
+
maxAttempts?: number;
|
|
208
|
+
/** Base backoff for the first retry, in ms. Default `500`. */
|
|
209
|
+
retryDelayMs?: number;
|
|
210
|
+
/** Upper bound on any single backoff delay, in ms. Default `30_000`. */
|
|
211
|
+
maxRetryDelayMs?: number;
|
|
212
|
+
/** Exponential backoff multiplier. Default `2`. */
|
|
213
|
+
backoffFactor?: number;
|
|
214
|
+
/** Apply full jitter to backoff. Default `true`. */
|
|
215
|
+
jitter?: boolean;
|
|
216
|
+
/** Per-attempt timeout, in ms. `0` disables. Default `10_000`. */
|
|
217
|
+
timeoutMs?: number;
|
|
218
|
+
/**
|
|
219
|
+
* Response statuses that trigger a retry. Default
|
|
220
|
+
* `[408, 429, 500, 502, 503, 504]`. Any other non-2xx status is a
|
|
221
|
+
* permanent failure (dead-lettered immediately).
|
|
222
|
+
*/
|
|
223
|
+
retryableStatuses?: readonly number[];
|
|
224
|
+
/** Honour a `Retry-After` header on a retryable response. Default `true`. */
|
|
225
|
+
respectRetryAfter?: boolean;
|
|
226
|
+
/** Header carrying the idempotency id. Default `"webhook-id"`. */
|
|
227
|
+
idHeader?: string;
|
|
228
|
+
/** Header carrying the Unix-seconds timestamp. Default `"webhook-timestamp"`. */
|
|
229
|
+
timestampHeader?: string;
|
|
230
|
+
/** Header carrying the signature. Default `"webhook-signature"`. */
|
|
231
|
+
signatureHeader?: string;
|
|
232
|
+
/** Header carrying the event type. Default `"webhook-event-type"`. */
|
|
233
|
+
eventTypeHeader?: string;
|
|
234
|
+
/** `User-Agent` sent with every delivery. Default `"DaloyJS-Webhook/1.0"`. */
|
|
235
|
+
userAgent?: string;
|
|
236
|
+
/** Sink for permanently-failed deliveries. */
|
|
237
|
+
deadLetter?: WebhookDeadLetterSink;
|
|
238
|
+
/** Clock (ms since epoch). Default {@link Date.now}. Override in tests. */
|
|
239
|
+
now?: () => number;
|
|
240
|
+
/**
|
|
241
|
+
* Abortable sleep, primarily for deterministic tests. Defaults to a
|
|
242
|
+
* `setTimeout`-based sleep.
|
|
243
|
+
*/
|
|
244
|
+
sleep?: (ms: number) => Promise<void>;
|
|
245
|
+
/** Per-attempt observer (e.g. to emit a metric). */
|
|
246
|
+
onAttempt?: (attempt: WebhookAttempt) => void;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Build a webhook sender bound to a signing secret and delivery policy.
|
|
250
|
+
* The returned `send(event)` function signs, delivers, retries, and
|
|
251
|
+
* dead-letters a single {@link WebhookEvent}, resolving to a
|
|
252
|
+
* {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
|
|
253
|
+
* failure).
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* const send = createWebhookSender({ secret: process.env.WEBHOOK_SECRET! });
|
|
258
|
+
* const result = await send({ url, eventType: "user.created", payload: { id } });
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* @since 0.37.0
|
|
262
|
+
*/
|
|
263
|
+
export declare function createWebhookSender(options: WebhookSenderOptions): (event: WebhookEvent) => Promise<WebhookDeliveryResult>;
|