@daloyjs/core 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +76 -2
package/dist/index.js
CHANGED
|
@@ -6,8 +6,9 @@ export { _resetInsecureDefaultsLogForTests } from "./app.js";
|
|
|
6
6
|
export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
|
|
7
7
|
export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
|
|
8
8
|
export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
|
|
9
|
-
export { HttpError, BadRequestError, ValidationError, NotFoundError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
|
|
9
|
+
export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
|
|
10
10
|
export { validate, isStandardSchema } from "./schema.js";
|
|
11
|
+
export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
|
|
11
12
|
export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
|
|
12
13
|
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
|
|
13
14
|
export { etag } from "./etag.js";
|
|
@@ -19,6 +20,18 @@ export { assertTemporalClaims, TemporalClaimError, } from "./time-claims.js";
|
|
|
19
20
|
export { every, some, except } from "./combine.js";
|
|
20
21
|
export { ipRestriction } from "./ip-restriction.js";
|
|
21
22
|
export { fetchGuard, SsrfBlockedError } from "./fetch-guard.js";
|
|
23
|
+
export { resilientFetch, CircuitBreaker, CircuitOpenError, FetchTimeoutError, } from "./fetch-resilience.js";
|
|
24
|
+
export { createWebhookSender, MemoryWebhookDeadLetterSink, } from "./webhook-delivery.js";
|
|
25
|
+
export { Scheduler, CronParseError, parseCron, nextCronRun, } from "./scheduler.js";
|
|
26
|
+
export { clientCertAuth, setClientCertificate, getClientCertificate, normalizePeerCertificate, parseForwardedClientCert, } from "./mtls.js";
|
|
27
|
+
export { signMessage, signRequest, verifyMessage, verifyRequest, httpSignatureAuth, contentDigest, verifyContentDigest, DEFAULT_SIGNATURE_LABEL, DEFAULT_MAX_SIGNATURE_AGE_SECONDS, DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS, } from "./http-signatures.js";
|
|
28
|
+
export { autoBan, MemoryAutoBanStore, _resetAutoBanStoresForTests, } from "./auto-ban.js";
|
|
29
|
+
export { botGuard, GOOGLEBOT, BINGBOT, WELL_KNOWN_BOTS } from "./bot-guard.js";
|
|
30
|
+
export { ipReputation, urlFeed } from "./ip-reputation.js";
|
|
31
|
+
export { geoBlock } from "./geo-block.js";
|
|
32
|
+
export { concurrencyLimit } from "./concurrency-limit.js";
|
|
33
|
+
export { requestDecompression, decompressRequestBody, DecompressionBombError, UnsupportedContentEncodingError, MalformedCompressedBodyError, _resetRequestDecompressionProbeForTests, } from "./request-decompression.js";
|
|
34
|
+
export { waf } from "./waf.js";
|
|
22
35
|
export { safeRedirect, OpenRedirectBlockedError } from "./safe-redirect.js";
|
|
23
36
|
export { loadShedding, LOAD_SHEDDING_MARKER } from "./load-shedding.js";
|
|
24
37
|
export { defineConfig, ConfigValidationError } from "./config.js";
|
|
@@ -28,6 +41,10 @@ export { sseStream, sseResponse, ndjsonStream, ndjsonResponse, } from "./streami
|
|
|
28
41
|
export { httpBearerScheme, httpBasicScheme, apiKeyScheme, oauth2Scheme, openIdConnectScheme, REQUIRE_PAYLOAD_AUTH_EXTENSION, securitySchemeRequiresPayloadAuth, toOpenAPISecurityScheme, } from "./security-schemes.js";
|
|
29
42
|
export { discriminator, discriminatedUnion } from "./discriminator.js";
|
|
30
43
|
export { session, rotateSession, signValue, verifySignedValue, MemorySessionStore, SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
|
|
44
|
+
export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTests, } from "./idempotency.js";
|
|
45
|
+
export { responseCache, MemoryResponseCacheStore, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
|
|
46
|
+
export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
|
|
47
|
+
export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
31
48
|
export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
|
|
32
49
|
export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
|
|
33
50
|
export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP reputation / dynamic denylist feed for {@link Hooks}. Where
|
|
3
|
+
* {@link "./ip-restriction.js".ipRestriction} enforces a *static* allow/deny
|
|
4
|
+
* list compiled once at construction, {@link ipReputation} wires **pluggable,
|
|
5
|
+
* periodically-refreshed abuse feeds** (Tor exit lists, Spamhaus DROP,
|
|
6
|
+
* cloud-abuse ranges, your own threat intel) into the request path without
|
|
7
|
+
* rebuilding the matcher.
|
|
8
|
+
*
|
|
9
|
+
* Design goals:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Pluggable feeds** — any {@link IpReputationFeed} that yields IP / CIDR
|
|
12
|
+
* strings. {@link urlFeed} ships for the common case (fetch a newline /
|
|
13
|
+
* Spamhaus-DROP-style list over HTTP), but a feed can be backed by anything.
|
|
14
|
+
* 2. **Periodic refresh** — the denylist is reloaded on an `unref`'d timer so a
|
|
15
|
+
* long-lived abuse range eventually expires and new ranges are picked up,
|
|
16
|
+
* without a redeploy.
|
|
17
|
+
* 3. **Fail-open** — a denylist is *additive* defense-in-depth, never the only
|
|
18
|
+
* gate. If a feed fails to load (initial or refresh), traffic is **not**
|
|
19
|
+
* blocked: the last-known-good list is kept (or an empty list if nothing has
|
|
20
|
+
* loaded yet). A feed outage must never take the whole app down.
|
|
21
|
+
*
|
|
22
|
+
* The middleware is dependency-free and runtime-portable; it reuses the
|
|
23
|
+
* SSRF-grade CIDR matcher from `ipRestriction()`. {@link urlFeed}'s default
|
|
24
|
+
* transport is the platform `fetch`; pass your own for non-standard runtimes or
|
|
25
|
+
* to layer SSRF protection.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { ipReputation, urlFeed } from "@daloyjs/core";
|
|
30
|
+
*
|
|
31
|
+
* const reputation = ipReputation({
|
|
32
|
+
* trustProxyHeaders: true,
|
|
33
|
+
* feeds: [
|
|
34
|
+
* urlFeed("https://www.spamhaus.org/drop/drop.txt", { name: "spamhaus-drop" }),
|
|
35
|
+
* urlFeed("https://check.torproject.org/torbulkexitlist", { name: "tor-exit" }),
|
|
36
|
+
* ],
|
|
37
|
+
* refreshIntervalMs: 60 * 60_000, // hourly
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* app.use(reputation.hooks);
|
|
41
|
+
* // On shutdown: reputation.stop();
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @module
|
|
45
|
+
* @since 0.37.0
|
|
46
|
+
*/
|
|
47
|
+
import type { BaseContext, Hooks } from "./types.js";
|
|
48
|
+
/**
|
|
49
|
+
* A pluggable source of abusive IP / CIDR entries. Implementations return the
|
|
50
|
+
* raw list each refresh; parsing, validation, and de-duplication are handled by
|
|
51
|
+
* {@link ipReputation}.
|
|
52
|
+
*
|
|
53
|
+
* @since 0.37.0
|
|
54
|
+
*/
|
|
55
|
+
export interface IpReputationFeed {
|
|
56
|
+
/** Human-readable feed name, surfaced in {@link IpReputationMatch.feed}. */
|
|
57
|
+
name: string;
|
|
58
|
+
/**
|
|
59
|
+
* Fetch the current entries. May return IP addresses or CIDR ranges as
|
|
60
|
+
* strings; invalid entries are skipped (never throw for junk lines). Honour
|
|
61
|
+
* the {@link AbortSignal} when provided.
|
|
62
|
+
*
|
|
63
|
+
* @param signal - Abort signal tied to the refresh timeout.
|
|
64
|
+
* @returns The current IP / CIDR entries.
|
|
65
|
+
*/
|
|
66
|
+
fetch(signal?: AbortSignal): Promise<readonly string[]>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Details of a request that matched the reputation denylist. Passed to
|
|
70
|
+
* {@link IpReputationOptions.onMatch}.
|
|
71
|
+
*
|
|
72
|
+
* @since 0.37.0
|
|
73
|
+
*/
|
|
74
|
+
export interface IpReputationMatch {
|
|
75
|
+
/** The resolved client IP. */
|
|
76
|
+
ip: string;
|
|
77
|
+
/** Names of the feeds whose entries matched. */
|
|
78
|
+
feeds: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Options for {@link ipReputation}.
|
|
82
|
+
*
|
|
83
|
+
* @since 0.37.0
|
|
84
|
+
*/
|
|
85
|
+
export interface IpReputationOptions {
|
|
86
|
+
/** One or more abuse feeds to merge into the denylist. Required, non-empty. */
|
|
87
|
+
feeds: readonly IpReputationFeed[];
|
|
88
|
+
/**
|
|
89
|
+
* How often to reload every feed, in ms. Default 1 hour. Set `0` to disable
|
|
90
|
+
* the timer and refresh only manually via {@link IpReputationController.refresh}.
|
|
91
|
+
*/
|
|
92
|
+
refreshIntervalMs?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Per-refresh timeout for each feed `fetch`, in ms. Default 30 s. A feed that
|
|
95
|
+
* exceeds it is aborted and treated as a (fail-open) refresh failure.
|
|
96
|
+
*/
|
|
97
|
+
fetchTimeoutMs?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Load the feeds immediately at construction. Default `true`. When `false`,
|
|
100
|
+
* the first load happens on the first timer tick (or manual `refresh()`), so
|
|
101
|
+
* early requests see an empty denylist (fail-open).
|
|
102
|
+
*/
|
|
103
|
+
loadOnStart?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Custom client-IP resolver. Overrides {@link IpReputationOptions.trustProxyHeaders}.
|
|
106
|
+
* Defaults to failing open (no IP → not blocked).
|
|
107
|
+
*/
|
|
108
|
+
resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
|
|
111
|
+
* enable behind a trusted proxy that overwrites these headers.
|
|
112
|
+
*/
|
|
113
|
+
trustProxyHeaders?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* `"block"` (default) throws a {@link ForbiddenError} on a match; `"log"`
|
|
116
|
+
* only invokes {@link IpReputationOptions.onMatch} and lets the request
|
|
117
|
+
* continue (monitor mode).
|
|
118
|
+
*/
|
|
119
|
+
mode?: "block" | "log";
|
|
120
|
+
/** Detail string for the `403` problem+json. Default `"IP address not permitted"`. */
|
|
121
|
+
message?: string;
|
|
122
|
+
/** Called whenever a request IP is on the denylist (in both modes). */
|
|
123
|
+
onMatch?: (match: IpReputationMatch) => void;
|
|
124
|
+
/**
|
|
125
|
+
* Called when a feed fails to load or refresh. The denylist keeps its
|
|
126
|
+
* last-known-good entries (fail-open). Use it to surface feed-health metrics.
|
|
127
|
+
*/
|
|
128
|
+
onError?: (error: unknown, feedName: string) => void;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A running IP-reputation guard. Pass {@link IpReputationController.hooks} to
|
|
132
|
+
* `app.use()`, drive refreshes manually with {@link IpReputationController.refresh},
|
|
133
|
+
* and release the timer on shutdown with {@link IpReputationController.stop}.
|
|
134
|
+
*
|
|
135
|
+
* @since 0.37.0
|
|
136
|
+
*/
|
|
137
|
+
export interface IpReputationController {
|
|
138
|
+
/** The middleware hooks to register via `app.use(...)`. */
|
|
139
|
+
hooks: Hooks;
|
|
140
|
+
/**
|
|
141
|
+
* Force an immediate reload of every feed. Resolves once all feeds have
|
|
142
|
+
* settled (failures are swallowed per the fail-open contract).
|
|
143
|
+
*/
|
|
144
|
+
refresh(): Promise<void>;
|
|
145
|
+
/** Stop the periodic-refresh timer. Idempotent. */
|
|
146
|
+
stop(): void;
|
|
147
|
+
/** Resolves after the first load attempt completes (success or fail-open). */
|
|
148
|
+
readonly ready: Promise<void>;
|
|
149
|
+
/** Current number of compiled denylist entries across all feeds. */
|
|
150
|
+
readonly size: number;
|
|
151
|
+
/** Test whether an IP is currently on the denylist (no side effects). */
|
|
152
|
+
has(ip: string): boolean;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Options for {@link urlFeed}.
|
|
156
|
+
*
|
|
157
|
+
* @since 0.37.0
|
|
158
|
+
*/
|
|
159
|
+
export interface UrlFeedOptions {
|
|
160
|
+
/** Feed name. Defaults to the URL. */
|
|
161
|
+
name?: string;
|
|
162
|
+
/**
|
|
163
|
+
* Custom `fetch` implementation. Defaults to the platform `fetch`. Provide an
|
|
164
|
+
* SSRF-guarded fetch or a non-standard-runtime client here.
|
|
165
|
+
*/
|
|
166
|
+
fetchImpl?: typeof fetch;
|
|
167
|
+
/** Extra request headers (e.g. an API token for a commercial feed). */
|
|
168
|
+
headers?: Record<string, string>;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build an {@link IpReputationFeed} that fetches a newline-delimited IP / CIDR
|
|
172
|
+
* list over HTTP. Handles the Spamhaus-DROP-style `<cidr> ; <annotation>`
|
|
173
|
+
* format and `#` / `;` / `//` comment lines. Lines that aren't valid IPs/CIDRs
|
|
174
|
+
* are skipped by {@link ipReputation}, so a partially-malformed feed still loads
|
|
175
|
+
* its good entries.
|
|
176
|
+
*
|
|
177
|
+
* @param url - The feed URL.
|
|
178
|
+
* @param opts - Optional feed name, custom `fetch`, and request headers.
|
|
179
|
+
* @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
|
|
180
|
+
* @since 0.37.0
|
|
181
|
+
*/
|
|
182
|
+
export declare function urlFeed(url: string, opts?: UrlFeedOptions): IpReputationFeed;
|
|
183
|
+
/**
|
|
184
|
+
* IP reputation / dynamic denylist middleware. Merges one or more pluggable
|
|
185
|
+
* abuse feeds into a periodically-refreshed denylist and rejects (or logs)
|
|
186
|
+
* requests from listed IPs, reusing the `ipRestriction()` CIDR matcher.
|
|
187
|
+
*
|
|
188
|
+
* Fail-open by design: a feed that cannot be loaded never blocks traffic — the
|
|
189
|
+
* last-known-good list is retained (empty until the first successful load). An
|
|
190
|
+
* unresolvable client IP is also treated as not-listed.
|
|
191
|
+
*
|
|
192
|
+
* @param opts - Reputation configuration; `feeds` must be non-empty.
|
|
193
|
+
* @returns An {@link IpReputationController} whose `hooks` go to `app.use(...)`.
|
|
194
|
+
* @throws Error when `feeds` is empty, `mode` is invalid, or a numeric option
|
|
195
|
+
* is out of range.
|
|
196
|
+
* @since 0.37.0
|
|
197
|
+
*/
|
|
198
|
+
export declare function ipReputation(opts: IpReputationOptions): IpReputationController;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP reputation / dynamic denylist feed for {@link Hooks}. Where
|
|
3
|
+
* {@link "./ip-restriction.js".ipRestriction} enforces a *static* allow/deny
|
|
4
|
+
* list compiled once at construction, {@link ipReputation} wires **pluggable,
|
|
5
|
+
* periodically-refreshed abuse feeds** (Tor exit lists, Spamhaus DROP,
|
|
6
|
+
* cloud-abuse ranges, your own threat intel) into the request path without
|
|
7
|
+
* rebuilding the matcher.
|
|
8
|
+
*
|
|
9
|
+
* Design goals:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Pluggable feeds** — any {@link IpReputationFeed} that yields IP / CIDR
|
|
12
|
+
* strings. {@link urlFeed} ships for the common case (fetch a newline /
|
|
13
|
+
* Spamhaus-DROP-style list over HTTP), but a feed can be backed by anything.
|
|
14
|
+
* 2. **Periodic refresh** — the denylist is reloaded on an `unref`'d timer so a
|
|
15
|
+
* long-lived abuse range eventually expires and new ranges are picked up,
|
|
16
|
+
* without a redeploy.
|
|
17
|
+
* 3. **Fail-open** — a denylist is *additive* defense-in-depth, never the only
|
|
18
|
+
* gate. If a feed fails to load (initial or refresh), traffic is **not**
|
|
19
|
+
* blocked: the last-known-good list is kept (or an empty list if nothing has
|
|
20
|
+
* loaded yet). A feed outage must never take the whole app down.
|
|
21
|
+
*
|
|
22
|
+
* The middleware is dependency-free and runtime-portable; it reuses the
|
|
23
|
+
* SSRF-grade CIDR matcher from `ipRestriction()`. {@link urlFeed}'s default
|
|
24
|
+
* transport is the platform `fetch`; pass your own for non-standard runtimes or
|
|
25
|
+
* to layer SSRF protection.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { ipReputation, urlFeed } from "@daloyjs/core";
|
|
30
|
+
*
|
|
31
|
+
* const reputation = ipReputation({
|
|
32
|
+
* trustProxyHeaders: true,
|
|
33
|
+
* feeds: [
|
|
34
|
+
* urlFeed("https://www.spamhaus.org/drop/drop.txt", { name: "spamhaus-drop" }),
|
|
35
|
+
* urlFeed("https://check.torproject.org/torbulkexitlist", { name: "tor-exit" }),
|
|
36
|
+
* ],
|
|
37
|
+
* refreshIntervalMs: 60 * 60_000, // hourly
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* app.use(reputation.hooks);
|
|
41
|
+
* // On shutdown: reputation.stop();
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @module
|
|
45
|
+
* @since 0.37.0
|
|
46
|
+
*/
|
|
47
|
+
import { ForbiddenError } from "./errors.js";
|
|
48
|
+
import { compileCidrMatcher, matchesMatcher, parseIp, } from "./ip-restriction.js";
|
|
49
|
+
const DEFAULT_REFRESH_MS = 60 * 60_000;
|
|
50
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
51
|
+
const DEFAULT_MESSAGE = "IP address not permitted";
|
|
52
|
+
/**
|
|
53
|
+
* Split a single feed line into its IP / CIDR token, stripping comments and the
|
|
54
|
+
* trailing annotations common to abuse feeds (e.g. Spamhaus DROP's
|
|
55
|
+
* `1.2.3.0/24 ; SBL123`). Returns `undefined` for comment-only / blank lines.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
function parseFeedLine(line) {
|
|
60
|
+
let s = line.trim();
|
|
61
|
+
if (!s || s.startsWith("#") || s.startsWith(";") || s.startsWith("//")) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
// Cut inline comments / annotations after the address token.
|
|
65
|
+
const cut = s.search(/[\s;#]/);
|
|
66
|
+
if (cut !== -1)
|
|
67
|
+
s = s.slice(0, cut);
|
|
68
|
+
return s || undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build an {@link IpReputationFeed} that fetches a newline-delimited IP / CIDR
|
|
72
|
+
* list over HTTP. Handles the Spamhaus-DROP-style `<cidr> ; <annotation>`
|
|
73
|
+
* format and `#` / `;` / `//` comment lines. Lines that aren't valid IPs/CIDRs
|
|
74
|
+
* are skipped by {@link ipReputation}, so a partially-malformed feed still loads
|
|
75
|
+
* its good entries.
|
|
76
|
+
*
|
|
77
|
+
* @param url - The feed URL.
|
|
78
|
+
* @param opts - Optional feed name, custom `fetch`, and request headers.
|
|
79
|
+
* @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
|
|
80
|
+
* @since 0.37.0
|
|
81
|
+
*/
|
|
82
|
+
export function urlFeed(url, opts = {}) {
|
|
83
|
+
const name = opts.name ?? url;
|
|
84
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
85
|
+
return {
|
|
86
|
+
name,
|
|
87
|
+
async fetch(signal) {
|
|
88
|
+
if (typeof doFetch !== "function") {
|
|
89
|
+
throw new Error("urlFeed: no fetch implementation available on this runtime. Pass options.fetchImpl.");
|
|
90
|
+
}
|
|
91
|
+
const res = await doFetch(url, {
|
|
92
|
+
signal,
|
|
93
|
+
headers: opts.headers,
|
|
94
|
+
redirect: "follow",
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
throw new Error(`urlFeed: ${name} responded ${res.status}.`);
|
|
98
|
+
}
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const line of text.split("\n")) {
|
|
102
|
+
const token = parseFeedLine(line);
|
|
103
|
+
if (token !== undefined)
|
|
104
|
+
out.push(token);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function forwardedIpResolver(ctx) {
|
|
111
|
+
const headers = ctx.request.headers;
|
|
112
|
+
const forwarded = headers.get("x-forwarded-for");
|
|
113
|
+
if (forwarded) {
|
|
114
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
115
|
+
if (first)
|
|
116
|
+
return first;
|
|
117
|
+
}
|
|
118
|
+
return ctx.request.headers.get("x-real-ip") ?? undefined;
|
|
119
|
+
}
|
|
120
|
+
function noIpResolver(_ctx) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* IP reputation / dynamic denylist middleware. Merges one or more pluggable
|
|
125
|
+
* abuse feeds into a periodically-refreshed denylist and rejects (or logs)
|
|
126
|
+
* requests from listed IPs, reusing the `ipRestriction()` CIDR matcher.
|
|
127
|
+
*
|
|
128
|
+
* Fail-open by design: a feed that cannot be loaded never blocks traffic — the
|
|
129
|
+
* last-known-good list is retained (empty until the first successful load). An
|
|
130
|
+
* unresolvable client IP is also treated as not-listed.
|
|
131
|
+
*
|
|
132
|
+
* @param opts - Reputation configuration; `feeds` must be non-empty.
|
|
133
|
+
* @returns An {@link IpReputationController} whose `hooks` go to `app.use(...)`.
|
|
134
|
+
* @throws Error when `feeds` is empty, `mode` is invalid, or a numeric option
|
|
135
|
+
* is out of range.
|
|
136
|
+
* @since 0.37.0
|
|
137
|
+
*/
|
|
138
|
+
export function ipReputation(opts) {
|
|
139
|
+
if (!opts.feeds || opts.feeds.length === 0) {
|
|
140
|
+
throw new Error("ipReputation(): at least one feed must be provided.");
|
|
141
|
+
}
|
|
142
|
+
const mode = opts.mode ?? "block";
|
|
143
|
+
if (mode !== "block" && mode !== "log") {
|
|
144
|
+
throw new Error('ipReputation(): mode must be "block" or "log".');
|
|
145
|
+
}
|
|
146
|
+
const refreshIntervalMs = opts.refreshIntervalMs ?? DEFAULT_REFRESH_MS;
|
|
147
|
+
if (!Number.isInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
|
|
148
|
+
throw new Error("ipReputation(): refreshIntervalMs must be a non-negative integer.");
|
|
149
|
+
}
|
|
150
|
+
const fetchTimeoutMs = opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
151
|
+
if (!Number.isInteger(fetchTimeoutMs) || fetchTimeoutMs <= 0) {
|
|
152
|
+
throw new Error("ipReputation(): fetchTimeoutMs must be a positive integer.");
|
|
153
|
+
}
|
|
154
|
+
const message = opts.message ?? DEFAULT_MESSAGE;
|
|
155
|
+
const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
|
|
156
|
+
// Last-known-good compiled denylist, one entry per feed so a single feed's
|
|
157
|
+
// failed refresh doesn't drop the others.
|
|
158
|
+
let compiled = opts.feeds.map((f) => ({ name: f.name, v4: [], v6: [] }));
|
|
159
|
+
const compileFeed = (name, entries) => {
|
|
160
|
+
const v4 = [];
|
|
161
|
+
const v6 = [];
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
let matcher;
|
|
164
|
+
try {
|
|
165
|
+
matcher = compileCidrMatcher(entry);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
continue; // skip junk lines; a malformed feed still loads its good rows
|
|
169
|
+
}
|
|
170
|
+
(matcher.family === 4 ? v4 : v6).push(matcher);
|
|
171
|
+
}
|
|
172
|
+
return { name, v4, v6 };
|
|
173
|
+
};
|
|
174
|
+
const refreshOne = async (feed, index) => {
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
|
177
|
+
if (typeof timer === "object" && typeof timer.unref === "function")
|
|
178
|
+
timer.unref();
|
|
179
|
+
try {
|
|
180
|
+
const entries = await feed.fetch(controller.signal);
|
|
181
|
+
compiled[index] = compileFeed(feed.name, entries);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// Fail-open: keep the previous compiled entries for this feed.
|
|
185
|
+
opts.onError?.(err, feed.name);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const refresh = async () => {
|
|
192
|
+
await Promise.all(opts.feeds.map((feed, i) => refreshOne(feed, i)));
|
|
193
|
+
};
|
|
194
|
+
const matchingFeeds = (ip) => {
|
|
195
|
+
const parsed = parseIp(ip);
|
|
196
|
+
if (!parsed)
|
|
197
|
+
return [];
|
|
198
|
+
const hits = [];
|
|
199
|
+
for (const feed of compiled) {
|
|
200
|
+
const list = parsed.family === 4 ? feed.v4 : feed.v6;
|
|
201
|
+
if (list.some((m) => matchesMatcher(parsed, m)))
|
|
202
|
+
hits.push(feed.name);
|
|
203
|
+
}
|
|
204
|
+
return hits;
|
|
205
|
+
};
|
|
206
|
+
let stopped = false;
|
|
207
|
+
let interval;
|
|
208
|
+
if (refreshIntervalMs > 0) {
|
|
209
|
+
interval = setInterval(() => {
|
|
210
|
+
void refresh();
|
|
211
|
+
}, refreshIntervalMs);
|
|
212
|
+
if (typeof interval === "object" && typeof interval.unref === "function") {
|
|
213
|
+
interval.unref();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const ready = opts.loadOnStart === false ? Promise.resolve() : refresh();
|
|
217
|
+
return {
|
|
218
|
+
hooks: {
|
|
219
|
+
beforeHandle(ctx) {
|
|
220
|
+
const ip = resolveIp(ctx);
|
|
221
|
+
if (!ip)
|
|
222
|
+
return undefined; // fail-open on unresolved IP
|
|
223
|
+
const feeds = matchingFeeds(ip);
|
|
224
|
+
if (feeds.length === 0)
|
|
225
|
+
return undefined;
|
|
226
|
+
opts.onMatch?.({ ip, feeds });
|
|
227
|
+
if (mode === "block")
|
|
228
|
+
throw new ForbiddenError(message);
|
|
229
|
+
return undefined;
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
refresh,
|
|
233
|
+
stop() {
|
|
234
|
+
if (stopped)
|
|
235
|
+
return;
|
|
236
|
+
stopped = true;
|
|
237
|
+
if (interval !== undefined)
|
|
238
|
+
clearInterval(interval);
|
|
239
|
+
},
|
|
240
|
+
get ready() {
|
|
241
|
+
return ready;
|
|
242
|
+
},
|
|
243
|
+
get size() {
|
|
244
|
+
let total = 0;
|
|
245
|
+
for (const feed of compiled)
|
|
246
|
+
total += feed.v4.length + feed.v6.length;
|
|
247
|
+
return total;
|
|
248
|
+
},
|
|
249
|
+
has(ip) {
|
|
250
|
+
return matchingFeeds(ip).length > 0;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
package/dist/jwk.d.ts
CHANGED
|
@@ -35,6 +35,21 @@ export interface JwkOptions {
|
|
|
35
35
|
* when `jwks` is not a URL.
|
|
36
36
|
*/
|
|
37
37
|
fetchTtlSeconds?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Grace window (seconds) during which a *previously fetched* JWKS keeps
|
|
40
|
+
* being served if a TTL-expiry refresh fails (network error, non-2xx,
|
|
41
|
+
* or malformed body). Measured from the last successful fetch, on top of
|
|
42
|
+
* {@link fetchTtlSeconds}; a transient IdP blip therefore does not fail
|
|
43
|
+
* every request while a perfectly valid cached key set is in hand. The
|
|
44
|
+
* first fetch is never eligible (there is no last-good set to fall back
|
|
45
|
+
* to), so an unreachable IdP at boot still rejects. Tokens are always
|
|
46
|
+
* cryptographically verified and `exp`-checked regardless. Set to `0` to
|
|
47
|
+
* disable and fail closed the moment the TTL lapses. Default `3600` (1h).
|
|
48
|
+
* Ignored when `jwks` is not a URL.
|
|
49
|
+
*
|
|
50
|
+
* @since 0.41.0
|
|
51
|
+
*/
|
|
52
|
+
maxStaleSeconds?: number;
|
|
38
53
|
/**
|
|
39
54
|
* Optional `fetch` implementation override (mainly for tests). Defaults
|
|
40
55
|
* to global `fetch`.
|
package/dist/jwk.js
CHANGED
|
@@ -71,7 +71,7 @@ function findJwkByKid(jwks, kid) {
|
|
|
71
71
|
}
|
|
72
72
|
return undefined;
|
|
73
73
|
}
|
|
74
|
-
function makeJwksLoader(source, fetchImpl, ttlSeconds) {
|
|
74
|
+
function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
|
|
75
75
|
if (typeof source === "string") {
|
|
76
76
|
if (!source.startsWith("https://")) {
|
|
77
77
|
throw new Error("jwk(): jwks URL must be https:// — refusing plaintext JWKS source.");
|
|
@@ -100,6 +100,20 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds) {
|
|
|
100
100
|
cache = { jwks: body, fetchedAt: Date.now() };
|
|
101
101
|
return body;
|
|
102
102
|
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
// Stale-while-error: a TTL-expiry refresh that fails must not take
|
|
105
|
+
// down all auth when a last-good JWKS is still within the grace
|
|
106
|
+
// window. Public verification keys rotate slowly; tokens remain
|
|
107
|
+
// cryptographically verified and exp-checked either way. The first
|
|
108
|
+
// fetch (no cache) always rethrows, so an unreachable IdP at boot
|
|
109
|
+
// still fails closed.
|
|
110
|
+
if (cache &&
|
|
111
|
+
maxStaleSeconds > 0 &&
|
|
112
|
+
Date.now() - cache.fetchedAt < (ttlSeconds + maxStaleSeconds) * 1000) {
|
|
113
|
+
return cache.jwks;
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
103
117
|
finally {
|
|
104
118
|
inflight = undefined;
|
|
105
119
|
}
|
|
@@ -168,12 +182,20 @@ export function jwk(opts) {
|
|
|
168
182
|
throw new Error("jwk(): fetchTtlSeconds must be a non-negative finite number.");
|
|
169
183
|
}
|
|
170
184
|
}
|
|
185
|
+
if (opts.maxStaleSeconds !== undefined) {
|
|
186
|
+
if (typeof opts.maxStaleSeconds !== "number" ||
|
|
187
|
+
!Number.isFinite(opts.maxStaleSeconds) ||
|
|
188
|
+
opts.maxStaleSeconds < 0) {
|
|
189
|
+
throw new Error("jwk(): maxStaleSeconds must be a non-negative finite number.");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
171
192
|
const realm = opts.realm ?? "api";
|
|
172
193
|
if (/["\r\n\0]/.test(realm)) {
|
|
173
194
|
throw new Error("jwk(): realm must not contain quotes, CR, LF, or NUL bytes.");
|
|
174
195
|
}
|
|
175
196
|
const ttl = opts.fetchTtlSeconds ?? 300;
|
|
176
|
-
const
|
|
197
|
+
const maxStale = opts.maxStaleSeconds ?? 3600;
|
|
198
|
+
const loader = makeJwksLoader(opts.jwks, opts.fetch ?? globalThis.fetch, ttl, maxStale);
|
|
177
199
|
const algorithms = [...opts.algorithms];
|
|
178
200
|
let cachedVerifier;
|
|
179
201
|
let cachedJwksRef;
|
package/dist/load-shedding.d.ts
CHANGED
|
@@ -39,10 +39,15 @@ export interface LoadSheddingOptions {
|
|
|
39
39
|
* @internal
|
|
40
40
|
*/
|
|
41
41
|
export interface LoadSheddingSnapshot {
|
|
42
|
+
/** Sampled event-loop delay in milliseconds. */
|
|
42
43
|
eventLoopDelayMs: number;
|
|
44
|
+
/** Sampled `heapUsed` in bytes. */
|
|
43
45
|
heapUsedBytes: number;
|
|
46
|
+
/** Sampled resident set size (`rss`) in bytes. */
|
|
44
47
|
rssBytes: number;
|
|
48
|
+
/** Sampled event-loop utilization in the range `[0, 1]`. */
|
|
45
49
|
eventLoopUtilization: number;
|
|
50
|
+
/** Reason the request was shed, when a threshold is exceeded. */
|
|
46
51
|
reason?: string;
|
|
47
52
|
}
|
|
48
53
|
/**
|
package/dist/logger.js
CHANGED
|
@@ -72,7 +72,11 @@ const JWT_LIKE_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
|
72
72
|
* conservatively to avoid false positives on ordinary identifiers.
|
|
73
73
|
*
|
|
74
74
|
* Sources (token formats published by each provider as of 2026):
|
|
75
|
-
* - GitHub: `gh[
|
|
75
|
+
* - GitHub: `gh[opru]_` 36–251 alphanumerics (opaque); `ghs_` 36+ of
|
|
76
|
+
* alnum/`.`/`-`/`_` to also cover the 2026 stateless installation-token
|
|
77
|
+
* format (a ~520-char `ghs_`-prefixed JWT with two dots — see
|
|
78
|
+
* <https://github.blog/changelog/2026-05-15-github-app-installation-tokens-per-request-override-header/>);
|
|
79
|
+
* `github_pat_` 40+ alnum/_
|
|
76
80
|
* - Slack: `xox[abprs]-` legacy/bot/user/refresh tokens
|
|
77
81
|
* - AWS: `AKIA`/`ASIA` + 16 uppercase alphanumerics
|
|
78
82
|
* - Stripe: `sk|rk|pk` + `_live_|_test_` + 20+ alphanumerics
|
|
@@ -82,7 +86,7 @@ const JWT_LIKE_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
|
82
86
|
* - Anthropic: `sk-ant-` + 20+ alnum/_/-
|
|
83
87
|
* - OpenAI: `sk-` + 20+ alnum/_/- (matched after the `sk-ant-` form)
|
|
84
88
|
*/
|
|
85
|
-
const CREDENTIAL_LIKE_RE = /(?:gh[
|
|
89
|
+
const CREDENTIAL_LIKE_RE = /(?:ghs_[A-Za-z0-9._-]{36,1024}|gh[opru]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{40,255}|xox[abprs]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20,}|AIza[A-Za-z0-9_-]{35}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,})/g;
|
|
86
90
|
function resolveRedaction(opt) {
|
|
87
91
|
if (opt === false)
|
|
88
92
|
return null;
|