@daloyjs/core 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +57 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +39 -5
- package/dist/index.js +19 -2
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +61 -7
- package/dist/security.js +75 -8
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +79 -3
|
@@ -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;
|
|
@@ -0,0 +1,291 @@
|
|
|
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 { ForbiddenError } from "./errors.js";
|
|
39
|
+
const DEFAULT_MESSAGE = "Bot access denied";
|
|
40
|
+
const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
|
|
41
|
+
const DEFAULT_CACHE_MAX = 10_000;
|
|
42
|
+
/**
|
|
43
|
+
* Built-in {@link VerifiedBotRule} for Googlebot (and other Google crawlers),
|
|
44
|
+
* verified against Google's documented `*.googlebot.com` / `*.google.com`
|
|
45
|
+
* reverse-DNS domains.
|
|
46
|
+
*
|
|
47
|
+
* @since 0.37.0
|
|
48
|
+
*/
|
|
49
|
+
export const GOOGLEBOT = {
|
|
50
|
+
name: "Googlebot",
|
|
51
|
+
userAgent: /googlebot|google-inspectiontool|storebot-google|googleother|google-extended/i,
|
|
52
|
+
domains: [".googlebot.com", ".google.com"],
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Built-in {@link VerifiedBotRule} for Bingbot, verified against Microsoft's
|
|
56
|
+
* documented `*.search.msn.com` reverse-DNS domain.
|
|
57
|
+
*
|
|
58
|
+
* @since 0.37.0
|
|
59
|
+
*/
|
|
60
|
+
export const BINGBOT = {
|
|
61
|
+
name: "Bingbot",
|
|
62
|
+
userAgent: /bingbot|bingpreview|adidxbot|msnbot/i,
|
|
63
|
+
domains: [".search.msn.com"],
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Convenience bundle of the built-in verified-crawler rules
|
|
67
|
+
* ({@link GOOGLEBOT}, {@link BINGBOT}).
|
|
68
|
+
*
|
|
69
|
+
* @since 0.37.0
|
|
70
|
+
*/
|
|
71
|
+
export const WELL_KNOWN_BOTS = [GOOGLEBOT, BINGBOT];
|
|
72
|
+
function matchesUserAgent(ua, patterns) {
|
|
73
|
+
const lower = ua.toLowerCase();
|
|
74
|
+
for (const pattern of patterns) {
|
|
75
|
+
if (typeof pattern === "string") {
|
|
76
|
+
if (pattern && lower.includes(pattern.toLowerCase()))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
else if (pattern.test(ua)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function forwardedIpResolver(ctx) {
|
|
86
|
+
const headers = ctx.request.headers;
|
|
87
|
+
const forwarded = headers.get("x-forwarded-for");
|
|
88
|
+
if (forwarded) {
|
|
89
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
90
|
+
if (first)
|
|
91
|
+
return first;
|
|
92
|
+
}
|
|
93
|
+
return ctx.request.headers.get("x-real-ip") ?? undefined;
|
|
94
|
+
}
|
|
95
|
+
function noIpResolver(_ctx) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
function createDefaultResolver() {
|
|
99
|
+
let dnsPromise = null;
|
|
100
|
+
const load = async () => {
|
|
101
|
+
if (!dnsPromise) {
|
|
102
|
+
dnsPromise = import("node:dns/promises")
|
|
103
|
+
.then((m) => ({
|
|
104
|
+
reverse: m.reverse,
|
|
105
|
+
lookup: m.lookup,
|
|
106
|
+
}))
|
|
107
|
+
.catch(() => null);
|
|
108
|
+
}
|
|
109
|
+
const dns = await dnsPromise;
|
|
110
|
+
if (!dns) {
|
|
111
|
+
throw new Error("botGuard: no DNS resolver available on this runtime. Pass options.resolver.");
|
|
112
|
+
}
|
|
113
|
+
return dns;
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
async reverse(ip) {
|
|
117
|
+
const dns = await load();
|
|
118
|
+
return dns.reverse(ip);
|
|
119
|
+
},
|
|
120
|
+
async forward(hostname) {
|
|
121
|
+
const dns = await load();
|
|
122
|
+
const results = await dns.lookup(hostname, { all: true });
|
|
123
|
+
return results.map((r) => r.address);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build the default DNS resolver backed by a lazily-imported
|
|
129
|
+
* `node:dns/promises`. Used internally by {@link botGuard} when no custom
|
|
130
|
+
* {@link BotGuardOptions.resolver} is supplied, and exported for tests. Throws
|
|
131
|
+
* on runtimes without `node:dns/promises` so callers are told to supply their
|
|
132
|
+
* own resolver.
|
|
133
|
+
*
|
|
134
|
+
* @returns A {@link BotResolver} backed by the platform's DNS.
|
|
135
|
+
* @internal
|
|
136
|
+
*/
|
|
137
|
+
export function _createDefaultBotResolver() {
|
|
138
|
+
return createDefaultResolver();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Confirm that `hostname` ends with one of the allowed `domains`. A leading dot
|
|
142
|
+
* in a domain enforces a subdomain boundary so `evil-googlebot.com` cannot match
|
|
143
|
+
* `.googlebot.com`; a bare domain also matches the apex exactly.
|
|
144
|
+
*
|
|
145
|
+
* @internal
|
|
146
|
+
*/
|
|
147
|
+
function hostnameMatchesDomains(hostname, domains) {
|
|
148
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
149
|
+
for (const domain of domains) {
|
|
150
|
+
const d = domain.toLowerCase();
|
|
151
|
+
if (d.startsWith(".")) {
|
|
152
|
+
if (host.endsWith(d))
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
else if (host === d || host.endsWith(`.${d}`)) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reverse-DNS + forward-confirm a client IP against a verified-bot rule, the way
|
|
163
|
+
* Google and Bing document. Returns `true` only when a PTR hostname both ends in
|
|
164
|
+
* an allowed domain and forward-resolves back to the same IP.
|
|
165
|
+
*
|
|
166
|
+
* @internal
|
|
167
|
+
*/
|
|
168
|
+
async function verifyCrawler(resolver, ip, rule) {
|
|
169
|
+
const hostnames = await resolver.reverse(ip);
|
|
170
|
+
for (const hostname of hostnames) {
|
|
171
|
+
if (!hostnameMatchesDomains(hostname, rule.domains))
|
|
172
|
+
continue;
|
|
173
|
+
const addresses = await resolver.forward(hostname);
|
|
174
|
+
if (addresses.includes(ip))
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Bot / User-Agent management middleware. Blocks empty or known-abusive
|
|
181
|
+
* `User-Agent` strings and verifies declared crawlers (Googlebot/Bingbot) via
|
|
182
|
+
* reverse-DNS + forward-confirm, so a spoofed `User-Agent` cannot impersonate a
|
|
183
|
+
* trusted crawler.
|
|
184
|
+
*
|
|
185
|
+
* All checks are opt-in and allowlist-friendly: {@link BotGuardOptions.allowUserAgents}
|
|
186
|
+
* is consulted first and bypasses every other rule.
|
|
187
|
+
*
|
|
188
|
+
* @param opts - Bot-guard configuration.
|
|
189
|
+
* @returns A {@link Hooks} bundle ready for `app.use(...)`.
|
|
190
|
+
* @throws Error when `verifiedBots` is set without an IP source
|
|
191
|
+
* (`resolveIp` or `trustProxyHeaders`), or when `mode` is invalid.
|
|
192
|
+
* @since 0.37.0
|
|
193
|
+
*/
|
|
194
|
+
export function botGuard(opts = {}) {
|
|
195
|
+
const blockEmpty = opts.blockEmptyUserAgent !== false;
|
|
196
|
+
const blocked = opts.blockedUserAgents ?? [];
|
|
197
|
+
const allowed = opts.allowUserAgents ?? [];
|
|
198
|
+
const verifiedBots = opts.verifiedBots ?? [];
|
|
199
|
+
const blockUnverifiable = opts.blockUnverifiableBots !== false;
|
|
200
|
+
const message = opts.message ?? DEFAULT_MESSAGE;
|
|
201
|
+
const cacheTtlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
202
|
+
const cacheMax = opts.cacheMaxEntries ?? DEFAULT_CACHE_MAX;
|
|
203
|
+
const mode = opts.mode ?? "block";
|
|
204
|
+
if (mode !== "block" && mode !== "log") {
|
|
205
|
+
throw new Error('botGuard(): mode must be "block" or "log".');
|
|
206
|
+
}
|
|
207
|
+
const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
|
|
208
|
+
if (verifiedBots.length > 0 && !opts.resolveIp && !opts.trustProxyHeaders) {
|
|
209
|
+
throw new Error("botGuard(): verifiedBots requires a client-IP source — provide resolveIp " +
|
|
210
|
+
"or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
|
|
211
|
+
}
|
|
212
|
+
const resolver = opts.resolver ?? createDefaultResolver();
|
|
213
|
+
// Per-IP verification cache (keyed by `ip\u0000botName`) so a crawler's DNS
|
|
214
|
+
// round-trip is paid once per TTL, not on every request.
|
|
215
|
+
const cache = new Map();
|
|
216
|
+
const readCache = (key) => {
|
|
217
|
+
const entry = cache.get(key);
|
|
218
|
+
if (!entry)
|
|
219
|
+
return undefined;
|
|
220
|
+
if (entry.expiresMs <= Date.now()) {
|
|
221
|
+
cache.delete(key);
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
return entry.verified;
|
|
225
|
+
};
|
|
226
|
+
const writeCache = (key, verified) => {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
cache.set(key, { verified, expiresMs: now + cacheTtlMs });
|
|
229
|
+
if (cache.size > cacheMax) {
|
|
230
|
+
for (const [k, v] of cache)
|
|
231
|
+
if (v.expiresMs <= now)
|
|
232
|
+
cache.delete(k);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
const reject = (event) => {
|
|
236
|
+
opts.onBlock?.(event);
|
|
237
|
+
if (mode === "block")
|
|
238
|
+
throw new ForbiddenError(message);
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
async beforeHandle(ctx) {
|
|
242
|
+
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
243
|
+
// Allowlist wins over every other rule.
|
|
244
|
+
if (allowed.length > 0 && matchesUserAgent(ua, allowed))
|
|
245
|
+
return undefined;
|
|
246
|
+
if (!ua.trim()) {
|
|
247
|
+
if (blockEmpty)
|
|
248
|
+
reject({ reason: "empty-user-agent", userAgent: ua });
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
if (blocked.length > 0 && matchesUserAgent(ua, blocked)) {
|
|
252
|
+
reject({ reason: "blocked-user-agent", userAgent: ua });
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const rule = verifiedBots.find((r) => r.userAgent.test(ua));
|
|
256
|
+
if (!rule)
|
|
257
|
+
return undefined;
|
|
258
|
+
const ip = resolveIp(ctx);
|
|
259
|
+
if (!ip) {
|
|
260
|
+
if (blockUnverifiable) {
|
|
261
|
+
reject({ reason: "unverifiable-bot", userAgent: ua, botName: rule.name });
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const cacheKey = `${ip}\u0000${rule.name}`;
|
|
266
|
+
const cached = readCache(cacheKey);
|
|
267
|
+
if (cached === true)
|
|
268
|
+
return undefined;
|
|
269
|
+
if (cached === false) {
|
|
270
|
+
reject({ reason: "spoofed-bot", userAgent: ua, ip, botName: rule.name });
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
let verified;
|
|
274
|
+
try {
|
|
275
|
+
verified = await verifyCrawler(resolver, ip, rule);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// DNS failure — cannot confirm. Don't cache transient errors.
|
|
279
|
+
if (blockUnverifiable) {
|
|
280
|
+
reject({ reason: "unverifiable-bot", userAgent: ua, ip, botName: rule.name });
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
writeCache(cacheKey, verified);
|
|
285
|
+
if (!verified) {
|
|
286
|
+
reject({ reason: "spoofed-bot", userAgent: ua, ip, botName: rule.name });
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export interface CliIO {
|
|
|
24
24
|
* can omit it.
|
|
25
25
|
*/
|
|
26
26
|
spawn?: (command: string, args: readonly string[]) => Promise<number>;
|
|
27
|
+
/**
|
|
28
|
+
* Read a UTF-8 text file by path. Required for `daloy diff`; optional so
|
|
29
|
+
* unit tests that only exercise `inspect` can omit it.
|
|
30
|
+
*/
|
|
31
|
+
readTextFile?: (path: string) => Promise<string>;
|
|
27
32
|
/**
|
|
28
33
|
* Override runtime detection (defaults to inspecting `globalThis.process.versions`).
|
|
29
34
|
* Mainly exists for tests.
|
|
@@ -40,6 +45,7 @@ export interface CliOptions {
|
|
|
40
45
|
check: boolean;
|
|
41
46
|
schemas: boolean;
|
|
42
47
|
openapi: boolean;
|
|
48
|
+
asyncapi: boolean;
|
|
43
49
|
ai: boolean;
|
|
44
50
|
/**
|
|
45
51
|
* Output format for `--ai` and `--openapi`. Defaults to `"json"`.
|
|
@@ -61,6 +67,8 @@ export interface CliOptions {
|
|
|
61
67
|
auditSecrets?: boolean;
|
|
62
68
|
/** `daloy doctor` — disable the default-defaults audit. */
|
|
63
69
|
noAuditDefaults?: boolean;
|
|
70
|
+
/** Positional arguments collected in order (used by `daloy diff`). */
|
|
71
|
+
positionals?: string[];
|
|
64
72
|
}
|
|
65
73
|
/** Runtime detected for `daloy dev`. */
|
|
66
74
|
export type DevRuntime = "node" | "bun" | "deno";
|