@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -3
  3. package/bin/daloy.mjs +2 -0
  4. package/dist/adapters/bun.js +16 -9
  5. package/dist/adapters/deno.js +7 -1
  6. package/dist/adapters/node.d.ts +25 -0
  7. package/dist/adapters/node.js +32 -0
  8. package/dist/app.d.ts +200 -6
  9. package/dist/app.js +235 -50
  10. package/dist/asyncapi.d.ts +98 -0
  11. package/dist/asyncapi.js +212 -0
  12. package/dist/auto-ban.d.ts +205 -0
  13. package/dist/auto-ban.js +222 -0
  14. package/dist/bot-guard.d.ts +209 -0
  15. package/dist/bot-guard.js +291 -0
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.js +113 -4
  18. package/dist/client.d.ts +23 -0
  19. package/dist/client.js +16 -0
  20. package/dist/concurrency-limit.d.ts +135 -0
  21. package/dist/concurrency-limit.js +254 -0
  22. package/dist/docs.d.ts +57 -6
  23. package/dist/docs.js +34 -3
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.js +57 -0
  26. package/dist/fetch-guard.js +4 -0
  27. package/dist/fetch-resilience.d.ts +295 -0
  28. package/dist/fetch-resilience.js +485 -0
  29. package/dist/geo-block.d.ts +184 -0
  30. package/dist/geo-block.js +153 -0
  31. package/dist/hashing.d.ts +2 -1
  32. package/dist/hashing.js +12 -1
  33. package/dist/http-signatures.d.ts +303 -0
  34. package/dist/http-signatures.js +782 -0
  35. package/dist/idempotency.d.ts +204 -0
  36. package/dist/idempotency.js +341 -0
  37. package/dist/index.d.ts +39 -5
  38. package/dist/index.js +19 -2
  39. package/dist/ip-reputation.d.ts +198 -0
  40. package/dist/ip-reputation.js +253 -0
  41. package/dist/jwk.d.ts +15 -0
  42. package/dist/jwk.js +24 -2
  43. package/dist/load-shedding.d.ts +5 -0
  44. package/dist/logger.js +6 -2
  45. package/dist/metrics.d.ts +208 -0
  46. package/dist/metrics.js +452 -0
  47. package/dist/middleware.js +0 -10
  48. package/dist/mtls.d.ts +266 -0
  49. package/dist/mtls.js +488 -0
  50. package/dist/multipart.js +1 -1
  51. package/dist/openapi-diff.d.ts +79 -0
  52. package/dist/openapi-diff.js +246 -0
  53. package/dist/openapi.js +4 -1
  54. package/dist/pagination.d.ts +210 -0
  55. package/dist/pagination.js +353 -0
  56. package/dist/rate-limit-redis.d.ts +8 -0
  57. package/dist/rate-limit-redis.js +8 -0
  58. package/dist/request-decompression.d.ts +200 -0
  59. package/dist/request-decompression.js +363 -0
  60. package/dist/response-cache.d.ts +205 -0
  61. package/dist/response-cache.js +374 -0
  62. package/dist/router.d.ts +22 -0
  63. package/dist/router.js +64 -7
  64. package/dist/safe-redirect.d.ts +2 -2
  65. package/dist/safe-redirect.js +3 -8
  66. package/dist/sbom.cdx.json +9 -9
  67. package/dist/sbom.spdx.json +5 -5
  68. package/dist/scheduler.d.ts +315 -0
  69. package/dist/scheduler.js +546 -0
  70. package/dist/security.d.ts +61 -7
  71. package/dist/security.js +75 -8
  72. package/dist/session.js +3 -3
  73. package/dist/types.d.ts +33 -0
  74. package/dist/waf.d.ts +213 -0
  75. package/dist/waf.js +334 -0
  76. package/dist/webhook-delivery.d.ts +263 -0
  77. package/dist/webhook-delivery.js +311 -0
  78. package/dist/websocket.d.ts +52 -0
  79. package/dist/websocket.js +13 -0
  80. package/package.json +79 -3
package/dist/security.js CHANGED
@@ -5,10 +5,10 @@
5
5
  * - safeJsonParse: JSON parser that strips __proto__ / constructor / prototype
6
6
  * keys to prevent prototype-pollution attacks.
7
7
  * - sanitizeHeaderName / sanitizeHeaderValue: prevent CRLF header injection.
8
- * - timingSafeEqual: constant-time string comparison for token checks.
8
+ * - timingSafeEqual: length-independent string compare for fixed-length token checks.
9
9
  * - randomId: cryptographically strong request id.
10
10
  */
11
- import { PayloadTooLargeError, BadRequestError, } from "./errors.js";
11
+ import { PayloadTooLargeError, BadRequestError, RequestHeaderFieldsTooLargeError, } from "./errors.js";
12
12
  // Resolved once at module load. Mirror of `DALOY_REQUEST_RAW_BODY` in
13
13
  // app.ts; defined here via the global Symbol registry to avoid an import
14
14
  // cycle (app.ts -> security.ts). Adapters attach a pre-validated
@@ -160,12 +160,32 @@ export function sanitizeHeaderValue(value) {
160
160
  return value;
161
161
  }
162
162
  /**
163
- * Constant-time string comparison resistant to timing attacks. Use whenever
164
- * comparing secrets such as CSRF tokens, HMAC signatures, or API keys; never
165
- * use `===` for those comparisons.
166
- *
167
- * @param a - First string.
168
- * @param b - Second string.
163
+ * Length-independent string comparison resistant to the *first-mismatch*
164
+ * timing leak. Use whenever comparing secrets such as CSRF tokens, HMAC
165
+ * signatures, or API keys; never use `===` for those comparisons.
166
+ *
167
+ * The comparison always folds every character of the longer input into a
168
+ * single accumulator (no early return), so it does not reveal the position
169
+ * of the first differing character the way `===` does. The byte lengths are
170
+ * mixed in too, so inputs of different lengths can never compare equal.
171
+ *
172
+ * Caveats — read before using for anything other than fixed-length tokens:
173
+ *
174
+ * - **Length is not hidden.** The loop runs `max(a.length, b.length)`
175
+ * iterations, so the running time grows with the longer input. Intended
176
+ * for values whose length is fixed and public (hex/base64 tokens, HMAC
177
+ * digests, API keys). Do not rely on it to conceal the length of a
178
+ * secret from an attacker who controls the other side.
179
+ * - **Not a hardware constant-time primitive.** It compares UTF-16 code
180
+ * units via `charCodeAt`, and the engine's per-character access time is
181
+ * not provably uniform. For raw bytes you already hold in memory, prefer
182
+ * Node's `crypto.timingSafeEqual(Buffer, Buffer)`, which also rejects
183
+ * length mismatches outright.
184
+ * - **Compares code units, not bytes.** Fine for ASCII tokens; for
185
+ * arbitrary binary, compare `Uint8Array`s instead.
186
+ *
187
+ * @param a - First string (typically the attacker-supplied candidate).
188
+ * @param b - Second string (typically the expected secret).
169
189
  * @returns `true` when the strings have the same length and contents.
170
190
  * @since 0.1.0
171
191
  */
@@ -301,6 +321,53 @@ export function assertNoReservedInternalHeaders(headers) {
301
321
  }
302
322
  });
303
323
  }
324
+ /**
325
+ * Default cap on the number of distinct request header fields accepted
326
+ * before {@link assertHeaderCountWithinLimit} rejects the request. Chosen to
327
+ * sit far above any realistic legitimate request (browsers, tracing layers,
328
+ * and reverse proxies rarely add more than a few dozen headers) yet far
329
+ * below the thousands-of-headers floods used by header-count amplification
330
+ * attacks such as the "HTTP/2 Bomb".
331
+ *
332
+ * @since 0.38.0
333
+ */
334
+ export const DEFAULT_MAX_HEADER_COUNT = 100;
335
+ /**
336
+ * Reject requests that carry more than `limit` distinct header fields.
337
+ *
338
+ * This is the runtime-portable, application-tier defence against
339
+ * header-*count* amplification (the dimension abused by the "HTTP/2 Bomb",
340
+ * where per-entry server bookkeeping — not header size — is the amplifier).
341
+ * It complements, and does not replace, the native header-count caps that a
342
+ * runtime/proxy terminating HTTP/2 must apply (NGINX `max_headers`, Node
343
+ * `server.maxHeadersCount`, etc.). Because the WHATWG `Headers` collection
344
+ * coalesces same-named fields, the count is over distinct header names —
345
+ * the truest signal available once a request has been normalised to a
346
+ * web-standard `Request`.
347
+ *
348
+ * A `limit` of `0` (or any non-positive / non-finite value) disables the
349
+ * check. Throws {@link RequestHeaderFieldsTooLargeError} (`431`) so the
350
+ * framework returns a structured `problem+json` response instead of routing
351
+ * a flood.
352
+ *
353
+ * @param headers - The incoming request headers.
354
+ * @param limit - Maximum distinct header fields to allow. `0` disables.
355
+ * @since 0.38.0
356
+ */
357
+ export function assertHeaderCountWithinLimit(headers, limit) {
358
+ if (!(limit > 0) || !Number.isFinite(limit))
359
+ return;
360
+ let count = 0;
361
+ // Count via forEach (not keys()) so a same-named coalesced field counts
362
+ // once and the check works on any Headers-shaped object. Throwing from the
363
+ // callback bails the instant the cap is crossed, so a flood pays for at
364
+ // most `limit + 1` iterations rather than walking the whole set.
365
+ headers.forEach(() => {
366
+ if (++count > limit) {
367
+ throw new RequestHeaderFieldsTooLargeError(limit);
368
+ }
369
+ });
370
+ }
304
371
  /**
305
372
  * Minimum acceptable secret length in bytes for HMAC / signing material in
306
373
  * production (boot guard). Matches the OWASP "Secret Management"
package/dist/session.js CHANGED
@@ -290,12 +290,12 @@ export function session(opts) {
290
290
  }
291
291
  internal.activeId = id;
292
292
  const regenerate = async (keepData) => {
293
+ // Only destroy a persisted session id. An id created earlier this
294
+ // request (or a prior mid-request rotation) was never written to the
295
+ // store, so there is nothing to destroy — we just discard it.
293
296
  if (internal.activeId && internal.originalId === internal.activeId) {
294
297
  await store.destroy(internal.activeId);
295
298
  }
296
- else if (internal.activeId && internal.activeId !== internal.originalId) {
297
- // We rotated mid-request previously; throw away the unsaved id.
298
- }
299
299
  const next = generator();
300
300
  if (!next)
301
301
  throw new Error("session(): generator returned an empty id.");
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;