@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
|
@@ -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;
|
package/dist/auto-ban.js
ADDED
|
@@ -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;
|