@daloyjs/core 1.3.2 → 1.3.3

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/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
- * Cached merge of `options.hooks` only. Used on the cold 404/405 path
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
- * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
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
- * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
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
- * Cached merge of `options.hooks` only. Used on the cold 404/405 path
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([this.options.hooks ?? {}]);
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 || origin === "null")
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
- * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
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
- * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
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 key = healthRouteKey(request, trustProxyHeaders);
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
- const h = request.headers.get("authorization") ?? "";
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 (!timingSafeEqual(m[1], token)) {
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 key = healthRouteKey(request, trustProxyHeaders);
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
- const h = request.headers.get("authorization") ?? "";
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 (!timingSafeEqual(m[1], token)) {
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
  }
@@ -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. Callers decide their own posture for `undefined`
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
- return parts[parts.length - hops];
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. Callers decide their own posture for `undefined`
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 picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), hops);
286
- if (picked)
287
- return picked;
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
- return request.headers.get("x-real-ip") ?? undefined;
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
@@ -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 the configured policy; network errors propagate.
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
@@ -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 the configured policy; network errors propagate.
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
- pre = undefined;
315
+ throw new SsrfBlockedError("[invalid URL]", "invalid-url");
315
316
  }
316
- if (pre && (pre.username !== "" || pre.password !== "")) {
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 the client closest to the origin
148
- * and is the one returned. Because Envoy only emits XFCC for connections it
149
- * mutually authenticated, the result is marked `verified: true`.
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 the client closest to the origin
260
- * and is the one returned. Because Envoy only emits XFCC for connections it
261
- * mutually authenticated, the result is marked `verified: true`.
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
@@ -7,7 +7,8 @@
7
7
  * - Path normalization and splitting avoid regular expressions.
8
8
  *
9
9
  * Safety:
10
- * - Path traversal (`..`) and empty segments are rejected at lookup time.
10
+ * - Raw `/../`, trailing `/..`, and empty segments are rejected at lookup time.
11
+ * - Decoded parameters are untrusted data, not sanitized filesystem paths.
11
12
  * - Duplicate routes and duplicate operationIds throw at registration.
12
13
  * - Wildcard segments must be terminal.
13
14
  */
package/dist/router.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * - Path normalization and splitting avoid regular expressions.
8
8
  *
9
9
  * Safety:
10
- * - Path traversal (`..`) and empty segments are rejected at lookup time.
10
+ * - Raw `/../`, trailing `/..`, and empty segments are rejected at lookup time.
11
+ * - Decoded parameters are untrusted data, not sanitized filesystem paths.
11
12
  * - Duplicate routes and duplicate operationIds throw at registration.
12
13
  * - Wildcard segments must be terminal.
13
14
  */
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:e5e6316e-26ff-52ae-9520-4b82d88c8397",
4
+ "serialNumber": "urn:uuid:6545c557-5cea-599b-8286-d910b13cb482",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-09-08T07:08:54.894Z",
7
+ "timestamp": "2026-09-10T10:47:58.279Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.3.2"
12
+ "version": "1.3.3"
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.2",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.3.3",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.3.2",
24
+ "version": "1.3.3",
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.2",
26
+ "purl": "pkg:npm/@daloyjs/core@1.3.3",
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.2",
49
+ "tagId": "swidtag--daloyjs-core-1.3.3",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.3.2",
51
+ "version": "1.3.3",
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.2",
60
+ "ref": "pkg:npm/@daloyjs/core@1.3.3",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -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.2",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.2-e5e6316e-26ff-52ae-9520-4b82d88c8397",
5
+ "name": "@daloyjs/core-1.3.3",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.3-6545c557-5cea-599b-8286-d910b13cb482",
7
7
  "creationInfo": {
8
- "created": "2026-09-08T07:08:54.894Z",
8
+ "created": "2026-09-10T10:47:58.279Z",
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.2",
19
+ "versionInfo": "1.3.3",
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.2"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.3.3"
31
31
  }
32
32
  ]
33
33
  }
@@ -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.replace(/7/g, "0"), 0, 6, "day-of-week", DAY_NAMES);
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, timeZone) {
191
- if (timeZone === undefined || timeZone === "UTC") {
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 = new Intl.DateTimeFormat("en-US", {
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, timeZone)))
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.replace(/\/\*[\s\S]*?\*\//g, " "));
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.2",
3
+ "version": "1.3.3",
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@11.1.3",
322
+ "packageManager": "pnpm@12.3.0",
323
323
  "dependencies": {}
324
324
  }