@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.
Files changed (77) hide show
  1. package/README.md +21 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +144 -1
  8. package/dist/app.js +208 -1
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Adaptive auto-ban (fail2ban-style) middleware. Where {@link "./middleware.js".loginThrottle}
3
+ * only protects credential-entry routes, {@link autoBan} generalizes the idea
4
+ * into a reusable, escalating, decaying ban primitive: when a single client
5
+ * trips too many "suspicious" responses (by default `401` / `403` / `429`) inside
6
+ * a rolling window, it is temporarily banned. Repeat offenders earn
7
+ * exponentially longer bans, and the record decays away once the client goes
8
+ * quiet — so a one-off burst is forgiven while a persistent attacker is shut out
9
+ * for progressively longer.
10
+ *
11
+ * The middleware is dependency-free and runtime-portable. It observes outgoing
12
+ * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
+ * status produced by *any* later middleware or handler, not just its own) and
14
+ * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
15
+ * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
+ * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
+ * implementation for multi-instance deployments.
18
+ *
19
+ * @module
20
+ * @since 0.37.0
21
+ */
22
+ import type { BaseContext, Hooks } from "./types.js";
23
+ /**
24
+ * One client's auto-ban bookkeeping. A record tracks the current strike count
25
+ * inside the rolling strike window, when that window expires, the timestamp the
26
+ * client is banned until (`0` when not banned), and how many bans the client
27
+ * has accumulated while the record has stayed alive (drives escalation).
28
+ *
29
+ * @since 0.37.0
30
+ */
31
+ export interface AutoBanRecord {
32
+ /** Suspicious responses seen inside the current strike window. */
33
+ strikes: number;
34
+ /** Epoch ms at which the current strike window resets (strikes decay to 0). */
35
+ strikeExpiresMs: number;
36
+ /** Epoch ms the client is banned until; `0` when the client is not banned. */
37
+ bannedUntilMs: number;
38
+ /** Total bans issued while this record stayed alive; drives escalation. */
39
+ banCount: number;
40
+ }
41
+ /**
42
+ * Pluggable backend for {@link autoBan}, mirroring the `rateLimit()` store
43
+ * contract. Implementations persist one {@link AutoBanRecord} per key and must
44
+ * treat an entry whose `ttlMs` has elapsed as absent (so bans and escalation
45
+ * decay automatically). The built-in default is in-memory and single-process;
46
+ * back it with Redis (or another shared store) for multi-instance deployments.
47
+ *
48
+ * @since 0.37.0
49
+ */
50
+ export interface AutoBanStore {
51
+ /** Resolve the current record for `key`, or `undefined` when none/expired. */
52
+ get(key: string): Promise<AutoBanRecord | undefined>;
53
+ /**
54
+ * Persist `record` for `key`, expiring it after `ttlMs`. Implementations
55
+ * should set the backing TTL so an idle key is reclaimed automatically.
56
+ */
57
+ set(key: string, record: AutoBanRecord, ttlMs: number): Promise<void>;
58
+ /** Forget `key` entirely (e.g. an operator manually lifting a ban). */
59
+ delete(key: string): Promise<void>;
60
+ }
61
+ /**
62
+ * Emitted via {@link AutoBanOptions.onBan} when a client crosses the strike
63
+ * threshold and a (possibly escalated) ban is issued. Useful for alerting,
64
+ * structured audit logging, or feeding an external denylist.
65
+ *
66
+ * @since 0.37.0
67
+ */
68
+ export interface AutoBanEvent {
69
+ /** The store key the ban applies to (group prefix + client identity). */
70
+ key: string;
71
+ /** How many times this client has been banned while its record stayed alive. */
72
+ banCount: number;
73
+ /** The duration of this ban in milliseconds. */
74
+ banDurationMs: number;
75
+ /** Epoch ms the client is banned until. */
76
+ bannedUntilMs: number;
77
+ }
78
+ /**
79
+ * Emitted via {@link AutoBanOptions.onStrike} every time a suspicious response
80
+ * is attributed to a client (before any resulting ban). Lets callers observe
81
+ * pressure building without waiting for the ban itself.
82
+ *
83
+ * @since 0.37.0
84
+ */
85
+ export interface AutoBanStrikeEvent {
86
+ /** The store key the strike applies to. */
87
+ key: string;
88
+ /** The strike count after recording this strike, inside the current window. */
89
+ strikes: number;
90
+ /** The response status that triggered the strike. */
91
+ status: number;
92
+ }
93
+ /**
94
+ * Configuration for {@link autoBan}. Every field is optional except that the
95
+ * middleware must be able to identify clients: supply a {@link keyGenerator} or
96
+ * set {@link trustProxyHeaders} (otherwise construction throws, to avoid
97
+ * accidentally banning every client through a shared `"global"` bucket).
98
+ *
99
+ * @since 0.37.0
100
+ */
101
+ export interface AutoBanOptions {
102
+ /** Rolling strike window in ms; strikes older than this decay. Default: 10 minutes. */
103
+ windowMs?: number;
104
+ /** Suspicious responses inside `windowMs` that trigger a ban. Default: 5. */
105
+ maxStrikes?: number;
106
+ /** Base ban duration in ms (first offence). Default: 15 minutes. */
107
+ banMs?: number;
108
+ /** Hard cap on an escalated ban duration in ms. Default: 24 hours. */
109
+ maxBanMs?: number;
110
+ /**
111
+ * Double the ban duration on each repeat ban while the record stays alive
112
+ * (`banMs`, `2×banMs`, `4×banMs`, … capped at `maxBanMs`). Default: `true`.
113
+ * When `false`, every ban lasts exactly `banMs`.
114
+ */
115
+ escalate?: boolean;
116
+ /**
117
+ * Response status codes treated as suspicious. Default: `[401, 403, 429]`.
118
+ * Add `400` / `422` to also count request-validation failures, but be aware
119
+ * those can include honest client mistakes.
120
+ */
121
+ watchStatuses?: readonly number[];
122
+ /**
123
+ * Status used for the ban rejection: `429` (default, carries `Retry-After`)
124
+ * or `403`. `403` surfaces {@link AutoBanOptions.message}.
125
+ */
126
+ banStatus?: 403 | 429;
127
+ /**
128
+ * Derive the client identity from `ctx`, or `undefined` to skip the request
129
+ * (fail-open — never banned, never counted). Defaults to the proxy-header
130
+ * resolver when {@link trustProxyHeaders} is set.
131
+ */
132
+ keyGenerator?: (ctx: BaseContext<any, any>) => string | undefined;
133
+ /**
134
+ * Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
135
+ * default because those headers are client-spoofable unless every request
136
+ * reaches the app through a proxy chain you control.
137
+ */
138
+ trustProxyHeaders?: boolean;
139
+ /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
140
+ store?: AutoBanStore;
141
+ /**
142
+ * Share one ban store across every `autoBan()` mounted with the same
143
+ * `groupId`, so a client banned on one route group is banned on all of them.
144
+ * Default: `"auto-ban"`. Only meaningful for the in-memory default store.
145
+ */
146
+ groupId?: string;
147
+ /** Send `Retry-After` on a `429` ban rejection. Default: `true`. */
148
+ retryAfter?: boolean;
149
+ /** Message for the `403` ban variant. Default: `"Temporarily banned"`. */
150
+ message?: string;
151
+ /** Called when a ban is issued (alerting / audit / external denylist). */
152
+ onBan?: (event: AutoBanEvent) => void;
153
+ /** Called for every recorded strike, before any resulting ban. */
154
+ onStrike?: (event: AutoBanStrikeEvent) => void;
155
+ }
156
+ /**
157
+ * Test-only helper that clears the process-wide shared auto-ban stores. Not part
158
+ * of the documented public API.
159
+ *
160
+ * @internal
161
+ */
162
+ export declare function _resetAutoBanStoresForTests(): void;
163
+ /**
164
+ * Default in-memory {@link AutoBanStore}. Single-process only; entries are
165
+ * reclaimed lazily on access and opportunistically when the map grows large, so
166
+ * an idle attacker's record decays without an explicit timer.
167
+ *
168
+ * @since 0.37.0
169
+ */
170
+ export declare class MemoryAutoBanStore implements AutoBanStore {
171
+ private map;
172
+ /** {@inheritDoc AutoBanStore.get} */
173
+ get(key: string): Promise<AutoBanRecord | undefined>;
174
+ /** {@inheritDoc AutoBanStore.set} */
175
+ set(key: string, record: AutoBanRecord, ttlMs: number): Promise<void>;
176
+ /** {@inheritDoc AutoBanStore.delete} */
177
+ delete(key: string): Promise<void>;
178
+ }
179
+ /**
180
+ * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
181
+ * suspicious outgoing responses per client and temporarily bans repeat
182
+ * offenders; bans grow exponentially for persistent abuse and decay once the
183
+ * client goes quiet.
184
+ *
185
+ * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
186
+ * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
187
+ * a misconfiguration can never collapse every caller into one shared bucket and
188
+ * ban the whole world at once. A request the key generator cannot attribute is
189
+ * skipped (never counted, never banned).
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * import { autoBan } from "@daloyjs/core";
194
+ *
195
+ * // Five 401/403/429s within 10 min → 15 min ban, doubling for repeat offenders.
196
+ * app.use(autoBan({ trustProxyHeaders: true }));
197
+ * ```
198
+ *
199
+ * @param opts - Auto-ban configuration.
200
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
201
+ * @throws Error when neither `keyGenerator` nor `trustProxyHeaders` is provided,
202
+ * or when a numeric option is out of range.
203
+ * @since 0.37.0
204
+ */
205
+ export declare function autoBan(opts?: AutoBanOptions): Hooks;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Adaptive auto-ban (fail2ban-style) middleware. Where {@link "./middleware.js".loginThrottle}
3
+ * only protects credential-entry routes, {@link autoBan} generalizes the idea
4
+ * into a reusable, escalating, decaying ban primitive: when a single client
5
+ * trips too many "suspicious" responses (by default `401` / `403` / `429`) inside
6
+ * a rolling window, it is temporarily banned. Repeat offenders earn
7
+ * exponentially longer bans, and the record decays away once the client goes
8
+ * quiet — so a one-off burst is forgiven while a persistent attacker is shut out
9
+ * for progressively longer.
10
+ *
11
+ * The middleware is dependency-free and runtime-portable. It observes outgoing
12
+ * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
+ * status produced by *any* later middleware or handler, not just its own) and
14
+ * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
15
+ * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
+ * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
+ * implementation for multi-instance deployments.
18
+ *
19
+ * @module
20
+ * @since 0.37.0
21
+ */
22
+ import { ForbiddenError, TooManyRequestsError } from "./errors.js";
23
+ const DEFAULT_WINDOW_MS = 10 * 60_000;
24
+ const DEFAULT_MAX_STRIKES = 5;
25
+ const DEFAULT_BAN_MS = 15 * 60_000;
26
+ const DEFAULT_MAX_BAN_MS = 24 * 60 * 60_000;
27
+ const DEFAULT_WATCH_STATUSES = [401, 403, 429];
28
+ const DEFAULT_GROUP_ID = "auto-ban";
29
+ const STATE_KEY = "__autoBanKey";
30
+ const STATE_REJECTED = "__autoBanRejected";
31
+ /**
32
+ * Process-wide registry of shared in-memory stores keyed by `groupId`, so two
33
+ * `autoBan({ groupId })` mounts cooperate on one ban map.
34
+ *
35
+ * @internal
36
+ */
37
+ const SHARED_AUTO_BAN_STORES = new Map();
38
+ /**
39
+ * Test-only helper that clears the process-wide shared auto-ban stores. Not part
40
+ * of the documented public API.
41
+ *
42
+ * @internal
43
+ */
44
+ export function _resetAutoBanStoresForTests() {
45
+ SHARED_AUTO_BAN_STORES.clear();
46
+ }
47
+ /**
48
+ * Default in-memory {@link AutoBanStore}. Single-process only; entries are
49
+ * reclaimed lazily on access and opportunistically when the map grows large, so
50
+ * an idle attacker's record decays without an explicit timer.
51
+ *
52
+ * @since 0.37.0
53
+ */
54
+ export class MemoryAutoBanStore {
55
+ map = new Map();
56
+ /** {@inheritDoc AutoBanStore.get} */
57
+ async get(key) {
58
+ const entry = this.map.get(key);
59
+ if (!entry)
60
+ return undefined;
61
+ if (entry.expiresMs <= Date.now()) {
62
+ this.map.delete(key);
63
+ return undefined;
64
+ }
65
+ return entry.record;
66
+ }
67
+ /** {@inheritDoc AutoBanStore.set} */
68
+ async set(key, record, ttlMs) {
69
+ const now = Date.now();
70
+ this.map.set(key, { record, expiresMs: now + ttlMs });
71
+ if (this.map.size > 10_000) {
72
+ for (const [k, v] of this.map)
73
+ if (v.expiresMs <= now)
74
+ this.map.delete(k);
75
+ }
76
+ }
77
+ /** {@inheritDoc AutoBanStore.delete} */
78
+ async delete(key) {
79
+ this.map.delete(key);
80
+ }
81
+ }
82
+ function assertPositiveInteger(name, value) {
83
+ if (!Number.isInteger(value) || value <= 0) {
84
+ throw new Error(`autoBan(): ${name} must be a positive integer.`);
85
+ }
86
+ }
87
+ function forwardedKey(ctx) {
88
+ const forwarded = ctx.request.headers.get("x-forwarded-for");
89
+ const first = forwarded ? forwarded.split(",")[0].trim() : "";
90
+ if (first)
91
+ return first;
92
+ return ctx.request.headers.get("x-real-ip") ?? undefined;
93
+ }
94
+ /**
95
+ * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
96
+ * suspicious outgoing responses per client and temporarily bans repeat
97
+ * offenders; bans grow exponentially for persistent abuse and decay once the
98
+ * client goes quiet.
99
+ *
100
+ * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
101
+ * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
102
+ * a misconfiguration can never collapse every caller into one shared bucket and
103
+ * ban the whole world at once. A request the key generator cannot attribute is
104
+ * skipped (never counted, never banned).
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * import { autoBan } from "@daloyjs/core";
109
+ *
110
+ * // Five 401/403/429s within 10 min → 15 min ban, doubling for repeat offenders.
111
+ * app.use(autoBan({ trustProxyHeaders: true }));
112
+ * ```
113
+ *
114
+ * @param opts - Auto-ban configuration.
115
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
116
+ * @throws Error when neither `keyGenerator` nor `trustProxyHeaders` is provided,
117
+ * or when a numeric option is out of range.
118
+ * @since 0.37.0
119
+ */
120
+ export function autoBan(opts = {}) {
121
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
122
+ const maxStrikes = opts.maxStrikes ?? DEFAULT_MAX_STRIKES;
123
+ const banMs = opts.banMs ?? DEFAULT_BAN_MS;
124
+ const maxBanMs = opts.maxBanMs ?? DEFAULT_MAX_BAN_MS;
125
+ assertPositiveInteger("windowMs", windowMs);
126
+ assertPositiveInteger("maxStrikes", maxStrikes);
127
+ assertPositiveInteger("banMs", banMs);
128
+ assertPositiveInteger("maxBanMs", maxBanMs);
129
+ if (maxBanMs < banMs) {
130
+ throw new Error("autoBan(): maxBanMs must be >= banMs.");
131
+ }
132
+ const escalate = opts.escalate ?? true;
133
+ const banStatus = opts.banStatus ?? 429;
134
+ if (banStatus !== 403 && banStatus !== 429) {
135
+ throw new Error("autoBan(): banStatus must be 403 or 429.");
136
+ }
137
+ const retryAfter = opts.retryAfter !== false;
138
+ const message = opts.message ?? "Temporarily banned";
139
+ const watchStatuses = opts.watchStatuses ?? DEFAULT_WATCH_STATUSES;
140
+ if (watchStatuses.length === 0) {
141
+ throw new Error("autoBan(): watchStatuses must list at least one status code.");
142
+ }
143
+ for (const status of watchStatuses) {
144
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
145
+ throw new Error("autoBan(): watchStatuses must be integer HTTP status codes (100-599).");
146
+ }
147
+ }
148
+ const watch = new Set(watchStatuses);
149
+ if (!opts.keyGenerator && !opts.trustProxyHeaders) {
150
+ throw new Error("autoBan(): provide keyGenerator or set trustProxyHeaders so clients can be identified; " +
151
+ "otherwise every caller shares one bucket and a single offender would ban everyone.");
152
+ }
153
+ const keyOf = opts.keyGenerator ?? forwardedKey;
154
+ const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
155
+ let store;
156
+ if (opts.store) {
157
+ store = opts.store;
158
+ }
159
+ else {
160
+ let shared = SHARED_AUTO_BAN_STORES.get(groupId);
161
+ if (!shared) {
162
+ shared = new MemoryAutoBanStore();
163
+ SHARED_AUTO_BAN_STORES.set(groupId, shared);
164
+ }
165
+ store = shared;
166
+ }
167
+ const prefix = `${groupId}:`;
168
+ return {
169
+ async beforeHandle(ctx) {
170
+ const identity = keyOf(ctx);
171
+ if (identity === undefined)
172
+ return undefined;
173
+ const key = `${prefix}${identity}`;
174
+ const state = ctx.state;
175
+ state[STATE_KEY] = key;
176
+ const record = await store.get(key);
177
+ const now = Date.now();
178
+ if (record && record.bannedUntilMs > now) {
179
+ state[STATE_REJECTED] = true;
180
+ if (banStatus === 403)
181
+ throw new ForbiddenError(message);
182
+ const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
183
+ throw new TooManyRequestsError(retryAfter ? retry : undefined);
184
+ }
185
+ return undefined;
186
+ },
187
+ async onSend(res, ctx) {
188
+ if (!ctx)
189
+ return undefined;
190
+ const state = ctx.state;
191
+ // Never count the ban rejection we just produced — that would let an
192
+ // active ban perpetually re-arm itself.
193
+ if (state[STATE_REJECTED] === true)
194
+ return undefined;
195
+ const key = state[STATE_KEY];
196
+ if (key === undefined)
197
+ return undefined;
198
+ if (!watch.has(res.status))
199
+ return undefined;
200
+ const now = Date.now();
201
+ const record = await store.get(key);
202
+ const windowActive = record !== undefined && record.strikeExpiresMs > now;
203
+ let strikes = (windowActive ? record.strikes : 0) + 1;
204
+ let banCount = record?.banCount ?? 0;
205
+ let bannedUntilMs = record?.bannedUntilMs ?? 0;
206
+ const strikeExpiresMs = now + windowMs;
207
+ opts.onStrike?.({ key, strikes, status: res.status });
208
+ if (strikes >= maxStrikes) {
209
+ banCount += 1;
210
+ const duration = escalate
211
+ ? Math.min(maxBanMs, banMs * 2 ** (banCount - 1))
212
+ : banMs;
213
+ bannedUntilMs = now + duration;
214
+ strikes = 0;
215
+ opts.onBan?.({ key, banCount, banDurationMs: duration, bannedUntilMs });
216
+ }
217
+ const ttlMs = Math.max(strikeExpiresMs, bannedUntilMs) - now;
218
+ await store.set(key, { strikes, strikeExpiresMs, bannedUntilMs, banCount }, ttlMs);
219
+ return undefined;
220
+ },
221
+ };
222
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Bot / User-Agent management middleware. Mirrors the bot-rule layer that
3
+ * Nginx, Cloudflare, and other WAFs run at the edge, but inside the app where
4
+ * the framework already owns request parsing and client-IP resolution.
5
+ *
6
+ * {@link botGuard} does three opt-in jobs:
7
+ *
8
+ * 1. **Block empty / missing `User-Agent`** — a common signature of crude
9
+ * scrapers and vulnerability scanners (on by default).
10
+ * 2. **Block known-abusive `User-Agent` strings** — caller-supplied substrings
11
+ * or `RegExp`s.
12
+ * 3. **Verify declared crawlers** — when a request *claims* to be Googlebot or
13
+ * Bingbot, confirm it via reverse-DNS + forward-confirm (the method Google
14
+ * and Bing themselves document) so a spoofed `User-Agent` can't impersonate a
15
+ * trusted crawler. Verification results are cached per IP to keep DNS off the
16
+ * hot path.
17
+ *
18
+ * The middleware is dependency-free and runtime-portable. The default DNS
19
+ * resolver is lazily imported from `node:dns/promises`; supply a custom
20
+ * {@link BotResolver} on non-Node runtimes or in tests.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { botGuard, WELL_KNOWN_BOTS } from "@daloyjs/core";
25
+ *
26
+ * app.use(
27
+ * botGuard({
28
+ * trustProxyHeaders: true,
29
+ * blockedUserAgents: [/sqlmap/i, /nikto/i, "masscan"],
30
+ * verifiedBots: WELL_KNOWN_BOTS, // spoofed Googlebot/Bingbot → 403
31
+ * }),
32
+ * );
33
+ * ```
34
+ *
35
+ * @module
36
+ * @since 0.37.0
37
+ */
38
+ import type { BaseContext, Hooks } from "./types.js";
39
+ /**
40
+ * Pluggable DNS resolver used to verify declared crawlers. The default
41
+ * implementation lazily imports `node:dns/promises`; provide your own on
42
+ * runtimes without it (Workers, Deno without `--allow-net`) or in tests.
43
+ *
44
+ * @since 0.37.0
45
+ */
46
+ export interface BotResolver {
47
+ /**
48
+ * Reverse-resolve an IP address to its PTR hostname(s).
49
+ *
50
+ * @param ip - The client IP address.
51
+ * @returns The PTR hostnames (may be empty).
52
+ */
53
+ reverse(ip: string): Promise<readonly string[]>;
54
+ /**
55
+ * Forward-resolve a hostname to its IP address(es).
56
+ *
57
+ * @param hostname - The hostname returned by {@link BotResolver.reverse}.
58
+ * @returns The resolved IP addresses (may be empty).
59
+ */
60
+ forward(hostname: string): Promise<readonly string[]>;
61
+ }
62
+ /**
63
+ * A declared-crawler verification rule. When the request `User-Agent` matches
64
+ * {@link VerifiedBotRule.userAgent}, the client's reverse-DNS hostname must end
65
+ * with one of {@link VerifiedBotRule.domains} and forward-resolve back to the
66
+ * same IP — otherwise the request is treated as a spoofed crawler.
67
+ *
68
+ * @since 0.37.0
69
+ */
70
+ export interface VerifiedBotRule {
71
+ /** Human-readable bot name, surfaced in {@link BotGuardEvent.botName}. */
72
+ name: string;
73
+ /** Pattern that identifies a request claiming to be this crawler. */
74
+ userAgent: RegExp;
75
+ /**
76
+ * Allowed reverse-DNS domain suffixes (e.g. `.googlebot.com`). A leading dot
77
+ * is recommended so `evilgooglebot.com` cannot match `googlebot.com`.
78
+ */
79
+ domains: readonly string[];
80
+ }
81
+ /**
82
+ * Why a request was flagged by {@link botGuard}. Passed to
83
+ * {@link BotGuardOptions.onBlock} and used to build the rejection.
84
+ *
85
+ * @since 0.37.0
86
+ */
87
+ export interface BotGuardEvent {
88
+ /** The specific rule that fired. */
89
+ reason: "empty-user-agent" | "blocked-user-agent" | "spoofed-bot" | "unverifiable-bot";
90
+ /** The request `User-Agent` (empty string when missing). */
91
+ userAgent: string;
92
+ /** The resolved client IP, when available. */
93
+ ip?: string;
94
+ /** The declared bot name, for `spoofed-bot` / `unverifiable-bot`. */
95
+ botName?: string;
96
+ }
97
+ /**
98
+ * Configuration for {@link botGuard}.
99
+ *
100
+ * @since 0.37.0
101
+ */
102
+ export interface BotGuardOptions {
103
+ /**
104
+ * Block requests whose `User-Agent` is missing or empty. Default `true`.
105
+ */
106
+ blockEmptyUserAgent?: boolean;
107
+ /**
108
+ * Known-abusive `User-Agent` patterns. A plain string matches
109
+ * case-insensitively as a substring; a `RegExp` is tested as-is.
110
+ */
111
+ blockedUserAgents?: readonly (string | RegExp)[];
112
+ /**
113
+ * Allowlist that bypasses **all** checks (including empty-UA and verified-bot
114
+ * verification). A plain string matches case-insensitively as a substring; a
115
+ * `RegExp` is tested as-is. Checked first.
116
+ */
117
+ allowUserAgents?: readonly (string | RegExp)[];
118
+ /**
119
+ * Declared-crawler verification rules. When provided, an IP source is
120
+ * required (`resolveIp` or `trustProxyHeaders`), otherwise construction
121
+ * throws.
122
+ */
123
+ verifiedBots?: readonly VerifiedBotRule[];
124
+ /**
125
+ * Block a declared crawler that cannot be verified (no client IP, or a DNS
126
+ * lookup failure). Default `true` — the secure-by-default posture, since an
127
+ * unverifiable "Googlebot" might be an impersonator. Set `false` to fail open
128
+ * and let unverifiable crawlers through.
129
+ */
130
+ blockUnverifiableBots?: boolean;
131
+ /**
132
+ * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
133
+ * enable behind a trusted proxy that overwrites these headers.
134
+ */
135
+ trustProxyHeaders?: boolean;
136
+ /**
137
+ * Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
138
+ */
139
+ resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
140
+ /**
141
+ * Custom DNS resolver for crawler verification. Defaults to a lazy
142
+ * `node:dns/promises`-backed resolver.
143
+ */
144
+ resolver?: BotResolver;
145
+ /**
146
+ * `"block"` (default) throws a {@link ForbiddenError}; `"log"` only invokes
147
+ * {@link BotGuardOptions.onBlock} and lets the request continue (monitor mode).
148
+ */
149
+ mode?: "block" | "log";
150
+ /** Detail string for the `403` problem+json. Default `"Bot access denied"`. */
151
+ message?: string;
152
+ /**
153
+ * TTL for cached crawler-verification results, in ms. Default 1 hour.
154
+ */
155
+ cacheTtlMs?: number;
156
+ /** Max cached IPs before opportunistic pruning. Default `10_000`. */
157
+ cacheMaxEntries?: number;
158
+ /** Called whenever a request is flagged (in both `block` and `log` modes). */
159
+ onBlock?: (event: BotGuardEvent) => void;
160
+ }
161
+ /**
162
+ * Built-in {@link VerifiedBotRule} for Googlebot (and other Google crawlers),
163
+ * verified against Google's documented `*.googlebot.com` / `*.google.com`
164
+ * reverse-DNS domains.
165
+ *
166
+ * @since 0.37.0
167
+ */
168
+ export declare const GOOGLEBOT: VerifiedBotRule;
169
+ /**
170
+ * Built-in {@link VerifiedBotRule} for Bingbot, verified against Microsoft's
171
+ * documented `*.search.msn.com` reverse-DNS domain.
172
+ *
173
+ * @since 0.37.0
174
+ */
175
+ export declare const BINGBOT: VerifiedBotRule;
176
+ /**
177
+ * Convenience bundle of the built-in verified-crawler rules
178
+ * ({@link GOOGLEBOT}, {@link BINGBOT}).
179
+ *
180
+ * @since 0.37.0
181
+ */
182
+ export declare const WELL_KNOWN_BOTS: readonly VerifiedBotRule[];
183
+ /**
184
+ * Build the default DNS resolver backed by a lazily-imported
185
+ * `node:dns/promises`. Used internally by {@link botGuard} when no custom
186
+ * {@link BotGuardOptions.resolver} is supplied, and exported for tests. Throws
187
+ * on runtimes without `node:dns/promises` so callers are told to supply their
188
+ * own resolver.
189
+ *
190
+ * @returns A {@link BotResolver} backed by the platform's DNS.
191
+ * @internal
192
+ */
193
+ export declare function _createDefaultBotResolver(): BotResolver;
194
+ /**
195
+ * Bot / User-Agent management middleware. Blocks empty or known-abusive
196
+ * `User-Agent` strings and verifies declared crawlers (Googlebot/Bingbot) via
197
+ * reverse-DNS + forward-confirm, so a spoofed `User-Agent` cannot impersonate a
198
+ * trusted crawler.
199
+ *
200
+ * All checks are opt-in and allowlist-friendly: {@link BotGuardOptions.allowUserAgents}
201
+ * is consulted first and bypasses every other rule.
202
+ *
203
+ * @param opts - Bot-guard configuration.
204
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
205
+ * @throws Error when `verifiedBots` is set without an IP source
206
+ * (`resolveIp` or `trustProxyHeaders`), or when `mode` is invalid.
207
+ * @since 0.37.0
208
+ */
209
+ export declare function botGuard(opts?: BotGuardOptions): Hooks;