@daloyjs/core 1.3.2 → 1.3.4
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 +2 -1
- package/dist/app.d.ts +14 -3
- package/dist/app.js +39 -15
- package/dist/conn-info.d.ts +3 -2
- package/dist/conn-info.js +11 -9
- package/dist/fetch-guard.d.ts +2 -1
- package/dist/fetch-guard.js +10 -3
- package/dist/logger.d.ts +3 -1
- package/dist/logger.js +8 -0
- package/dist/mtls.d.ts +7 -3
- package/dist/mtls.js +7 -3
- package/dist/router.d.ts +26 -7
- package/dist/router.js +93 -25
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +31 -14
- package/dist/waf.js +19 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -491,7 +491,8 @@ miss 7,742,635 ops/sec
|
|
|
491
491
|
|
|
492
492
|
- After traversal checks, exact static routes resolve with an allocation-free
|
|
493
493
|
`Map.get` fast path — **~26M ops/sec**.
|
|
494
|
-
- Dynamic routes walk a trie
|
|
494
|
+
- Dynamic routes walk a segment trie in path-length time without backtracking;
|
|
495
|
+
overlapping routes can require visiting additional branches.
|
|
495
496
|
- Body parsing is lazy and only runs when a route declares a body schema.
|
|
496
497
|
- Path normalization and splitting use index/character scans rather than
|
|
497
498
|
regular expressions.
|
package/dist/app.d.ts
CHANGED
|
@@ -1049,7 +1049,7 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1049
1049
|
*/
|
|
1050
1050
|
private responseBodySchemaAuditDone;
|
|
1051
1051
|
/**
|
|
1052
|
-
|
|
1052
|
+
* Cached merge of `options.hooks` and secure-header response hooks. Used on the cold 404/405 path
|
|
1053
1053
|
* and as the baseline for cross-origin guard decisions when no route
|
|
1054
1054
|
* matches.
|
|
1055
1055
|
*/
|
|
@@ -1405,7 +1405,9 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1405
1405
|
*
|
|
1406
1406
|
* Defaults (secure-by-default):
|
|
1407
1407
|
* - path: `/healthz`
|
|
1408
|
-
|
|
1408
|
+
* - rate-limit: 60 req/min per trusted identity, or a shared global bucket,
|
|
1409
|
+
* in-memory (per-process). Verified-token and rejected requests use
|
|
1410
|
+
* separate budgets so unauthenticated traffic cannot exhaust probe capacity.
|
|
1409
1411
|
* - auth: opt-in via `token`. In production with `secureDefaults: true`,
|
|
1410
1412
|
* registration refuses to add the route without a `token` unless
|
|
1411
1413
|
* `acknowledgeUnauthenticated: true` is set, so an unguarded
|
|
@@ -1416,6 +1418,9 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1416
1418
|
* app.healthcheck({ token: process.env.HEALTH_TOKEN! });
|
|
1417
1419
|
* ```
|
|
1418
1420
|
*
|
|
1421
|
+
* @param opts - Probe path, token, and rate-limit policy.
|
|
1422
|
+
* @returns This app for chaining.
|
|
1423
|
+
* @throws {Error} If production authentication is missing without acknowledgment.
|
|
1419
1424
|
* @since 0.18.0
|
|
1420
1425
|
*/
|
|
1421
1426
|
healthcheck(opts?: HealthRouteOptions): this;
|
|
@@ -1428,6 +1433,9 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1428
1433
|
*
|
|
1429
1434
|
* Defaults match {@link App.healthcheck} (path defaults to `/readyz`).
|
|
1430
1435
|
*
|
|
1436
|
+
* @param opts - Probe path, token, and rate-limit policy.
|
|
1437
|
+
* @returns This app for chaining.
|
|
1438
|
+
* @throws {Error} If production authentication is missing without acknowledgment.
|
|
1431
1439
|
* @since 0.18.0
|
|
1432
1440
|
*/
|
|
1433
1441
|
readinesscheck(opts?: HealthRouteOptions): this;
|
|
@@ -1445,7 +1453,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1445
1453
|
*
|
|
1446
1454
|
* The scrape route inherits the same hardened posture as
|
|
1447
1455
|
* {@link App.healthcheck}: optional bearer token compared via
|
|
1448
|
-
|
|
1456
|
+
* {@link timingSafeEqual}, a per-trusted-identity (otherwise global) fixed-window
|
|
1457
|
+
* rate limit with separate verified-token and rejected-request budgets, and a
|
|
1449
1458
|
* refuse-to-boot guard in production (an unauthenticated `/metrics`
|
|
1450
1459
|
* endpoint leaks internal route names, latency, and traffic volume) unless
|
|
1451
1460
|
* a token is supplied or `acknowledgeUnauthenticated: true` is passed.
|
|
@@ -1462,6 +1471,7 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1462
1471
|
*
|
|
1463
1472
|
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1464
1473
|
* @returns `this` for chaining.
|
|
1474
|
+
* @throws {Error} If production authentication is missing without acknowledgment.
|
|
1465
1475
|
* @since 0.37.0
|
|
1466
1476
|
*/
|
|
1467
1477
|
metrics(opts?: MetricsRouteOptions): this;
|
|
@@ -1674,6 +1684,7 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1674
1684
|
* ```
|
|
1675
1685
|
*
|
|
1676
1686
|
* @param hooks - Hook bundle applied to subsequent routes.
|
|
1687
|
+
* Secure-header hooks also apply to unmatched routes and early rejections.
|
|
1677
1688
|
* @returns This `App` instance for chaining.
|
|
1678
1689
|
*/
|
|
1679
1690
|
use(hooks: Hooks): this;
|
package/dist/app.js
CHANGED
|
@@ -479,7 +479,7 @@ export class App {
|
|
|
479
479
|
*/
|
|
480
480
|
responseBodySchemaAuditDone = false;
|
|
481
481
|
/**
|
|
482
|
-
|
|
482
|
+
* Cached merge of `options.hooks` and secure-header response hooks. Used on the cold 404/405 path
|
|
483
483
|
* and as the baseline for cross-origin guard decisions when no route
|
|
484
484
|
* matches.
|
|
485
485
|
*/
|
|
@@ -487,7 +487,12 @@ export class App {
|
|
|
487
487
|
_globalCorsAllowsCache;
|
|
488
488
|
get globalHooks() {
|
|
489
489
|
if (this._globalHooksCache === undefined) {
|
|
490
|
-
this._globalHooksCache = mergeHooks([
|
|
490
|
+
this._globalHooksCache = mergeHooks([
|
|
491
|
+
this.options.hooks ?? {},
|
|
492
|
+
...this.groupHooks
|
|
493
|
+
.filter(hook => hook[SECURE_HEADERS_MARKER] === true)
|
|
494
|
+
.map(hook => ({ onSend: hook.onSend, onResponse: hook.onResponse })),
|
|
495
|
+
]);
|
|
491
496
|
}
|
|
492
497
|
return this._globalHooksCache;
|
|
493
498
|
}
|
|
@@ -829,8 +834,13 @@ export class App {
|
|
|
829
834
|
return;
|
|
830
835
|
}
|
|
831
836
|
const origin = request.headers.get("origin");
|
|
832
|
-
if (!origin
|
|
837
|
+
if (!origin)
|
|
833
838
|
return;
|
|
839
|
+
if (origin === "null") {
|
|
840
|
+
if (corsOriginAllows.some((allows) => allows(origin)))
|
|
841
|
+
return;
|
|
842
|
+
throw new ForbiddenError("Cross-origin state-changing request rejected: opaque Origin requires an allowing cors() policy.");
|
|
843
|
+
}
|
|
834
844
|
// Fast path: when both the Origin header and the request URL are in the
|
|
835
845
|
// trivially-normalized shape (lowercase ASCII scheme://host[:port] with
|
|
836
846
|
// no userinfo / percent-escapes / IPv6 brackets), their origins can be
|
|
@@ -1784,7 +1794,9 @@ export class App {
|
|
|
1784
1794
|
*
|
|
1785
1795
|
* Defaults (secure-by-default):
|
|
1786
1796
|
* - path: `/healthz`
|
|
1787
|
-
|
|
1797
|
+
* - rate-limit: 60 req/min per trusted identity, or a shared global bucket,
|
|
1798
|
+
* in-memory (per-process). Verified-token and rejected requests use
|
|
1799
|
+
* separate budgets so unauthenticated traffic cannot exhaust probe capacity.
|
|
1788
1800
|
* - auth: opt-in via `token`. In production with `secureDefaults: true`,
|
|
1789
1801
|
* registration refuses to add the route without a `token` unless
|
|
1790
1802
|
* `acknowledgeUnauthenticated: true` is set, so an unguarded
|
|
@@ -1795,6 +1807,9 @@ export class App {
|
|
|
1795
1807
|
* app.healthcheck({ token: process.env.HEALTH_TOKEN! });
|
|
1796
1808
|
* ```
|
|
1797
1809
|
*
|
|
1810
|
+
* @param opts - Probe path, token, and rate-limit policy.
|
|
1811
|
+
* @returns This app for chaining.
|
|
1812
|
+
* @throws {Error} If production authentication is missing without acknowledgment.
|
|
1798
1813
|
* @since 0.18.0
|
|
1799
1814
|
*/
|
|
1800
1815
|
healthcheck(opts = {}) {
|
|
@@ -1813,6 +1828,9 @@ export class App {
|
|
|
1813
1828
|
*
|
|
1814
1829
|
* Defaults match {@link App.healthcheck} (path defaults to `/readyz`).
|
|
1815
1830
|
*
|
|
1831
|
+
* @param opts - Probe path, token, and rate-limit policy.
|
|
1832
|
+
* @returns This app for chaining.
|
|
1833
|
+
* @throws {Error} If production authentication is missing without acknowledgment.
|
|
1816
1834
|
* @since 0.18.0
|
|
1817
1835
|
*/
|
|
1818
1836
|
readinesscheck(opts = {}) {
|
|
@@ -1847,7 +1865,8 @@ export class App {
|
|
|
1847
1865
|
*
|
|
1848
1866
|
* The scrape route inherits the same hardened posture as
|
|
1849
1867
|
* {@link App.healthcheck}: optional bearer token compared via
|
|
1850
|
-
|
|
1868
|
+
* {@link timingSafeEqual}, a per-trusted-identity (otherwise global) fixed-window
|
|
1869
|
+
* rate limit with separate verified-token and rejected-request budgets, and a
|
|
1851
1870
|
* refuse-to-boot guard in production (an unauthenticated `/metrics`
|
|
1852
1871
|
* endpoint leaks internal route names, latency, and traffic volume) unless
|
|
1853
1872
|
* a token is supplied or `acknowledgeUnauthenticated: true` is passed.
|
|
@@ -1864,6 +1883,7 @@ export class App {
|
|
|
1864
1883
|
*
|
|
1865
1884
|
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1866
1885
|
* @returns `this` for chaining.
|
|
1886
|
+
* @throws {Error} If production authentication is missing without acknowledgment.
|
|
1867
1887
|
* @since 0.37.0
|
|
1868
1888
|
*/
|
|
1869
1889
|
metrics(opts = {}) {
|
|
@@ -1931,8 +1951,11 @@ export class App {
|
|
|
1931
1951
|
// Prometheus text exposition rendered by the framework registry.
|
|
1932
1952
|
acknowledgeNoResponseBodySchema: true,
|
|
1933
1953
|
handler: async ({ request }) => {
|
|
1954
|
+
const authMatch = token === undefined ? null : /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "");
|
|
1955
|
+
const tokenAccepted = token !== undefined && authMatch !== null && timingSafeEqual(authMatch[1], token);
|
|
1934
1956
|
if (buckets && rateLimitConfig) {
|
|
1935
|
-
const
|
|
1957
|
+
const identity = healthRouteKey(request, trustProxyHeaders);
|
|
1958
|
+
const key = token === undefined ? identity : `${tokenAccepted ? "verified" : "unverified"}\u0000${identity}`;
|
|
1936
1959
|
const now = Date.now();
|
|
1937
1960
|
const entry = buckets.get(key);
|
|
1938
1961
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1949,16 +1972,14 @@ export class App {
|
|
|
1949
1972
|
}
|
|
1950
1973
|
}
|
|
1951
1974
|
if (token !== undefined) {
|
|
1952
|
-
|
|
1953
|
-
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
1954
|
-
if (!m) {
|
|
1975
|
+
if (!authMatch) {
|
|
1955
1976
|
throw new HttpError(401, {
|
|
1956
1977
|
type: "https://daloyjs.dev/errors/unauthorized",
|
|
1957
1978
|
title: "Unauthorized",
|
|
1958
1979
|
detail: "Metrics scrape requires a bearer token.",
|
|
1959
1980
|
}, { "www-authenticate": 'Bearer realm="metrics"' });
|
|
1960
1981
|
}
|
|
1961
|
-
if (!
|
|
1982
|
+
if (!tokenAccepted) {
|
|
1962
1983
|
throw new ForbiddenError("Invalid metrics scrape token.");
|
|
1963
1984
|
}
|
|
1964
1985
|
}
|
|
@@ -2222,8 +2243,11 @@ export class App {
|
|
|
2222
2243
|
// Framework-serialized probe payload; missing schema is intentional.
|
|
2223
2244
|
acknowledgeNoResponseBodySchema: true,
|
|
2224
2245
|
handler: async ({ request }) => {
|
|
2246
|
+
const authMatch = token === undefined ? null : /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "");
|
|
2247
|
+
const tokenAccepted = token !== undefined && authMatch !== null && timingSafeEqual(authMatch[1], token);
|
|
2225
2248
|
if (buckets && rateLimitConfig) {
|
|
2226
|
-
const
|
|
2249
|
+
const identity = healthRouteKey(request, trustProxyHeaders);
|
|
2250
|
+
const key = token === undefined ? identity : `${tokenAccepted ? "verified" : "unverified"}\u0000${identity}`;
|
|
2227
2251
|
const now = Date.now();
|
|
2228
2252
|
const entry = buckets.get(key);
|
|
2229
2253
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -2240,16 +2264,14 @@ export class App {
|
|
|
2240
2264
|
}
|
|
2241
2265
|
}
|
|
2242
2266
|
if (token !== undefined) {
|
|
2243
|
-
|
|
2244
|
-
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
2245
|
-
if (!m) {
|
|
2267
|
+
if (!authMatch) {
|
|
2246
2268
|
throw new HttpError(401, {
|
|
2247
2269
|
type: "https://daloyjs.dev/errors/unauthorized",
|
|
2248
2270
|
title: "Unauthorized",
|
|
2249
2271
|
detail: "Health probe requires a bearer token.",
|
|
2250
2272
|
}, { "www-authenticate": 'Bearer realm="health"' });
|
|
2251
2273
|
}
|
|
2252
|
-
if (!
|
|
2274
|
+
if (!tokenAccepted) {
|
|
2253
2275
|
throw new ForbiddenError("Invalid health probe token.");
|
|
2254
2276
|
}
|
|
2255
2277
|
}
|
|
@@ -2459,6 +2481,7 @@ export class App {
|
|
|
2459
2481
|
* ```
|
|
2460
2482
|
*
|
|
2461
2483
|
* @param hooks - Hook bundle applied to subsequent routes.
|
|
2484
|
+
* Secure-header hooks also apply to unmatched routes and early rejections.
|
|
2462
2485
|
* @returns This `App` instance for chaining.
|
|
2463
2486
|
*/
|
|
2464
2487
|
use(hooks) {
|
|
@@ -2480,6 +2503,7 @@ export class App {
|
|
|
2480
2503
|
}
|
|
2481
2504
|
this.groupHooks.push(hooks);
|
|
2482
2505
|
this._coldPathHooksCache = undefined;
|
|
2506
|
+
this._globalHooksCache = undefined;
|
|
2483
2507
|
if (hooks[CORS_HOOK_MARKER] === true) {
|
|
2484
2508
|
this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
|
|
2485
2509
|
}
|
package/dist/conn-info.d.ts
CHANGED
|
@@ -108,7 +108,7 @@ export declare function assertBehindProxy(cfg: BehindProxyConfig | undefined): v
|
|
|
108
108
|
* @param header - Raw `X-Forwarded-For` header value, or `null` when absent.
|
|
109
109
|
* @param hops - Declared number of trusted proxy hops (must be >= 1).
|
|
110
110
|
* @returns The client IP at the declared hop, or `undefined` when the chain
|
|
111
|
-
* is too short or `hops < 1`.
|
|
111
|
+
* is too short, the selected slot is not an IP, or `hops < 1`.
|
|
112
112
|
* @internal
|
|
113
113
|
*/
|
|
114
114
|
export declare function pickForwardedForByHops(header: string | null, hops: number): string | undefined;
|
|
@@ -216,7 +216,8 @@ export declare function resolveTrustedProxyMatchers(name: string, opts: {
|
|
|
216
216
|
* {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
|
|
217
217
|
* are honoured only if the immediate peer matches; otherwise `undefined`.
|
|
218
218
|
* @returns The resolved client IP, or `undefined` when no forwarded identity
|
|
219
|
-
* is available.
|
|
219
|
+
* is available. Placeholder and malformed identities are rejected without
|
|
220
|
+
* shifting the selected hop. Callers decide their own posture for `undefined`
|
|
220
221
|
* (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
|
|
221
222
|
* @since 1.0.0-rc.7
|
|
222
223
|
*/
|
package/dist/conn-info.js
CHANGED
|
@@ -101,7 +101,7 @@ export function assertBehindProxy(cfg) {
|
|
|
101
101
|
* @param header - Raw `X-Forwarded-For` header value, or `null` when absent.
|
|
102
102
|
* @param hops - Declared number of trusted proxy hops (must be >= 1).
|
|
103
103
|
* @returns The client IP at the declared hop, or `undefined` when the chain
|
|
104
|
-
* is too short or `hops < 1`.
|
|
104
|
+
* is too short, the selected slot is not an IP, or `hops < 1`.
|
|
105
105
|
* @internal
|
|
106
106
|
*/
|
|
107
107
|
export function pickForwardedForByHops(header, hops) {
|
|
@@ -109,13 +109,13 @@ export function pickForwardedForByHops(header, hops) {
|
|
|
109
109
|
return undefined;
|
|
110
110
|
const parts = header
|
|
111
111
|
.split(",")
|
|
112
|
-
.map((p) => p.trim())
|
|
113
|
-
.filter((p) => p.length > 0);
|
|
112
|
+
.map((p) => p.trim());
|
|
114
113
|
if (parts.length < hops)
|
|
115
114
|
return undefined;
|
|
116
115
|
// Right-to-left: index 0 is the last hop closest to Daloy. The client
|
|
117
116
|
// typically lives at parts[parts.length - hops].
|
|
118
|
-
|
|
117
|
+
const selected = parts[parts.length - hops];
|
|
118
|
+
return selected && parseIp(selected) ? selected : undefined;
|
|
119
119
|
}
|
|
120
120
|
/**
|
|
121
121
|
* Validate a middleware's forwarded-header trust options and resolve them into
|
|
@@ -274,7 +274,8 @@ function isTrustedPeer(request, trustedPeers) {
|
|
|
274
274
|
* {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
|
|
275
275
|
* are honoured only if the immediate peer matches; otherwise `undefined`.
|
|
276
276
|
* @returns The resolved client IP, or `undefined` when no forwarded identity
|
|
277
|
-
* is available.
|
|
277
|
+
* is available. Placeholder and malformed identities are rejected without
|
|
278
|
+
* shifting the selected hop. Callers decide their own posture for `undefined`
|
|
278
279
|
* (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
|
|
279
280
|
* @since 1.0.0-rc.7
|
|
280
281
|
*/
|
|
@@ -282,9 +283,9 @@ export function resolveForwardedClientIp(request, hops = 1, trustedPeers) {
|
|
|
282
283
|
if (trustedPeers !== undefined && !isTrustedPeer(request, trustedPeers)) {
|
|
283
284
|
return undefined;
|
|
284
285
|
}
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
return
|
|
286
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
287
|
+
if (forwarded !== null)
|
|
288
|
+
return pickForwardedForByHops(forwarded, hops);
|
|
288
289
|
// Fail closed past one hop. A chain that produced fewer than `hops` entries
|
|
289
290
|
// means the request never traversed the declared topology — a direct-to-origin
|
|
290
291
|
// request that skipped the CDN, say — so no forwarded value it carries is
|
|
@@ -298,7 +299,8 @@ export function resolveForwardedClientIp(request, hops = 1, trustedPeers) {
|
|
|
298
299
|
// identity", not "the identity the caller asked me to believe".
|
|
299
300
|
if (hops !== 1)
|
|
300
301
|
return undefined;
|
|
301
|
-
|
|
302
|
+
const realIp = request.headers.get("x-real-ip")?.trim();
|
|
303
|
+
return realIp && parseIp(realIp) ? realIp : undefined;
|
|
302
304
|
}
|
|
303
305
|
/**
|
|
304
306
|
* Compiled-CIDR cache for {@link resolveClientIp}'s `{ cidrs }` branch. Keyed
|
package/dist/fetch-guard.d.ts
CHANGED
|
@@ -260,7 +260,8 @@ export interface FetchGuardOptions {
|
|
|
260
260
|
* redirect destinations; use `redirect: "error"` or `"manual"` in that case.
|
|
261
261
|
* @throws {Error} If no underlying fetch implementation is available.
|
|
262
262
|
* @throws {SsrfBlockedError} The returned function throws when a destination
|
|
263
|
-
* or redirect chain violates
|
|
263
|
+
* or redirect chain violates policy, including malformed URLs and userinfo.
|
|
264
|
+
* Userinfo is removed from error URLs; network errors propagate.
|
|
264
265
|
* @throws {TypeError} The returned function throws on invalid requests or
|
|
265
266
|
* redirects when `redirect: "error"` is selected.
|
|
266
267
|
* @since 0.34.0
|
package/dist/fetch-guard.js
CHANGED
|
@@ -182,7 +182,8 @@ const UNIQUE_LOCAL = ["fc00::/7"];
|
|
|
182
182
|
* redirect destinations; use `redirect: "error"` or `"manual"` in that case.
|
|
183
183
|
* @throws {Error} If no underlying fetch implementation is available.
|
|
184
184
|
* @throws {SsrfBlockedError} The returned function throws when a destination
|
|
185
|
-
* or redirect chain violates
|
|
185
|
+
* or redirect chain violates policy, including malformed URLs and userinfo.
|
|
186
|
+
* Userinfo is removed from error URLs; network errors propagate.
|
|
186
187
|
* @throws {TypeError} The returned function throws on invalid requests or
|
|
187
188
|
* redirects when `redirect: "error"` is selected.
|
|
188
189
|
* @since 0.34.0
|
|
@@ -311,9 +312,9 @@ export function fetchGuard(options = {}) {
|
|
|
311
312
|
pre = new URL(input);
|
|
312
313
|
}
|
|
313
314
|
catch {
|
|
314
|
-
|
|
315
|
+
throw new SsrfBlockedError("[invalid URL]", "invalid-url");
|
|
315
316
|
}
|
|
316
|
-
if (pre
|
|
317
|
+
if (pre.username !== "" || pre.password !== "") {
|
|
317
318
|
pre.username = "";
|
|
318
319
|
pre.password = "";
|
|
319
320
|
throw new SsrfBlockedError(pre.toString(), "credentials-in-url");
|
|
@@ -362,6 +363,12 @@ export function fetchGuard(options = {}) {
|
|
|
362
363
|
catch {
|
|
363
364
|
throw new SsrfBlockedError(loc, "invalid-url");
|
|
364
365
|
}
|
|
366
|
+
if (next.username !== "" || next.password !== "") {
|
|
367
|
+
next.username = "";
|
|
368
|
+
next.password = "";
|
|
369
|
+
void res.body?.cancel().catch(() => undefined);
|
|
370
|
+
throw new SsrfBlockedError(next.toString(), "credentials-in-url");
|
|
371
|
+
}
|
|
365
372
|
// Per fetch spec: 303 (and 301/302 for non-GET/HEAD in practice) downgrade to GET.
|
|
366
373
|
const method = request.method.toUpperCase();
|
|
367
374
|
const shouldDowngrade = res.status === 303 ||
|
package/dist/logger.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface LoggerRedactionOptions {
|
|
|
58
58
|
* @since 0.69.0
|
|
59
59
|
*/
|
|
60
60
|
redactCredentialLikeStrings?: boolean;
|
|
61
|
-
/** Maximum recursion depth when walking nested objects. Default: 6. */
|
|
61
|
+
/** Maximum recursion depth when walking nested objects. Deeper objects and arrays are censored in full. Default: 6. */
|
|
62
62
|
maxDepth?: number;
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
@@ -98,6 +98,8 @@ interface ResolvedRedaction {
|
|
|
98
98
|
* matches `cfg.keys` and any string value shaped like a JWT (when
|
|
99
99
|
* `cfg.redactJwt` is on) with `cfg.censor`. Exported for direct use by
|
|
100
100
|
* custom logger implementations that want the same defaults.
|
|
101
|
+
* Objects and arrays beyond the depth budget are replaced with the censor,
|
|
102
|
+
* rather than serialized without inspecting their contents.
|
|
101
103
|
*
|
|
102
104
|
* @param record - Log record to redact. Mutated in place (cycle-safe, depth-capped).
|
|
103
105
|
* @param cfg - Resolved redaction settings (key set, censor, JWT/credential toggles, max depth).
|
package/dist/logger.js
CHANGED
|
@@ -133,6 +133,8 @@ function redactString(value, cfg) {
|
|
|
133
133
|
* matches `cfg.keys` and any string value shaped like a JWT (when
|
|
134
134
|
* `cfg.redactJwt` is on) with `cfg.censor`. Exported for direct use by
|
|
135
135
|
* custom logger implementations that want the same defaults.
|
|
136
|
+
* Objects and arrays beyond the depth budget are replaced with the censor,
|
|
137
|
+
* rather than serialized without inspecting their contents.
|
|
136
138
|
*
|
|
137
139
|
* @param record - Log record to redact. Mutated in place (cycle-safe, depth-capped).
|
|
138
140
|
* @param cfg - Resolved redaction settings (key set, censor, JWT/credential toggles, max depth).
|
|
@@ -159,6 +161,9 @@ function walkRedact(node, cfg, depth, seen) {
|
|
|
159
161
|
if (replaced !== v)
|
|
160
162
|
node[i] = replaced;
|
|
161
163
|
}
|
|
164
|
+
else if (v !== null && typeof v === "object" && depth >= cfg.maxDepth) {
|
|
165
|
+
node[i] = cfg.censor;
|
|
166
|
+
}
|
|
162
167
|
else {
|
|
163
168
|
walkRedact(v, cfg, depth + 1, seen);
|
|
164
169
|
}
|
|
@@ -178,6 +183,9 @@ function walkRedact(node, cfg, depth, seen) {
|
|
|
178
183
|
if (replaced !== v)
|
|
179
184
|
obj[key] = replaced;
|
|
180
185
|
}
|
|
186
|
+
else if (v !== null && typeof v === "object" && depth >= cfg.maxDepth) {
|
|
187
|
+
obj[key] = cfg.censor;
|
|
188
|
+
}
|
|
181
189
|
else {
|
|
182
190
|
walkRedact(v, cfg, depth + 1, seen);
|
|
183
191
|
}
|
package/dist/mtls.d.ts
CHANGED
|
@@ -144,9 +144,10 @@ export declare function normalizePeerCertificate(raw: PeerCertificateLike | null
|
|
|
144
144
|
* Parse an Envoy `X-Forwarded-Client-Cert` (XFCC) header value into a
|
|
145
145
|
* {@link ClientCertificate}. XFCC is a comma-separated list of proxy elements,
|
|
146
146
|
* each a `;`-delimited set of `Key=Value` pairs (`Hash`, `Subject`, `URI`,
|
|
147
|
-
* `DNS`, `Cert`, …). The **first** element is
|
|
148
|
-
*
|
|
149
|
-
*
|
|
147
|
+
* `DNS`, `Cert`, …). The **first** element is returned. The `verified: true`
|
|
148
|
+
* result is a trusted-proxy assertion, not cryptographic verification by this
|
|
149
|
+
* parser. The terminator must verify client certificates, strip incoming XFCC,
|
|
150
|
+
* and replace it with its own value. Append-only forwarding is insufficient.
|
|
150
151
|
*
|
|
151
152
|
* @param headerValue Raw XFCC header value; `null`/`undefined` are tolerated.
|
|
152
153
|
* @returns The certificate parsed from the first XFCC element, or `undefined`
|
|
@@ -258,6 +259,9 @@ export interface ClientCertAuthOptions {
|
|
|
258
259
|
* parsed from a trusted-proxy header, enforces verification + optional
|
|
259
260
|
* allow-lists + validity window + a custom hook, and stamps the accepted
|
|
260
261
|
* certificate on `ctx.state` for downstream handlers.
|
|
262
|
+
* Header mode requires an origin reachable only through a trusted terminator
|
|
263
|
+
* that strips and replaces identity headers after certificate verification.
|
|
264
|
+
* Fingerprint allowlists do not authenticate client-supplied header values.
|
|
261
265
|
*
|
|
262
266
|
* Rejection semantics:
|
|
263
267
|
* - **No certificate presented** → `401` `application/problem+json` with
|
package/dist/mtls.js
CHANGED
|
@@ -256,9 +256,10 @@ function cnFromDN(dn) {
|
|
|
256
256
|
* Parse an Envoy `X-Forwarded-Client-Cert` (XFCC) header value into a
|
|
257
257
|
* {@link ClientCertificate}. XFCC is a comma-separated list of proxy elements,
|
|
258
258
|
* each a `;`-delimited set of `Key=Value` pairs (`Hash`, `Subject`, `URI`,
|
|
259
|
-
* `DNS`, `Cert`, …). The **first** element is
|
|
260
|
-
*
|
|
261
|
-
*
|
|
259
|
+
* `DNS`, `Cert`, …). The **first** element is returned. The `verified: true`
|
|
260
|
+
* result is a trusted-proxy assertion, not cryptographic verification by this
|
|
261
|
+
* parser. The terminator must verify client certificates, strip incoming XFCC,
|
|
262
|
+
* and replace it with its own value. Append-only forwarding is insufficient.
|
|
262
263
|
*
|
|
263
264
|
* @param headerValue Raw XFCC header value; `null`/`undefined` are tolerated.
|
|
264
265
|
* @returns The certificate parsed from the first XFCC element, or `undefined`
|
|
@@ -344,6 +345,9 @@ const MISSING_CERT_BODY = JSON.stringify({
|
|
|
344
345
|
* parsed from a trusted-proxy header, enforces verification + optional
|
|
345
346
|
* allow-lists + validity window + a custom hook, and stamps the accepted
|
|
346
347
|
* certificate on `ctx.state` for downstream handlers.
|
|
348
|
+
* Header mode requires an origin reachable only through a trusted terminator
|
|
349
|
+
* that strips and replaces identity headers after certificate verification.
|
|
350
|
+
* Fingerprint allowlists do not authenticate client-supplied header values.
|
|
347
351
|
*
|
|
348
352
|
* Rejection semantics:
|
|
349
353
|
* - **No certificate presented** → `401` `application/problem+json` with
|
package/dist/router.d.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Performance:
|
|
5
5
|
* - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
|
|
6
|
-
* - Dynamic paths walk a trie,
|
|
6
|
+
* - Dynamic paths walk a segment trie, visiting static branches before
|
|
7
|
+
* parameters and wildcards. Backtracking cost depends on overlapping routes.
|
|
7
8
|
* - Path normalization and splitting avoid regular expressions.
|
|
8
9
|
*
|
|
9
10
|
* Safety:
|
|
10
|
-
* -
|
|
11
|
+
* - Raw `/../`, trailing `/..`, and empty segments are rejected at lookup time.
|
|
12
|
+
* - Decoded parameters are untrusted data, not sanitized filesystem paths.
|
|
11
13
|
* - Duplicate routes and duplicate operationIds throw at registration.
|
|
12
14
|
* - Wildcard segments must be terminal.
|
|
13
15
|
*/
|
|
@@ -22,12 +24,15 @@ export interface RouteMatch<T> {
|
|
|
22
24
|
/**
|
|
23
25
|
* Trie/radix router with a static-route fast path. Registers handlers via
|
|
24
26
|
* {@link Router.add} and resolves them with {@link Router.find}. Rejects
|
|
25
|
-
* duplicate routes, duplicate operationIds, conflicting
|
|
26
|
-
* path-traversal lookups.
|
|
27
|
+
* duplicate routes, duplicate operationIds, conflicting or unsafe capture names,
|
|
28
|
+
* and raw path-traversal lookups.
|
|
27
29
|
*/
|
|
28
30
|
export declare class Router<T> {
|
|
29
31
|
private root;
|
|
30
32
|
private operationIds;
|
|
33
|
+
private hasDynamicRoutes;
|
|
34
|
+
private revision;
|
|
35
|
+
private staticMethods;
|
|
31
36
|
/** Static (no-param/no-wildcard) routes for O(1) lookup. */
|
|
32
37
|
private staticTable;
|
|
33
38
|
/**
|
|
@@ -37,16 +42,22 @@ export declare class Router<T> {
|
|
|
37
42
|
*
|
|
38
43
|
* @param method - HTTP method to register the handler under.
|
|
39
44
|
* @param path - Route path; supports `:param` and a trailing `*wildcard`.
|
|
40
|
-
* @param handler - Value returned by {@link Router.find} on a match
|
|
45
|
+
* @param handler - Value returned by {@link Router.find} on a match, including
|
|
46
|
+
* falsy values or `undefined`.
|
|
41
47
|
* @param operationId - Optional unique id; tracked to reject duplicates.
|
|
42
48
|
* @throws Error on a duplicate route, duplicate `operationId`, or conflicting
|
|
43
|
-
*
|
|
49
|
+
* parameter or wildcard names at the same trie position, empty, repeated,
|
|
50
|
+
* or prototype-sensitive capture names, or a nonterminal wildcard.
|
|
51
|
+
* Failed registration does not reserve the `operationId`.
|
|
44
52
|
*/
|
|
45
53
|
add(method: HttpMethod, path: string, handler: T, operationId?: string): void;
|
|
46
54
|
/**
|
|
47
55
|
* Look up the handler registered for the given method and path. Tries the
|
|
48
56
|
* static fast path first, then walks the trie, extracting and decoding path
|
|
49
57
|
* params. Path-traversal lookups (`..`, `//`) are rejected up front.
|
|
58
|
+
* Handler tables inherit only from an empty, frozen, prototype-free base:
|
|
59
|
+
* Object.prototype properties never count as routes, including for untyped
|
|
60
|
+
* runtime method values.
|
|
50
61
|
*
|
|
51
62
|
* @param method - HTTP method to match.
|
|
52
63
|
* @param path - Request path to resolve, including any dynamic segments.
|
|
@@ -54,7 +65,15 @@ export declare class Router<T> {
|
|
|
54
65
|
* route matches the method + path.
|
|
55
66
|
*/
|
|
56
67
|
find(method: HttpMethod, path: string): RouteMatch<T> | undefined;
|
|
57
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Return the methods registered at the matched path for 405 responses.
|
|
70
|
+
* Static-only routers skip trie traversal. Results are fresh arrays and
|
|
71
|
+
* reflect routes registered after earlier lookups. Only validated registered
|
|
72
|
+
* static paths are cached, bounding cache size by the static route count.
|
|
73
|
+
* @param path - Request path, subject to the same traversal and empty-segment
|
|
74
|
+
* rejection as {@link Router.find}.
|
|
75
|
+
* @returns Registered methods, or an empty array for rejected or unmatched paths.
|
|
76
|
+
*/
|
|
58
77
|
allowedMethods(path: string): HttpMethod[];
|
|
59
78
|
private walk;
|
|
60
79
|
}
|
package/dist/router.js
CHANGED
|
@@ -3,26 +3,33 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Performance:
|
|
5
5
|
* - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
|
|
6
|
-
* - Dynamic paths walk a trie,
|
|
6
|
+
* - Dynamic paths walk a segment trie, visiting static branches before
|
|
7
|
+
* parameters and wildcards. Backtracking cost depends on overlapping routes.
|
|
7
8
|
* - Path normalization and splitting avoid regular expressions.
|
|
8
9
|
*
|
|
9
10
|
* Safety:
|
|
10
|
-
* -
|
|
11
|
+
* - Raw `/../`, trailing `/..`, and empty segments are rejected at lookup time.
|
|
12
|
+
* - Decoded parameters are untrusted data, not sanitized filesystem paths.
|
|
11
13
|
* - Duplicate routes and duplicate operationIds throw at registration.
|
|
12
14
|
* - Wildcard segments must be terminal.
|
|
13
15
|
*/
|
|
16
|
+
import { isForbiddenObjectKey } from "./security.js";
|
|
17
|
+
const handlerPrototype = Object.freeze(Object.create(null));
|
|
14
18
|
function createNode() {
|
|
15
|
-
return { children: new Map(), handlers:
|
|
19
|
+
return { children: new Map(), handlers: undefined };
|
|
16
20
|
}
|
|
17
21
|
/**
|
|
18
22
|
* Trie/radix router with a static-route fast path. Registers handlers via
|
|
19
23
|
* {@link Router.add} and resolves them with {@link Router.find}. Rejects
|
|
20
|
-
* duplicate routes, duplicate operationIds, conflicting
|
|
21
|
-
* path-traversal lookups.
|
|
24
|
+
* duplicate routes, duplicate operationIds, conflicting or unsafe capture names,
|
|
25
|
+
* and raw path-traversal lookups.
|
|
22
26
|
*/
|
|
23
27
|
export class Router {
|
|
24
28
|
root = createNode();
|
|
25
29
|
operationIds = new Set();
|
|
30
|
+
hasDynamicRoutes = false;
|
|
31
|
+
revision = 0;
|
|
32
|
+
staticMethods = new Map();
|
|
26
33
|
/** Static (no-param/no-wildcard) routes for O(1) lookup. */
|
|
27
34
|
staticTable = new Map();
|
|
28
35
|
/**
|
|
@@ -32,28 +39,47 @@ export class Router {
|
|
|
32
39
|
*
|
|
33
40
|
* @param method - HTTP method to register the handler under.
|
|
34
41
|
* @param path - Route path; supports `:param` and a trailing `*wildcard`.
|
|
35
|
-
* @param handler - Value returned by {@link Router.find} on a match
|
|
42
|
+
* @param handler - Value returned by {@link Router.find} on a match, including
|
|
43
|
+
* falsy values or `undefined`.
|
|
36
44
|
* @param operationId - Optional unique id; tracked to reject duplicates.
|
|
37
45
|
* @throws Error on a duplicate route, duplicate `operationId`, or conflicting
|
|
38
|
-
*
|
|
46
|
+
* parameter or wildcard names at the same trie position, empty, repeated,
|
|
47
|
+
* or prototype-sensitive capture names, or a nonterminal wildcard.
|
|
48
|
+
* Failed registration does not reserve the `operationId`.
|
|
39
49
|
*/
|
|
40
50
|
add(method, path, handler, operationId) {
|
|
41
51
|
const segments = splitPath(path);
|
|
42
52
|
if (operationId && this.operationIds.has(operationId))
|
|
43
53
|
throw new Error(`Duplicate operationId: "${operationId}"`);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
let captureNames;
|
|
55
|
+
for (let index = 0; index < segments.length; index++) {
|
|
56
|
+
const segment = segments[index];
|
|
57
|
+
const wildcard = segment.startsWith("*");
|
|
58
|
+
if (wildcard && index !== segments.length - 1) {
|
|
59
|
+
throw new Error(`Wildcard must be the terminal segment: ${path}`);
|
|
60
|
+
}
|
|
61
|
+
if (wildcard || segment.startsWith(":")) {
|
|
62
|
+
const name = wildcard && segment.length === 1 ? "wildcard" : segment.slice(1);
|
|
63
|
+
if (!name || isForbiddenObjectKey(name) || captureNames?.has(name)) {
|
|
64
|
+
throw new Error(`Invalid or duplicate capture name: "${name}" in ${path}`);
|
|
65
|
+
}
|
|
66
|
+
(captureNames ??= new Set()).add(name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const isStatic = captureNames === undefined;
|
|
47
70
|
const normalized = "/" + segments.join("/");
|
|
48
71
|
if (isStatic) {
|
|
49
72
|
let entry = this.staticTable.get(normalized);
|
|
50
73
|
if (!entry) {
|
|
51
|
-
entry =
|
|
74
|
+
entry = Object.create(handlerPrototype);
|
|
52
75
|
this.staticTable.set(normalized, entry);
|
|
53
76
|
}
|
|
54
|
-
if (entry
|
|
77
|
+
if (Object.hasOwn(entry, method))
|
|
55
78
|
throw new Error(`Duplicate route: ${method} ${path}`);
|
|
56
79
|
entry[method] = handler;
|
|
80
|
+
this.revision++;
|
|
81
|
+
if (operationId)
|
|
82
|
+
this.operationIds.add(operationId);
|
|
57
83
|
return;
|
|
58
84
|
}
|
|
59
85
|
let node = this.root;
|
|
@@ -70,7 +96,12 @@ export class Router {
|
|
|
70
96
|
}
|
|
71
97
|
else if (seg.startsWith("*")) {
|
|
72
98
|
const name = seg.length > 1 ? seg.slice(1) : "wildcard";
|
|
73
|
-
node.wildcardChild
|
|
99
|
+
if (!node.wildcardChild) {
|
|
100
|
+
node.wildcardChild = { name, node: createNode() };
|
|
101
|
+
}
|
|
102
|
+
else if (node.wildcardChild.name !== name) {
|
|
103
|
+
throw new Error(`Conflicting wildcard names at same position: "${node.wildcardChild.name}" vs "${name}"`);
|
|
104
|
+
}
|
|
74
105
|
node = node.wildcardChild.node;
|
|
75
106
|
break;
|
|
76
107
|
}
|
|
@@ -83,14 +114,22 @@ export class Router {
|
|
|
83
114
|
node = next;
|
|
84
115
|
}
|
|
85
116
|
}
|
|
86
|
-
|
|
117
|
+
node.handlers ??= Object.create(handlerPrototype);
|
|
118
|
+
if (Object.hasOwn(node.handlers, method))
|
|
87
119
|
throw new Error(`Duplicate route: ${method} ${path}`);
|
|
88
120
|
node.handlers[method] = handler;
|
|
121
|
+
this.hasDynamicRoutes = true;
|
|
122
|
+
this.revision++;
|
|
123
|
+
if (operationId)
|
|
124
|
+
this.operationIds.add(operationId);
|
|
89
125
|
}
|
|
90
126
|
/**
|
|
91
127
|
* Look up the handler registered for the given method and path. Tries the
|
|
92
128
|
* static fast path first, then walks the trie, extracting and decoding path
|
|
93
129
|
* params. Path-traversal lookups (`..`, `//`) are rejected up front.
|
|
130
|
+
* Handler tables inherit only from an empty, frozen, prototype-free base:
|
|
131
|
+
* Object.prototype properties never count as routes, including for untyped
|
|
132
|
+
* runtime method values.
|
|
94
133
|
*
|
|
95
134
|
* @param method - HTTP method to match.
|
|
96
135
|
* @param path - Request path to resolve, including any dynamic segments.
|
|
@@ -108,8 +147,11 @@ export class Router {
|
|
|
108
147
|
if (!staticEntry && path.endsWith("/")) {
|
|
109
148
|
staticEntry = this.staticTable.get(trimTrailingSlashes(path));
|
|
110
149
|
}
|
|
111
|
-
if (staticEntry
|
|
112
|
-
|
|
150
|
+
if (staticEntry) {
|
|
151
|
+
const handler = staticEntry[method];
|
|
152
|
+
if (handler !== undefined || Object.hasOwn(staticEntry, method)) {
|
|
153
|
+
return { handler: handler, params: {} };
|
|
154
|
+
}
|
|
113
155
|
}
|
|
114
156
|
const segments = splitPath(path);
|
|
115
157
|
const params = {};
|
|
@@ -117,25 +159,51 @@ export class Router {
|
|
|
117
159
|
if (!found)
|
|
118
160
|
return undefined;
|
|
119
161
|
const handler = found.handlers[method];
|
|
120
|
-
if (!
|
|
162
|
+
if (handler === undefined && !Object.hasOwn(found.handlers, method))
|
|
121
163
|
return undefined;
|
|
122
|
-
return { handler, params };
|
|
164
|
+
return { handler: handler, params };
|
|
123
165
|
}
|
|
124
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Return the methods registered at the matched path for 405 responses.
|
|
168
|
+
* Static-only routers skip trie traversal. Results are fresh arrays and
|
|
169
|
+
* reflect routes registered after earlier lookups. Only validated registered
|
|
170
|
+
* static paths are cached, bounding cache size by the static route count.
|
|
171
|
+
* @param path - Request path, subject to the same traversal and empty-segment
|
|
172
|
+
* rejection as {@link Router.find}.
|
|
173
|
+
* @returns Registered methods, or an empty array for rejected or unmatched paths.
|
|
174
|
+
*/
|
|
125
175
|
allowedMethods(path) {
|
|
176
|
+
const cached = this.staticMethods.get(path);
|
|
177
|
+
if (cached?.revision === this.revision)
|
|
178
|
+
return cached.methods.slice();
|
|
179
|
+
if (path.includes("/../") || path.endsWith("/..") || path.includes("//")) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
126
182
|
let fromStatic = this.staticTable.get(path);
|
|
127
183
|
if (!fromStatic && path.endsWith("/")) {
|
|
128
184
|
fromStatic = this.staticTable.get(trimTrailingSlashes(path));
|
|
129
185
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
186
|
+
const found = this.hasDynamicRoutes
|
|
187
|
+
? this.walk(this.root, splitPath(path), 0, {})
|
|
188
|
+
: undefined;
|
|
189
|
+
if (!fromStatic)
|
|
190
|
+
return found ? Object.keys(found.handlers) : [];
|
|
191
|
+
const methods = Object.keys(fromStatic);
|
|
192
|
+
if (found) {
|
|
193
|
+
for (const method of Object.keys(found.handlers)) {
|
|
194
|
+
if (!Object.hasOwn(fromStatic, method))
|
|
195
|
+
methods.push(method);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.staticMethods.set(trimTrailingSlashes(path), {
|
|
199
|
+
revision: this.revision,
|
|
200
|
+
methods,
|
|
201
|
+
});
|
|
202
|
+
return methods.slice();
|
|
135
203
|
}
|
|
136
204
|
walk(node, segs, i, params) {
|
|
137
205
|
if (i === segs.length)
|
|
138
|
-
return node;
|
|
206
|
+
return node.handlers ? node : undefined;
|
|
139
207
|
const seg = segs[i];
|
|
140
208
|
const staticNext = node.children.get(seg);
|
|
141
209
|
if (staticNext) {
|
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:ea6ea660-6d6a-5827-8fe5-9def13029bef",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-09-
|
|
7
|
+
"timestamp": "2026-09-13T02:28:52.478Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.3.
|
|
12
|
+
"version": "1.3.4"
|
|
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.3.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.3.4",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.3.
|
|
24
|
+
"version": "1.3.4",
|
|
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.3.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.3.4",
|
|
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.3.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.3.4",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.3.
|
|
51
|
+
"version": "1.3.4",
|
|
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.3.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.3.4",
|
|
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.3.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.
|
|
5
|
+
"name": "@daloyjs/core-1.3.4",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.4-ea6ea660-6d6a-5827-8fe5-9def13029bef",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-09-
|
|
8
|
+
"created": "2026-09-13T02:28:52.478Z",
|
|
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.3.
|
|
19
|
+
"versionInfo": "1.3.4",
|
|
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.3.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.3.4"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -228,6 +228,7 @@ export interface CronFields {
|
|
|
228
228
|
* Supported syntax per field: `*`, lists (`1,2,3`), ranges (`1-5`), steps
|
|
229
229
|
* (`*\/5`, `1-10/2`), and case-insensitive month (`JAN`–`DEC`) / day
|
|
230
230
|
* (`SUN`–`SAT`) names. Day-of-week accepts both `0` and `7` for Sunday.
|
|
231
|
+
* Numeric weekday ranges are expanded before Sunday aliases are normalized.
|
|
231
232
|
*
|
|
232
233
|
* @param expression - A cron expression or alias.
|
|
233
234
|
* @returns The compiled field sets.
|
|
@@ -245,6 +246,7 @@ export declare function parseCron(expression: string): CronFields;
|
|
|
245
246
|
* @returns The next matching `Date`.
|
|
246
247
|
* @throws {@link CronParseError} if no match occurs within five years
|
|
247
248
|
* (an unsatisfiable expression).
|
|
249
|
+
* @throws {RangeError} If `after` is invalid or the timezone is unsupported.
|
|
248
250
|
* @since 0.37.0
|
|
249
251
|
*/
|
|
250
252
|
export declare function nextCronRun(expression: string | CronFields, after?: Date, timeZone?: string): Date;
|
package/dist/scheduler.js
CHANGED
|
@@ -145,6 +145,7 @@ function parseField(field, min, max, fieldName, names) {
|
|
|
145
145
|
* Supported syntax per field: `*`, lists (`1,2,3`), ranges (`1-5`), steps
|
|
146
146
|
* (`*\/5`, `1-10/2`), and case-insensitive month (`JAN`–`DEC`) / day
|
|
147
147
|
* (`SUN`–`SAT`) names. Day-of-week accepts both `0` and `7` for Sunday.
|
|
148
|
+
* Numeric weekday ranges are expanded before Sunday aliases are normalized.
|
|
148
149
|
*
|
|
149
150
|
* @param expression - A cron expression or alias.
|
|
150
151
|
* @returns The compiled field sets.
|
|
@@ -167,7 +168,9 @@ export function parseCron(expression) {
|
|
|
167
168
|
const dayOfMonth = parseField(dom, 1, 31, "day-of-month");
|
|
168
169
|
const month = parseField(mon, 1, 12, "month", MONTH_NAMES);
|
|
169
170
|
// Day-of-week allows 7 as an alias for Sunday; normalize 7 -> 0.
|
|
170
|
-
const dowRaw = parseField(dow
|
|
171
|
+
const dowRaw = parseField(dow, 0, 7, "day-of-week", DAY_NAMES);
|
|
172
|
+
if (dowRaw.delete(7))
|
|
173
|
+
dowRaw.add(0);
|
|
171
174
|
return {
|
|
172
175
|
minute,
|
|
173
176
|
hour,
|
|
@@ -187,8 +190,8 @@ const WEEKDAY_INDEX = {
|
|
|
187
190
|
Fri: 5,
|
|
188
191
|
Sat: 6,
|
|
189
192
|
};
|
|
190
|
-
function wallClockOf(date,
|
|
191
|
-
if (
|
|
193
|
+
function wallClockOf(date, formatter) {
|
|
194
|
+
if (formatter === undefined) {
|
|
192
195
|
return {
|
|
193
196
|
minute: date.getUTCMinutes(),
|
|
194
197
|
hour: date.getUTCHours(),
|
|
@@ -197,16 +200,7 @@ function wallClockOf(date, timeZone) {
|
|
|
197
200
|
dayOfWeek: date.getUTCDay(),
|
|
198
201
|
};
|
|
199
202
|
}
|
|
200
|
-
const parts =
|
|
201
|
-
timeZone,
|
|
202
|
-
hour12: false,
|
|
203
|
-
year: "numeric",
|
|
204
|
-
month: "numeric",
|
|
205
|
-
day: "numeric",
|
|
206
|
-
hour: "numeric",
|
|
207
|
-
minute: "numeric",
|
|
208
|
-
weekday: "short",
|
|
209
|
-
}).formatToParts(date);
|
|
203
|
+
const parts = formatter.formatToParts(date);
|
|
210
204
|
const get = (type) => parts.find((p) => p.type === type)?.value ?? "0";
|
|
211
205
|
let hour = Number(get("hour"));
|
|
212
206
|
if (hour === 24)
|
|
@@ -253,15 +247,38 @@ const MAX_LOOKAHEAD_MINUTES = 5 * 366 * 24 * 60;
|
|
|
253
247
|
* @returns The next matching `Date`.
|
|
254
248
|
* @throws {@link CronParseError} if no match occurs within five years
|
|
255
249
|
* (an unsatisfiable expression).
|
|
250
|
+
* @throws {RangeError} If `after` is invalid or the timezone is unsupported.
|
|
256
251
|
* @since 0.37.0
|
|
257
252
|
*/
|
|
258
253
|
export function nextCronRun(expression, after = new Date(), timeZone) {
|
|
259
254
|
const fields = typeof expression === "string" ? parseCron(expression) : expression;
|
|
255
|
+
if (!Number.isFinite(after.getTime()))
|
|
256
|
+
throw new RangeError("Invalid cron search date.");
|
|
257
|
+
const formatter = timeZone === undefined || timeZone === "UTC"
|
|
258
|
+
? undefined
|
|
259
|
+
: new Intl.DateTimeFormat("en-US", {
|
|
260
|
+
timeZone,
|
|
261
|
+
hour12: false,
|
|
262
|
+
year: "numeric",
|
|
263
|
+
month: "numeric",
|
|
264
|
+
day: "numeric",
|
|
265
|
+
hour: "numeric",
|
|
266
|
+
minute: "numeric",
|
|
267
|
+
weekday: "short",
|
|
268
|
+
});
|
|
269
|
+
if (fields.domRestricted && !fields.dowRestricted) {
|
|
270
|
+
const possible = [...fields.month].some(month => {
|
|
271
|
+
const maxDay = new Date(Date.UTC(2000, month, 0)).getUTCDate();
|
|
272
|
+
return [...fields.dayOfMonth].some(day => day <= maxDay);
|
|
273
|
+
});
|
|
274
|
+
if (!possible)
|
|
275
|
+
throw new CronParseError("Cron expression has no valid calendar day.");
|
|
276
|
+
}
|
|
260
277
|
// Advance to the start of the next whole minute.
|
|
261
278
|
const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
|
|
262
279
|
for (let i = 0; i < MAX_LOOKAHEAD_MINUTES; i++) {
|
|
263
280
|
const candidate = new Date(start + i * 60_000);
|
|
264
|
-
if (matches(fields, wallClockOf(candidate,
|
|
281
|
+
if (matches(fields, wallClockOf(candidate, formatter)))
|
|
265
282
|
return candidate;
|
|
266
283
|
}
|
|
267
284
|
throw new CronParseError(`Cron expression matches no time within five years (unsatisfiable).`);
|
package/dist/waf.js
CHANGED
|
@@ -268,7 +268,7 @@ function inspectionVariants(value, maxValueLength) {
|
|
|
268
268
|
if (v.includes("+"))
|
|
269
269
|
push(v.replace(/\+/g, " "));
|
|
270
270
|
if (v.includes("/*"))
|
|
271
|
-
push(v
|
|
271
|
+
push(stripBlockComments(v));
|
|
272
272
|
// Control characters (notably NUL) are not `\s`, so `1'%00OR%001=1` split
|
|
273
273
|
// `OR` from `1=1` and walked past the whitespace-anchored signatures. Scan
|
|
274
274
|
// a control-char→space form; benign traffic carries almost no C0 bytes, so
|
|
@@ -285,6 +285,24 @@ function inspectionVariants(value, maxValueLength) {
|
|
|
285
285
|
}
|
|
286
286
|
return out;
|
|
287
287
|
}
|
|
288
|
+
function stripBlockComments(value) {
|
|
289
|
+
let cursor = 0;
|
|
290
|
+
const parts = [];
|
|
291
|
+
for (;;) {
|
|
292
|
+
const start = value.indexOf("/*", cursor);
|
|
293
|
+
if (start < 0)
|
|
294
|
+
break;
|
|
295
|
+
const end = value.indexOf("*/", start + 2);
|
|
296
|
+
if (end < 0)
|
|
297
|
+
break;
|
|
298
|
+
parts.push(value.slice(cursor, start), " ");
|
|
299
|
+
cursor = end + 2;
|
|
300
|
+
}
|
|
301
|
+
if (cursor === 0)
|
|
302
|
+
return value;
|
|
303
|
+
parts.push(value.slice(cursor));
|
|
304
|
+
return parts.join("");
|
|
305
|
+
}
|
|
288
306
|
/**
|
|
289
307
|
* Scan every inspection variant of `value` for the active rule set.
|
|
290
308
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
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": {
|
|
@@ -319,6 +319,6 @@
|
|
|
319
319
|
"bin",
|
|
320
320
|
"README.md"
|
|
321
321
|
],
|
|
322
|
-
"packageManager": "pnpm@
|
|
322
|
+
"packageManager": "pnpm@12.3.0",
|
|
323
323
|
"dependencies": {}
|
|
324
324
|
}
|