@daloyjs/core 0.36.0 → 0.37.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/README.md +21 -2
- 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 +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- 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 +88 -4
- 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 +20 -0
- package/dist/errors.js +27 -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 +38 -4
- package/dist/index.js +18 -1
- 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 +27 -7
- package/dist/security.js +27 -7
- 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 +76 -2
package/dist/types.d.ts
CHANGED
|
@@ -320,6 +320,39 @@ export interface RouteDefinition<P extends PathString = PathString, M extends Ht
|
|
|
320
320
|
tags?: string[];
|
|
321
321
|
deprecated?: boolean;
|
|
322
322
|
version?: string;
|
|
323
|
+
/**
|
|
324
|
+
* Mark the endpoint as scheduled for removal at a specific date (RFC 8594
|
|
325
|
+
* "The Sunset HTTP Header Field"). Accepts an ISO-8601 string, any string
|
|
326
|
+
* parseable by `new Date(...)`, or a `Date`. When set, the framework:
|
|
327
|
+
*
|
|
328
|
+
* - implicitly treats the route as {@link RouteDefinition.deprecated}
|
|
329
|
+
* (the OpenAPI operation is emitted with `deprecated: true`);
|
|
330
|
+
* - emits a `Deprecation: true` response header (RFC 8594 / the
|
|
331
|
+
* `Deprecation` HTTP header field) on every response from the route; and
|
|
332
|
+
* - emits a `Sunset: <IMF-fixdate>` response header normalized to an HTTP
|
|
333
|
+
* date so clients and gateways can schedule migration.
|
|
334
|
+
*
|
|
335
|
+
* The OpenAPI document also surfaces the normalized value as an
|
|
336
|
+
* `x-sunset` vendor extension on the operation.
|
|
337
|
+
*
|
|
338
|
+
* Invalid (unparseable) values are rejected at `app.route(...)`
|
|
339
|
+
* registration time, never per-request.
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* app.route({
|
|
344
|
+
* method: "GET",
|
|
345
|
+
* path: "/v1/legacy",
|
|
346
|
+
* deprecated: true,
|
|
347
|
+
* sunset: "2026-12-31T00:00:00Z",
|
|
348
|
+
* responses: { 200: { description: "OK" } },
|
|
349
|
+
* handler: () => ({ status: 200, body: { ok: true } }),
|
|
350
|
+
* });
|
|
351
|
+
* ```
|
|
352
|
+
*
|
|
353
|
+
* @since 0.37.0
|
|
354
|
+
*/
|
|
355
|
+
sunset?: string | Date;
|
|
323
356
|
request?: Req;
|
|
324
357
|
responses: Res;
|
|
325
358
|
auth?: AuthSpec;
|
package/dist/waf.d.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
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 type { Hooks } from "./types.js";
|
|
45
|
+
/**
|
|
46
|
+
* Identifier of a built-in WAF rule category. Each maps to a curated set of
|
|
47
|
+
* high-confidence signatures (plus, for `nosqli`, a structural body check).
|
|
48
|
+
*
|
|
49
|
+
* @since 0.37.0
|
|
50
|
+
*/
|
|
51
|
+
export type WafRuleId = "sqli" | "xss" | "nosqli" | "cmdi";
|
|
52
|
+
/**
|
|
53
|
+
* Where in the request a signature matched. Surfaced on {@link WafMatch} so
|
|
54
|
+
* operators can see whether the hit came from the path, query string, an
|
|
55
|
+
* inspected header, or the request body.
|
|
56
|
+
*
|
|
57
|
+
* @since 0.37.0
|
|
58
|
+
*/
|
|
59
|
+
export type WafInspectionLocation = "path" | "query" | "header" | "body";
|
|
60
|
+
/**
|
|
61
|
+
* Per-rule configuration. Pass a boolean to enable/disable a rule, or an object
|
|
62
|
+
* to enable it with a custom anomaly {@link WafRuleConfig.score}.
|
|
63
|
+
*
|
|
64
|
+
* @since 0.37.0
|
|
65
|
+
*/
|
|
66
|
+
export interface WafRuleConfig {
|
|
67
|
+
/** Whether the rule is active. Default: `true`. */
|
|
68
|
+
enabled?: boolean;
|
|
69
|
+
/** Anomaly score this rule contributes when it matches. Default: `5`. */
|
|
70
|
+
score?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* One rule's contribution to a flagged request. Reported (deduplicated per rule)
|
|
74
|
+
* in {@link WafEvent.matches}.
|
|
75
|
+
*
|
|
76
|
+
* @since 0.37.0
|
|
77
|
+
*/
|
|
78
|
+
export interface WafMatch {
|
|
79
|
+
/** Which rule category fired. */
|
|
80
|
+
ruleId: WafRuleId;
|
|
81
|
+
/** The anomaly score this rule contributed. */
|
|
82
|
+
score: number;
|
|
83
|
+
/** Where the first matching value was found. */
|
|
84
|
+
location: WafInspectionLocation;
|
|
85
|
+
/** A short, truncated sample of the offending value (for server-side logs). */
|
|
86
|
+
sample: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Detail of a flagged request, passed to {@link WafOptions.onMatch}. Emitted only
|
|
90
|
+
* when a request's total score reaches the block threshold (in both `"block"`
|
|
91
|
+
* and `"log"` mode), so it always represents an actionable detection.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.37.0
|
|
94
|
+
*/
|
|
95
|
+
export interface WafEvent {
|
|
96
|
+
/** The mode the middleware is running in. */
|
|
97
|
+
mode: WafMode;
|
|
98
|
+
/** Whether the request was rejected (`"block"`) or allowed through (`"log"`). */
|
|
99
|
+
action: "blocked" | "logged";
|
|
100
|
+
/** The request method. */
|
|
101
|
+
method: string;
|
|
102
|
+
/** The request path (decoded pathname). */
|
|
103
|
+
path: string;
|
|
104
|
+
/** Best-effort client IP (socket remote address), when available. */
|
|
105
|
+
clientIp: string | undefined;
|
|
106
|
+
/** Total anomaly score accumulated across all fired rules. */
|
|
107
|
+
score: number;
|
|
108
|
+
/** The threshold the score met or exceeded. */
|
|
109
|
+
threshold: number;
|
|
110
|
+
/** The deduplicated per-rule matches that drove the score. */
|
|
111
|
+
matches: readonly WafMatch[];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Enforcement mode for {@link waf}.
|
|
115
|
+
*
|
|
116
|
+
* - `"block"` (default) — reject a flagged request with a generic `403`.
|
|
117
|
+
* - `"log"` — never reject; only invoke {@link WafOptions.onMatch}. Use this to
|
|
118
|
+
* tune rules against production traffic before enforcing.
|
|
119
|
+
*
|
|
120
|
+
* @since 0.37.0
|
|
121
|
+
*/
|
|
122
|
+
export type WafMode = "block" | "log";
|
|
123
|
+
/**
|
|
124
|
+
* Selects which parts of the request are inspected. Path, query, and body are
|
|
125
|
+
* inspected by default; header inspection is opt-in via {@link WafInspectConfig.headers}.
|
|
126
|
+
*
|
|
127
|
+
* @since 0.37.0
|
|
128
|
+
*/
|
|
129
|
+
export interface WafInspectConfig {
|
|
130
|
+
/** Inspect the decoded URL pathname. Default: `true`. */
|
|
131
|
+
path?: boolean;
|
|
132
|
+
/** Inspect the raw and decoded query string. Default: `true`. */
|
|
133
|
+
query?: boolean;
|
|
134
|
+
/** Inspect the validated request body (`ctx.body`). Default: `true`. */
|
|
135
|
+
body?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Inspect a specific allowlist of request headers (lower-cased names). Header
|
|
138
|
+
* inspection is **off** unless you provide this list, because common headers
|
|
139
|
+
* (`User-Agent`, `Cookie`, `Referer`) carry punctuation that can trip
|
|
140
|
+
* signatures. Example: `["referer", "x-forwarded-host"]`.
|
|
141
|
+
*/
|
|
142
|
+
headers?: readonly string[];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Configuration for {@link waf}. Every field is optional — `waf()` ships secure,
|
|
146
|
+
* low-false-positive defaults (all four rules on at score 5, block threshold 5,
|
|
147
|
+
* block mode, path/query/body inspected, headers not).
|
|
148
|
+
*
|
|
149
|
+
* @since 0.37.0
|
|
150
|
+
*/
|
|
151
|
+
export interface WafOptions {
|
|
152
|
+
/** Enforcement mode. Default: `"block"`. */
|
|
153
|
+
mode?: WafMode;
|
|
154
|
+
/**
|
|
155
|
+
* Per-rule overrides. Any rule omitted here keeps its default (enabled, score
|
|
156
|
+
* 5). Disable a noisy rule with `{ xss: false }`, or reweight one with
|
|
157
|
+
* `{ sqli: { score: 8 } }`.
|
|
158
|
+
*/
|
|
159
|
+
rules?: Partial<Record<WafRuleId, boolean | WafRuleConfig>>;
|
|
160
|
+
/**
|
|
161
|
+
* Total anomaly score at which a request is flagged (blocked or logged).
|
|
162
|
+
* Default: `5` — any single high-confidence rule trips it. Raise it (e.g. `8`)
|
|
163
|
+
* to require two independent rule categories to fire. Must be a positive number.
|
|
164
|
+
*/
|
|
165
|
+
blockThreshold?: number;
|
|
166
|
+
/** Which request parts to inspect. See {@link WafInspectConfig}. */
|
|
167
|
+
inspect?: WafInspectConfig;
|
|
168
|
+
/**
|
|
169
|
+
* Cap on the length of any single string value that is scanned. Longer values
|
|
170
|
+
* are truncated to this prefix before matching. Default: `8192`. Must be a
|
|
171
|
+
* positive integer.
|
|
172
|
+
*/
|
|
173
|
+
maxValueLength?: number;
|
|
174
|
+
/**
|
|
175
|
+
* Cap on the number of nodes walked when inspecting the body, to bound CPU on
|
|
176
|
+
* deeply nested or huge payloads. Default: `10000`. Must be a positive integer.
|
|
177
|
+
*/
|
|
178
|
+
maxBodyNodes?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Observability callback invoked once per flagged request (in both modes),
|
|
181
|
+
* before any `403` is thrown. Receives the structured {@link WafEvent}. Must
|
|
182
|
+
* not throw.
|
|
183
|
+
*/
|
|
184
|
+
onMatch?: (event: WafEvent) => void;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Build an opt-in, scored WAF-lite inbound-inspection middleware.
|
|
188
|
+
*
|
|
189
|
+
* Inspects the decoded path, query string, an optional header allowlist, and the
|
|
190
|
+
* validated body for SQLi / XSS / NoSQL-operator / command-injection signatures.
|
|
191
|
+
* When the summed anomaly score of the rules that fire reaches
|
|
192
|
+
* {@link WafOptions.blockThreshold}, the request is rejected with a generic `403`
|
|
193
|
+
* (block mode) or reported to {@link WafOptions.onMatch} (log mode).
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* import { App, waf } from "@daloyjs/core";
|
|
197
|
+
*
|
|
198
|
+
* const app = new App();
|
|
199
|
+
*
|
|
200
|
+
* // Start in log mode to tune against real traffic, then switch to block.
|
|
201
|
+
* app.use(waf({
|
|
202
|
+
* mode: "log",
|
|
203
|
+
* onMatch: (e) => logger.warn({ waf: e }, "waf detection"),
|
|
204
|
+
* }));
|
|
205
|
+
* ```
|
|
206
|
+
*
|
|
207
|
+
* @param opts - Mode, per-rule overrides, threshold, inspection surface, and the `onMatch` hook.
|
|
208
|
+
* @returns A {@link "./types.js".Hooks} bundle exposing only a `beforeHandle` hook.
|
|
209
|
+
* @throws {TypeError} At construction when an option is invalid.
|
|
210
|
+
* @throws {ForbiddenError} Per request, in block mode, when a request is flagged.
|
|
211
|
+
* @since 0.37.0
|
|
212
|
+
*/
|
|
213
|
+
export declare function waf(opts?: WafOptions): Hooks;
|
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
|
+
}
|