@daloyjs/core 1.0.0 → 1.1.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 +4 -3
- package/dist/adapters/node.d.ts +6 -1
- package/dist/adapters/node.js +54 -0
- package/dist/auto-ban.d.ts +21 -6
- package/dist/auto-ban.js +10 -8
- package/dist/bot-guard.d.ts +12 -2
- package/dist/bot-guard.js +7 -5
- package/dist/concurrency-limit.d.ts +10 -0
- package/dist/concurrency-limit.js +15 -6
- package/dist/conn-info.d.ts +54 -9
- package/dist/conn-info.js +142 -14
- package/dist/fetch-guard.js +1 -1
- package/dist/geo-block.d.ts +11 -0
- package/dist/geo-block.js +6 -4
- package/dist/ip-match.d.ts +62 -0
- package/dist/ip-match.js +171 -0
- package/dist/ip-reputation.d.ts +10 -0
- package/dist/ip-reputation.js +7 -5
- package/dist/ip-restriction.d.ts +13 -48
- package/dist/ip-restriction.js +10 -161
- package/dist/jwt.d.ts +15 -1
- package/dist/jwt.js +35 -1
- package/dist/middleware.d.ts +27 -2
- package/dist/middleware.js +26 -7
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
package/dist/ip-restriction.js
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* @since 0.19.0
|
|
9
9
|
*/
|
|
10
10
|
import { ForbiddenError } from "./errors.js";
|
|
11
|
-
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
11
|
+
import { resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
12
|
+
// Matcher primitives live in a leaf module so `conn-info` can peer-verify
|
|
13
|
+
// without importing this file (which would form a cycle through trust helpers).
|
|
14
|
+
import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-match.js";
|
|
15
|
+
export { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-match.js";
|
|
12
16
|
/**
|
|
13
17
|
* Block or allow requests by source IP / CIDR range. In direct Web-standard
|
|
14
18
|
* runtimes, pass `resolveIp` from the adapter-specific connection metadata.
|
|
@@ -42,7 +46,9 @@ export function ipRestriction(opts) {
|
|
|
42
46
|
const allow = (opts.allow ?? []).map(compileCidrMatcher);
|
|
43
47
|
const deny = (opts.deny ?? []).map(compileCidrMatcher);
|
|
44
48
|
const hops = resolveForwardedTrust("ipRestriction()", opts);
|
|
45
|
-
const
|
|
49
|
+
const proxyMatchers = resolveTrustedProxyMatchers("ipRestriction()", opts);
|
|
50
|
+
const resolveIp = opts.resolveIp ??
|
|
51
|
+
(hops !== undefined ? forwardedIpResolver(hops, proxyMatchers) : noIpResolver);
|
|
46
52
|
const message = opts.message ?? "IP address not permitted";
|
|
47
53
|
return {
|
|
48
54
|
// `preBody`, not `beforeHandle`: a gate that returns a Response from
|
|
@@ -69,163 +75,6 @@ export function ipRestriction(opts) {
|
|
|
69
75
|
function noIpResolver(_ctx) {
|
|
70
76
|
return undefined;
|
|
71
77
|
}
|
|
72
|
-
function forwardedIpResolver(hops) {
|
|
73
|
-
return (ctx) => resolveForwardedClientIp(ctx.request, hops);
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Test whether a parsed IP falls inside a compiled CIDR matcher, comparing
|
|
77
|
-
* only the matcher's prefix bits. IPv4-mapped IPv6 addresses
|
|
78
|
-
* (`::ffff:a.b.c.d`) are normalized so they match IPv4 matchers.
|
|
79
|
-
*
|
|
80
|
-
* @param ip Parsed client address from {@link parseIp}.
|
|
81
|
-
* @param m Compiled matcher from {@link compileCidrMatcher}.
|
|
82
|
-
* @returns `true` when the address is within the matcher's range.
|
|
83
|
-
* @internal
|
|
84
|
-
*/
|
|
85
|
-
export function matchesMatcher(ip, m) {
|
|
86
|
-
const candidate = normalizeFamily(ip, m.family);
|
|
87
|
-
if (!candidate)
|
|
88
|
-
return false;
|
|
89
|
-
const expected = m.bytes;
|
|
90
|
-
const totalBits = candidate.length * 8;
|
|
91
|
-
const prefix = Math.min(m.prefix, totalBits);
|
|
92
|
-
const fullBytes = prefix >> 3;
|
|
93
|
-
for (let i = 0; i < fullBytes; i++) {
|
|
94
|
-
if (candidate[i] !== expected[i])
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
const remaining = prefix - fullBytes * 8;
|
|
98
|
-
if (remaining === 0)
|
|
99
|
-
return true;
|
|
100
|
-
const mask = 0xff << (8 - remaining);
|
|
101
|
-
return ((candidate[fullBytes] ^ expected[fullBytes]) & mask) === 0;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Compile an IP or CIDR pattern (e.g. `"10.0.0.0/8"`, `"::1"`) into an
|
|
105
|
-
* {@link IpMatcher}. A bare address gets a full-length prefix (/32 or /128);
|
|
106
|
-
* host bits beyond the prefix are masked to zero.
|
|
107
|
-
*
|
|
108
|
-
* @param input IPv4/IPv6 address, optionally with a `/prefix` suffix.
|
|
109
|
-
* @returns The compiled matcher used by {@link matchesMatcher}.
|
|
110
|
-
* @throws Error when the address or CIDR prefix is invalid.
|
|
111
|
-
* @internal
|
|
112
|
-
*/
|
|
113
|
-
export function compileCidrMatcher(input) {
|
|
114
|
-
let addr = input;
|
|
115
|
-
let prefixStr;
|
|
116
|
-
if (input.includes("/")) {
|
|
117
|
-
const slash = input.indexOf("/");
|
|
118
|
-
addr = input.slice(0, slash);
|
|
119
|
-
prefixStr = input.slice(slash + 1);
|
|
120
|
-
}
|
|
121
|
-
const parsed = parseIp(addr);
|
|
122
|
-
if (!parsed) {
|
|
123
|
-
throw new Error(`ipRestriction(): invalid IP address ${JSON.stringify(input)}.`);
|
|
124
|
-
}
|
|
125
|
-
const totalBits = parsed.family === 4 ? 32 : 128;
|
|
126
|
-
let prefix = totalBits;
|
|
127
|
-
if (prefixStr !== undefined) {
|
|
128
|
-
if (!/^\d+$/.test(prefixStr)) {
|
|
129
|
-
throw new Error(`ipRestriction(): invalid CIDR prefix in ${JSON.stringify(input)}.`);
|
|
130
|
-
}
|
|
131
|
-
prefix = Number.parseInt(prefixStr, 10);
|
|
132
|
-
if (!Number.isInteger(prefix) || prefix < 0 || prefix > totalBits) {
|
|
133
|
-
throw new Error(`ipRestriction(): invalid CIDR prefix in ${JSON.stringify(input)}.`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return { family: parsed.family, prefix, bytes: applyPrefixMask(parsed.bytes, prefix) };
|
|
137
|
-
}
|
|
138
|
-
function normalizeFamily(ip, family) {
|
|
139
|
-
if (ip.family === family)
|
|
140
|
-
return ip.bytes;
|
|
141
|
-
if (ip.family === 6 && family === 4) {
|
|
142
|
-
// IPv4-mapped IPv6 (::ffff:a.b.c.d) — accept as IPv4.
|
|
143
|
-
const b = ip.bytes;
|
|
144
|
-
const isMapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
145
|
-
if (isMapped)
|
|
146
|
-
return b.slice(12);
|
|
147
|
-
return undefined;
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
function applyPrefixMask(bytes, prefix) {
|
|
152
|
-
const out = new Uint8Array(bytes);
|
|
153
|
-
const fullBytes = prefix >> 3;
|
|
154
|
-
const remaining = prefix - fullBytes * 8;
|
|
155
|
-
if (remaining > 0 && fullBytes < out.length) {
|
|
156
|
-
const mask = 0xff << (8 - remaining);
|
|
157
|
-
out[fullBytes] = out[fullBytes] & mask;
|
|
158
|
-
}
|
|
159
|
-
for (let i = fullBytes + (remaining > 0 ? 1 : 0); i < out.length; i++) {
|
|
160
|
-
out[i] = 0;
|
|
161
|
-
}
|
|
162
|
-
return out;
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Parse an IPv4 or IPv6 address string into raw bytes. Supports IPv6 `::`
|
|
166
|
-
* compression and IPv4-mapped tails (`::ffff:1.2.3.4`).
|
|
167
|
-
*
|
|
168
|
-
* @param input Address string; surrounding whitespace is trimmed.
|
|
169
|
-
* @returns The parsed address, or `undefined` when the input is not a valid
|
|
170
|
-
* IP (callers treat unparseable addresses as a rejection, failing closed).
|
|
171
|
-
* @internal
|
|
172
|
-
*/
|
|
173
|
-
export function parseIp(input) {
|
|
174
|
-
const trimmed = input.trim();
|
|
175
|
-
if (trimmed.includes(":"))
|
|
176
|
-
return parseIPv6(trimmed);
|
|
177
|
-
return parseIPv4(trimmed);
|
|
178
|
-
}
|
|
179
|
-
function parseIPv4(input) {
|
|
180
|
-
const parts = input.split(".");
|
|
181
|
-
if (parts.length !== 4)
|
|
182
|
-
return undefined;
|
|
183
|
-
const bytes = new Uint8Array(4);
|
|
184
|
-
for (let i = 0; i < 4; i++) {
|
|
185
|
-
const part = parts[i];
|
|
186
|
-
if (!/^\d{1,3}$/.test(part))
|
|
187
|
-
return undefined;
|
|
188
|
-
const n = Number.parseInt(part, 10);
|
|
189
|
-
if (n < 0 || n > 255)
|
|
190
|
-
return undefined;
|
|
191
|
-
bytes[i] = n;
|
|
192
|
-
}
|
|
193
|
-
return { bytes, family: 4 };
|
|
194
|
-
}
|
|
195
|
-
function parseIPv6(input) {
|
|
196
|
-
// Support IPv4-mapped tail (::ffff:1.2.3.4).
|
|
197
|
-
let working = input;
|
|
198
|
-
const lastColon = working.lastIndexOf(":");
|
|
199
|
-
if (lastColon !== -1 && working.slice(lastColon + 1).includes(".")) {
|
|
200
|
-
const v4 = parseIPv4(working.slice(lastColon + 1));
|
|
201
|
-
if (!v4)
|
|
202
|
-
return undefined;
|
|
203
|
-
const hi = (v4.bytes[0] << 8) | v4.bytes[1];
|
|
204
|
-
const lo = (v4.bytes[2] << 8) | v4.bytes[3];
|
|
205
|
-
working = working.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
|
|
206
|
-
}
|
|
207
|
-
const parts = working.split("::");
|
|
208
|
-
if (parts.length > 2)
|
|
209
|
-
return undefined;
|
|
210
|
-
const headGroups = parts[0] === "" ? [] : parts[0].split(":");
|
|
211
|
-
const tailGroups = parts.length === 2 && parts[1] !== "" ? parts[1].split(":") : [];
|
|
212
|
-
const explicit = headGroups.length + tailGroups.length;
|
|
213
|
-
if (explicit > 8)
|
|
214
|
-
return undefined;
|
|
215
|
-
if (parts.length === 1 && explicit !== 8)
|
|
216
|
-
return undefined;
|
|
217
|
-
const missing = parts.length === 2 ? 8 - explicit : 0;
|
|
218
|
-
const groups = [...headGroups, ...Array.from({ length: missing }, () => "0"), ...tailGroups];
|
|
219
|
-
if (groups.length !== 8)
|
|
220
|
-
return undefined;
|
|
221
|
-
const bytes = new Uint8Array(16);
|
|
222
|
-
for (let index = 0; index < 8; index++) {
|
|
223
|
-
const group = groups[index];
|
|
224
|
-
if (!/^[0-9a-fA-F]{1,4}$/.test(group))
|
|
225
|
-
return undefined;
|
|
226
|
-
const n = Number.parseInt(group, 16);
|
|
227
|
-
bytes[index * 2] = (n >> 8) & 0xff;
|
|
228
|
-
bytes[index * 2 + 1] = n & 0xff;
|
|
229
|
-
}
|
|
230
|
-
return { bytes, family: 6 };
|
|
78
|
+
function forwardedIpResolver(hops, trustedPeers) {
|
|
79
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
231
80
|
}
|
package/dist/jwt.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export interface JwtSignerOptions {
|
|
|
57
57
|
env?: "production" | "development" | "test";
|
|
58
58
|
/** Set `secureDefaults: false` to skip the production opt-out gate. */
|
|
59
59
|
secureDefaults?: boolean;
|
|
60
|
-
/** Optional extra header fields (`kid`, `typ`, ...). `alg` is always derived. */
|
|
60
|
+
/** Optional extra header fields (`kid`, `typ`, ...). `alg` is always derived. `crit` is refused: no JWS extensions are supported, so a `crit` token could never verify. */
|
|
61
61
|
header?: Record<string, unknown>;
|
|
62
62
|
}
|
|
63
63
|
/** Options for {@link createJwtVerifier}. */
|
|
@@ -94,6 +94,20 @@ export interface JwtVerifierOptions {
|
|
|
94
94
|
* never `token_revoked` (which would leak the existence of the `jti`).
|
|
95
95
|
*/
|
|
96
96
|
isRevoked?: (verified: JwtVerified) => boolean | Promise<boolean>;
|
|
97
|
+
/**
|
|
98
|
+
* Maximum accepted token lifetime in seconds, enforced at verification
|
|
99
|
+
* time as `exp - (iat ?? now) <= maxLifetimeSeconds`. Mirrors the signer-
|
|
100
|
+
* side {@link JwtSignerOptions.maxLifetimeSeconds} requirement: without it,
|
|
101
|
+
* a verifier accepts any validly-signed token no matter how long it lives
|
|
102
|
+
* (a 100-year token issued by a misconfigured or compromised signer of the
|
|
103
|
+
* same key would verify). Tokens with no `exp` are also rejected when this
|
|
104
|
+
* is set. Optional — left unset for callers that deliberately accept
|
|
105
|
+
* externally-issued long-lived tokens (e.g. refresh tokens verified by a
|
|
106
|
+
* dedicated endpoint).
|
|
107
|
+
*
|
|
108
|
+
* @since 1.1.0
|
|
109
|
+
*/
|
|
110
|
+
maxLifetimeSeconds?: number;
|
|
97
111
|
/** Optional injectable clock for tests. */
|
|
98
112
|
now?: () => number;
|
|
99
113
|
}
|
package/dist/jwt.js
CHANGED
|
@@ -260,6 +260,14 @@ export function createJwtSigner(opts) {
|
|
|
260
260
|
opts.secureDefaults !== false) {
|
|
261
261
|
throw new JwtError("ack_no_exp_refused_in_production", "jwt(): acknowledgeNoExp: true is refused in production under secureDefaults — every issued JWT must carry an exp claim.");
|
|
262
262
|
}
|
|
263
|
+
// The verifier refuses any `crit` header per RFC 7515 §4.1.11 (no JWS
|
|
264
|
+
// extensions are supported), so the signer must never emit one either —
|
|
265
|
+
// otherwise it would mint tokens its own verifier rejects. Checked
|
|
266
|
+
// synchronously at construction like every other signer option.
|
|
267
|
+
const extraHeader = opts.header && isJsonObject(opts.header) ? { ...opts.header } : {};
|
|
268
|
+
if ("crit" in extraHeader) {
|
|
269
|
+
throw new JwtError("unsupported_crit", "jwt(): header.crit is refused — this implementation supports no JWS extensions, so a crit token could never verify.");
|
|
270
|
+
}
|
|
263
271
|
const resolved = (async () => {
|
|
264
272
|
const key = await importKey(alg, opts.key, "sign");
|
|
265
273
|
return {
|
|
@@ -267,7 +275,7 @@ export function createJwtSigner(opts) {
|
|
|
267
275
|
key,
|
|
268
276
|
maxLifetimeSeconds: opts.maxLifetimeSeconds,
|
|
269
277
|
allowNoExp: opts.acknowledgeNoExp === true,
|
|
270
|
-
header:
|
|
278
|
+
header: extraHeader,
|
|
271
279
|
};
|
|
272
280
|
})();
|
|
273
281
|
return {
|
|
@@ -375,6 +383,12 @@ export function createJwtVerifier(opts) {
|
|
|
375
383
|
if (opts.isRevoked !== undefined && typeof opts.isRevoked !== "function") {
|
|
376
384
|
throw new JwtError("invalid_is_revoked", "jwt(): isRevoked must be a function (verified) => boolean | Promise<boolean>.");
|
|
377
385
|
}
|
|
386
|
+
if (opts.maxLifetimeSeconds !== undefined &&
|
|
387
|
+
(!Number.isFinite(opts.maxLifetimeSeconds) ||
|
|
388
|
+
opts.maxLifetimeSeconds <= 0 ||
|
|
389
|
+
!Number.isInteger(opts.maxLifetimeSeconds))) {
|
|
390
|
+
throw new JwtError("invalid_max_lifetime", "jwt(): verifier maxLifetimeSeconds must be a positive integer.");
|
|
391
|
+
}
|
|
378
392
|
const issuers = normalizeStringSet(opts.issuer);
|
|
379
393
|
const audiences = normalizeStringSet(opts.audience);
|
|
380
394
|
const keyCache = new Map();
|
|
@@ -403,6 +417,7 @@ export function createJwtVerifier(opts) {
|
|
|
403
417
|
audiences,
|
|
404
418
|
clockSkewSeconds: opts.clockSkewSeconds ?? 0,
|
|
405
419
|
isRevoked: opts.isRevoked ?? null,
|
|
420
|
+
maxLifetimeSeconds: opts.maxLifetimeSeconds ?? null,
|
|
406
421
|
now: opts.now ?? (() => Math.floor(Date.now() / 1000)),
|
|
407
422
|
};
|
|
408
423
|
return {
|
|
@@ -449,6 +464,15 @@ async function verifyInternal(token, r) {
|
|
|
449
464
|
throw new JwtError("alg_not_allowed", `jwt(): token alg "${String(algRaw)}" is not in the allowlist.`);
|
|
450
465
|
}
|
|
451
466
|
const alg = algRaw;
|
|
467
|
+
// RFC 7515 §4.1.11: a JWS whose header carries `crit` MUST be rejected when
|
|
468
|
+
// any listed extension is not understood. This verifier supports no JWS
|
|
469
|
+
// extensions at all, so any `crit` header is refused outright — otherwise a
|
|
470
|
+
// token smuggling an extension-marked-critical parameter (e.g.
|
|
471
|
+
// `crit:["exp"]` with `exp` mirrored into the header) would be accepted
|
|
472
|
+
// while its critical semantics were silently ignored.
|
|
473
|
+
if (header.crit !== undefined) {
|
|
474
|
+
throw new JwtError("unsupported_crit", "jwt(): token header carries 'crit' but this verifier supports no JWS extensions; refusing per RFC 7515 §4.1.11.");
|
|
475
|
+
}
|
|
452
476
|
const key = await r.resolveKey(header);
|
|
453
477
|
const sig = b64urlDecode(sigB64);
|
|
454
478
|
const signingInput = ENC.encode(`${headerB64}.${payloadB64}`);
|
|
@@ -475,6 +499,16 @@ async function verifyInternal(token, r) {
|
|
|
475
499
|
}
|
|
476
500
|
throw err;
|
|
477
501
|
}
|
|
502
|
+
if (r.maxLifetimeSeconds !== null) {
|
|
503
|
+
const exp = payload.exp;
|
|
504
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) {
|
|
505
|
+
throw new JwtError("missing_exp", "jwt(): verifier maxLifetimeSeconds is set but the token has no exp claim.");
|
|
506
|
+
}
|
|
507
|
+
const iat = typeof payload.iat === "number" && Number.isFinite(payload.iat) ? payload.iat : now;
|
|
508
|
+
if (exp - iat > r.maxLifetimeSeconds) {
|
|
509
|
+
throw new JwtError("lifetime_exceeded", `jwt(): token lifetime ${exp - iat}s exceeds verifier maxLifetimeSeconds ${r.maxLifetimeSeconds}s.`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
478
512
|
if (r.issuers) {
|
|
479
513
|
const iss = payload.iss;
|
|
480
514
|
if (typeof iss !== "string" || !r.issuers.has(iss)) {
|
package/dist/middleware.d.ts
CHANGED
|
@@ -453,7 +453,7 @@ export interface RateLimitStore {
|
|
|
453
453
|
export type RateLimitContext = PreBodyContext<any> | BaseContext<any, any>;
|
|
454
454
|
/** Options for {@link rateLimit}. */
|
|
455
455
|
export interface RateLimitOptions {
|
|
456
|
-
/**
|
|
456
|
+
/** Fixed-window width in milliseconds (e.g. `60_000` for one minute). */
|
|
457
457
|
windowMs: number;
|
|
458
458
|
/** Maximum allowed requests per `windowMs` per key. */
|
|
459
459
|
max: number;
|
|
@@ -474,7 +474,9 @@ export interface RateLimitOptions {
|
|
|
474
474
|
* When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
|
|
475
475
|
* one your immediate proxy appended — never the attacker-influenceable
|
|
476
476
|
* leftmost one, so rotating spoofed left entries cannot evade the limit.
|
|
477
|
-
* Behind more than one proxy hop, set {@link trustedHops} instead
|
|
477
|
+
* Behind more than one proxy hop, set {@link trustedHops} instead; to also
|
|
478
|
+
* verify the peer is one of YOUR proxies (required when the origin itself
|
|
479
|
+
* can be reached), set {@link trustedProxies}.
|
|
478
480
|
*/
|
|
479
481
|
trustProxyHeaders?: boolean;
|
|
480
482
|
/**
|
|
@@ -486,6 +488,18 @@ export interface RateLimitOptions {
|
|
|
486
488
|
* is supplied.
|
|
487
489
|
*/
|
|
488
490
|
trustedHops?: number;
|
|
491
|
+
/**
|
|
492
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
493
|
+
* peer's address. Forwarded headers feed the bucket key only when the TCP
|
|
494
|
+
* socket actually talking to the adapter matches the list — the one
|
|
495
|
+
* property a remote client cannot spoof — so a direct-to-origin attacker
|
|
496
|
+
* can neither evade the limit with rotating spoofed XFF nor burn another
|
|
497
|
+
* identity's bucket. Implies proxy-header trust at one hop unless
|
|
498
|
+
* {@link trustedHops} says otherwise; validated and compiled at
|
|
499
|
+
* construction. On peer-less edge platforms verification fails closed.
|
|
500
|
+
* Ignored when a custom `keyGenerator` is supplied.
|
|
501
|
+
*/
|
|
502
|
+
trustedProxies?: readonly string[];
|
|
489
503
|
/** When true, set Retry-After header on 429. Default: true. */
|
|
490
504
|
retryAfter?: boolean;
|
|
491
505
|
/**
|
|
@@ -578,6 +592,17 @@ export interface LoginThrottleOptions {
|
|
|
578
592
|
* supplied.
|
|
579
593
|
*/
|
|
580
594
|
trustedHops?: number;
|
|
595
|
+
/**
|
|
596
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
597
|
+
* peer's address. Forwarded headers feed the throttle key only when the
|
|
598
|
+
* TCP socket actually talking to the adapter matches the list, so a
|
|
599
|
+
* direct-to-origin attacker cannot dodge the slowdown with spoofed XFF.
|
|
600
|
+
* Implies proxy-header trust at one hop unless {@link trustedHops} says
|
|
601
|
+
* otherwise; validated and compiled at construction. On peer-less edge
|
|
602
|
+
* platforms verification fails closed. Ignored when a custom
|
|
603
|
+
* `keyGenerator` is supplied.
|
|
604
|
+
*/
|
|
605
|
+
trustedProxies?: readonly string[];
|
|
581
606
|
/** When true, set Retry-After header on 429. Default: true. */
|
|
582
607
|
retryAfter?: boolean;
|
|
583
608
|
/** Start slowing responses after this many attempts in the same window. Default: 2. */
|
package/dist/middleware.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { assertCookieAttributes, readRequestCookie, serializeCookie } from "./cookie.js";
|
|
8
8
|
import { TooManyRequestsError, ForbiddenError } from "./errors.js";
|
|
9
9
|
import { randomId, sanitizeHeaderName, timingSafeEqual } from "./security.js";
|
|
10
|
-
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
10
|
+
import { getConnInfo, resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
11
11
|
/**
|
|
12
12
|
* Generate or accept a stable `X-Request-ID` for every request. The id is
|
|
13
13
|
* stamped on `ctx.state.requestId`, mirrored on outgoing response headers,
|
|
@@ -777,7 +777,8 @@ export function rateLimit(opts) {
|
|
|
777
777
|
}
|
|
778
778
|
const groupPrefix = opts.groupId ? `${opts.groupId}:` : "";
|
|
779
779
|
const hops = resolveForwardedTrust("rateLimit()", opts);
|
|
780
|
-
const
|
|
780
|
+
const proxyMatchers = resolveTrustedProxyMatchers("rateLimit()", opts);
|
|
781
|
+
const keyOf = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops, proxyMatchers);
|
|
781
782
|
const enforce = async (ctx) => {
|
|
782
783
|
const key = `${groupPrefix}${keyOf(ctx)}`;
|
|
783
784
|
const { count, resetMs } = await store.hit(key, opts.windowMs);
|
|
@@ -807,20 +808,37 @@ function assertPositiveInteger(name, value) {
|
|
|
807
808
|
}
|
|
808
809
|
/**
|
|
809
810
|
* Default rate-limit / login-throttle key: the spoof-resistant forwarded client
|
|
810
|
-
* IP,
|
|
811
|
-
*
|
|
811
|
+
* IP, the unspoofable TCP peer when no trustworthy forwarded identity exists,
|
|
812
|
+
* or the shared `"global"` bucket only when proxy-header trust is off or the
|
|
813
|
+
* adapter attached no peer metadata.
|
|
812
814
|
*
|
|
813
815
|
* @param hops - Trusted proxy hop count from
|
|
814
816
|
* {@link "./conn-info.js".resolveForwardedTrust}, or `undefined` when
|
|
815
817
|
* forwarded-header trust is disabled.
|
|
818
|
+
* @param trustedPeers - Compiled `trustedProxies` allowlist from
|
|
819
|
+
* {@link "./conn-info.js".resolveTrustedProxyMatchers}; when supplied, the
|
|
820
|
+
* forwarded identity is honoured only for a verified proxy peer.
|
|
816
821
|
* @returns A key generator suitable for {@link rateLimit} and
|
|
817
822
|
* {@link loginThrottle}.
|
|
818
823
|
* @internal
|
|
819
824
|
*/
|
|
820
|
-
function defaultForwardedRateLimitKey(hops) {
|
|
825
|
+
function defaultForwardedRateLimitKey(hops, trustedPeers) {
|
|
821
826
|
if (hops === undefined)
|
|
822
827
|
return () => "global";
|
|
823
|
-
return (ctx) =>
|
|
828
|
+
return (ctx) => {
|
|
829
|
+
const forwarded = resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
830
|
+
if (forwarded !== undefined)
|
|
831
|
+
return forwarded;
|
|
832
|
+
// Fail safe, not silent: missing/untrusted XFF must not collapse every
|
|
833
|
+
// caller into one shared bucket (attacker-induced global lockout). Key on
|
|
834
|
+
// the unspoofable TCP peer when the adapter exposed one — including the
|
|
835
|
+
// plain `trustProxyHeaders: true` path, not only `trustedProxies`. Only a
|
|
836
|
+
// truly peer-less request shares "global".
|
|
837
|
+
const peer = getConnInfo(ctx.request)?.remoteAddress;
|
|
838
|
+
if (peer !== undefined)
|
|
839
|
+
return `peer:${peer}`;
|
|
840
|
+
return "global";
|
|
841
|
+
};
|
|
824
842
|
}
|
|
825
843
|
function wait(ms) {
|
|
826
844
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -854,7 +872,8 @@ export function loginThrottle(opts = {}) {
|
|
|
854
872
|
assertNonNegativeInteger("maxDelayMs", maxDelayMs);
|
|
855
873
|
const groupId = opts.groupId ?? "login";
|
|
856
874
|
const hops = resolveForwardedTrust("loginThrottle()", opts);
|
|
857
|
-
const
|
|
875
|
+
const proxyMatchers = resolveTrustedProxyMatchers("loginThrottle()", opts);
|
|
876
|
+
const keyGenerator = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops, proxyMatchers);
|
|
858
877
|
const limiter = rateLimit({
|
|
859
878
|
windowMs,
|
|
860
879
|
max,
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:6f1d01b1-95cd-5475-994d-9d54a26deac5",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-08-
|
|
7
|
+
"timestamp": "2026-08-07T14:15:51.163Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.1.0"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.1.0",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.
|
|
24
|
+
"version": "1.1.0",
|
|
25
25
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.1.0",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.1.0",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.
|
|
51
|
+
"version": "1.1.0",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.1.0",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.
|
|
5
|
+
"name": "@daloyjs/core-1.1.0",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.1.0-6f1d01b1-95cd-5475-994d-9d54a26deac5",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-08-
|
|
8
|
+
"created": "2026-08-07T14:15:51.163Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.
|
|
19
|
+
"versionInfo": "1.1.0",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.1.0"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops \u2014 distributed via pnpm.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|