@daloyjs/core 1.3.1 → 1.3.2
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/compression.d.ts +2 -0
- package/dist/compression.js +4 -52
- package/dist/fetch-guard.d.ts +9 -1
- package/dist/fetch-guard.js +15 -1
- package/dist/fetch-resilience.d.ts +4 -0
- package/dist/fetch-resilience.js +10 -7
- package/dist/http-signatures.d.ts +12 -3
- package/dist/http-signatures.js +30 -11
- package/dist/idempotency.d.ts +16 -1
- package/dist/idempotency.js +28 -5
- package/dist/internal-body.d.ts +12 -0
- package/dist/internal-body.js +45 -0
- package/dist/internal-replay.d.ts +14 -0
- package/dist/internal-replay.js +21 -0
- package/dist/jwk.d.ts +6 -2
- package/dist/jwk.js +4 -0
- package/dist/jwt.d.ts +11 -1
- package/dist/jwt.js +21 -2
- package/dist/mtls.d.ts +4 -0
- package/dist/mtls.js +52 -5
- package/dist/response-cache.d.ts +4 -0
- package/dist/response-cache.js +10 -2
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/webhook-delivery.d.ts +2 -0
- package/dist/webhook-delivery.js +3 -0
- package/package.json +3 -3
package/dist/compression.d.ts
CHANGED
|
@@ -115,6 +115,8 @@ export declare function _resetCompressionRuntimeProbeForTests(): void;
|
|
|
115
115
|
* - response already declares a `Content-Encoding`;
|
|
116
116
|
* - response declares a `Set-Cookie` (response is mutating auth state);
|
|
117
117
|
* - response body byte length is below `minimumSize` (default `1024`);
|
|
118
|
+
* - response body exceeds `maxCompressibleBytes` while reading; only the
|
|
119
|
+
* capture clone is cancelled, without waiting for the client to consume it;
|
|
118
120
|
* - response `Content-Type` is in the always-on already-compressed
|
|
119
121
|
* deny-list (image/video/audio/archives/fonts/wasm/pdf, with
|
|
120
122
|
* `image/svg+xml` carved back in as compressible XML).
|
package/dist/compression.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* @since 0.25.0
|
|
22
22
|
*/
|
|
23
|
+
import { readResponseBodyUpTo } from "./internal-body.js";
|
|
23
24
|
/**
|
|
24
25
|
* Internal marker stamped on the hook so a future audit gate can confirm
|
|
25
26
|
* the BREACH-aware middleware is installed (audit-item parity).
|
|
@@ -217,57 +218,6 @@ function normalizeOptionTokens(values, optionName) {
|
|
|
217
218
|
}
|
|
218
219
|
return Object.freeze(normalized);
|
|
219
220
|
}
|
|
220
|
-
/**
|
|
221
|
-
* Read a response body up to `maxBytes`. Returns `null` if the stream
|
|
222
|
-
* exceeds the cap (body is cancelled; caller should leave the response
|
|
223
|
-
* uncompressed). Returns an empty buffer when there is no body.
|
|
224
|
-
*
|
|
225
|
-
* @param res - Response whose body will be consumed (pass a clone).
|
|
226
|
-
* @param maxBytes - Inclusive upper bound on buffered size.
|
|
227
|
-
*/
|
|
228
|
-
async function readBodyUpTo(res, maxBytes) {
|
|
229
|
-
if (!res.body)
|
|
230
|
-
return new Uint8Array(0);
|
|
231
|
-
const reader = res.body.getReader();
|
|
232
|
-
const chunks = [];
|
|
233
|
-
let total = 0;
|
|
234
|
-
try {
|
|
235
|
-
// eslint-disable-next-line no-constant-condition
|
|
236
|
-
while (true) {
|
|
237
|
-
const { done, value } = await reader.read();
|
|
238
|
-
if (done)
|
|
239
|
-
break;
|
|
240
|
-
if (!value || value.byteLength === 0)
|
|
241
|
-
continue;
|
|
242
|
-
total += value.byteLength;
|
|
243
|
-
if (total > maxBytes) {
|
|
244
|
-
await reader.cancel();
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
chunks.push(value);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
catch {
|
|
251
|
-
try {
|
|
252
|
-
await reader.cancel();
|
|
253
|
-
}
|
|
254
|
-
catch {
|
|
255
|
-
/* ignore */
|
|
256
|
-
}
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
if (chunks.length === 0)
|
|
260
|
-
return new Uint8Array(0);
|
|
261
|
-
if (chunks.length === 1)
|
|
262
|
-
return chunks[0];
|
|
263
|
-
const out = new Uint8Array(total);
|
|
264
|
-
let offset = 0;
|
|
265
|
-
for (const c of chunks) {
|
|
266
|
-
out.set(c, offset);
|
|
267
|
-
offset += c.byteLength;
|
|
268
|
-
}
|
|
269
|
-
return out;
|
|
270
|
-
}
|
|
271
221
|
async function compressBytes(bytes, encoding) {
|
|
272
222
|
const Stream = globalThis.CompressionStream;
|
|
273
223
|
const cs = new Stream(encoding);
|
|
@@ -317,6 +267,8 @@ async function compressBytes(bytes, encoding) {
|
|
|
317
267
|
* - response already declares a `Content-Encoding`;
|
|
318
268
|
* - response declares a `Set-Cookie` (response is mutating auth state);
|
|
319
269
|
* - response body byte length is below `minimumSize` (default `1024`);
|
|
270
|
+
* - response body exceeds `maxCompressibleBytes` while reading; only the
|
|
271
|
+
* capture clone is cancelled, without waiting for the client to consume it;
|
|
320
272
|
* - response `Content-Type` is in the always-on already-compressed
|
|
321
273
|
* deny-list (image/video/audio/archives/fonts/wasm/pdf, with
|
|
322
274
|
* `image/svg+xml` carved back in as compressible XML).
|
|
@@ -407,7 +359,7 @@ export function compression(opts = {}) {
|
|
|
407
359
|
if (Number.isFinite(n) && n > maxCompressibleBytes)
|
|
408
360
|
return undefined;
|
|
409
361
|
}
|
|
410
|
-
const original = await
|
|
362
|
+
const original = await readResponseBodyUpTo(res.clone(), maxCompressibleBytes).catch(() => null);
|
|
411
363
|
if (original === null)
|
|
412
364
|
return undefined; // exceeded cap while streaming
|
|
413
365
|
if (original.byteLength < minimumSize)
|
package/dist/fetch-guard.d.ts
CHANGED
|
@@ -254,7 +254,15 @@ export interface FetchGuardOptions {
|
|
|
254
254
|
* @param options - Guard configuration; see {@link FetchGuardOptions}. Omit
|
|
255
255
|
* for the strict default posture (public IPs over `http:`/`https:` only).
|
|
256
256
|
* @returns A `fetch`-compatible function that validates every hop (including
|
|
257
|
-
* redirects)
|
|
257
|
+
* redirects). Cross-origin redirects remove `Authorization`, `Cookie`,
|
|
258
|
+
* `Proxy-Authorization`, and explicit `Host` headers; same-origin redirects
|
|
259
|
+
* preserve them. Custom credential headers must not be used with untrusted
|
|
260
|
+
* redirect destinations; use `redirect: "error"` or `"manual"` in that case.
|
|
261
|
+
* @throws {Error} If no underlying fetch implementation is available.
|
|
262
|
+
* @throws {SsrfBlockedError} The returned function throws when a destination
|
|
263
|
+
* or redirect chain violates the configured policy; network errors propagate.
|
|
264
|
+
* @throws {TypeError} The returned function throws on invalid requests or
|
|
265
|
+
* redirects when `redirect: "error"` is selected.
|
|
258
266
|
* @since 0.34.0
|
|
259
267
|
*/
|
|
260
268
|
export declare function fetchGuard(options?: FetchGuardOptions): typeof fetch;
|
package/dist/fetch-guard.js
CHANGED
|
@@ -176,7 +176,15 @@ const UNIQUE_LOCAL = ["fc00::/7"];
|
|
|
176
176
|
* @param options - Guard configuration; see {@link FetchGuardOptions}. Omit
|
|
177
177
|
* for the strict default posture (public IPs over `http:`/`https:` only).
|
|
178
178
|
* @returns A `fetch`-compatible function that validates every hop (including
|
|
179
|
-
* redirects)
|
|
179
|
+
* redirects). Cross-origin redirects remove `Authorization`, `Cookie`,
|
|
180
|
+
* `Proxy-Authorization`, and explicit `Host` headers; same-origin redirects
|
|
181
|
+
* preserve them. Custom credential headers must not be used with untrusted
|
|
182
|
+
* redirect destinations; use `redirect: "error"` or `"manual"` in that case.
|
|
183
|
+
* @throws {Error} If no underlying fetch implementation is available.
|
|
184
|
+
* @throws {SsrfBlockedError} The returned function throws when a destination
|
|
185
|
+
* or redirect chain violates the configured policy; network errors propagate.
|
|
186
|
+
* @throws {TypeError} The returned function throws on invalid requests or
|
|
187
|
+
* redirects when `redirect: "error"` is selected.
|
|
180
188
|
* @since 0.34.0
|
|
181
189
|
*/
|
|
182
190
|
export function fetchGuard(options = {}) {
|
|
@@ -371,6 +379,12 @@ export function fetchGuard(options = {}) {
|
|
|
371
379
|
referrerPolicy: request.referrerPolicy,
|
|
372
380
|
})
|
|
373
381
|
: new Request(next, request);
|
|
382
|
+
if (next.origin !== currentUrl.origin) {
|
|
383
|
+
request.headers.delete("authorization");
|
|
384
|
+
request.headers.delete("cookie");
|
|
385
|
+
request.headers.delete("proxy-authorization");
|
|
386
|
+
request.headers.delete("host");
|
|
387
|
+
}
|
|
374
388
|
currentUrl = next;
|
|
375
389
|
}
|
|
376
390
|
};
|
|
@@ -283,6 +283,10 @@ export interface ResilientFetchOptions {
|
|
|
283
283
|
* Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
|
|
284
284
|
* circuit breaker. The returned function has the same call signature as
|
|
285
285
|
* the global `fetch`.
|
|
286
|
+
* Response bodies discarded for a retry are cancelled before backoff without
|
|
287
|
+
* waiting for producer cancellation. The final response remains caller-owned.
|
|
288
|
+
* Caller cancellation preserves arbitrary abort reasons and never counts as
|
|
289
|
+
* an upstream failure or schedules another retry.
|
|
286
290
|
*
|
|
287
291
|
* Layer it over {@link fetchGuard} to keep SSRF protection underneath:
|
|
288
292
|
*
|
package/dist/fetch-resilience.js
CHANGED
|
@@ -348,6 +348,10 @@ function isAbortError(err) {
|
|
|
348
348
|
* Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
|
|
349
349
|
* circuit breaker. The returned function has the same call signature as
|
|
350
350
|
* the global `fetch`.
|
|
351
|
+
* Response bodies discarded for a retry are cancelled before backoff without
|
|
352
|
+
* waiting for producer cancellation. The final response remains caller-owned.
|
|
353
|
+
* Caller cancellation preserves arbitrary abort reasons and never counts as
|
|
354
|
+
* an upstream failure or schedules another retry.
|
|
351
355
|
*
|
|
352
356
|
* Layer it over {@link fetchGuard} to keep SSRF protection underneath:
|
|
353
357
|
*
|
|
@@ -416,6 +420,7 @@ export function resilientFetch(options = {}) {
|
|
|
416
420
|
// the caller's signal can be combined per attempt.
|
|
417
421
|
const request = new Request(input, init);
|
|
418
422
|
const callerSignal = init?.signal ?? request.signal;
|
|
423
|
+
callerSignal?.throwIfAborted();
|
|
419
424
|
const run = async () => {
|
|
420
425
|
let lastError;
|
|
421
426
|
for (let attempt = 1; attempt <= retries + 1; attempt++) {
|
|
@@ -428,8 +433,7 @@ export function resilientFetch(options = {}) {
|
|
|
428
433
|
catch (err) {
|
|
429
434
|
cleanup();
|
|
430
435
|
// Caller cancelled: never retry, never count as upstream failure.
|
|
431
|
-
|
|
432
|
-
throw err;
|
|
436
|
+
callerSignal?.throwIfAborted();
|
|
433
437
|
// An SSRF refusal from an underlying fetchGuard is a hard, terminal
|
|
434
438
|
// decision about the request itself — never retried.
|
|
435
439
|
if (err instanceof Error && err.name === "SsrfBlockedError")
|
|
@@ -441,8 +445,7 @@ export function resilientFetch(options = {}) {
|
|
|
441
445
|
const delay = backoffFor(attempt);
|
|
442
446
|
options.onRetry?.(ctx, delay);
|
|
443
447
|
await sleep(delay, callerSignal ?? undefined);
|
|
444
|
-
|
|
445
|
-
throw lastError;
|
|
448
|
+
callerSignal?.throwIfAborted();
|
|
446
449
|
continue;
|
|
447
450
|
}
|
|
448
451
|
throw lastError;
|
|
@@ -452,9 +455,9 @@ export function resilientFetch(options = {}) {
|
|
|
452
455
|
if (attempt <= retries && shouldRetry(ctx)) {
|
|
453
456
|
const delay = backoffFor(attempt, response);
|
|
454
457
|
options.onRetry?.(ctx, delay);
|
|
458
|
+
void response.body?.cancel().catch(() => undefined);
|
|
455
459
|
await sleep(delay, callerSignal ?? undefined);
|
|
456
|
-
|
|
457
|
-
return response;
|
|
460
|
+
callerSignal?.throwIfAborted();
|
|
458
461
|
continue;
|
|
459
462
|
}
|
|
460
463
|
return response;
|
|
@@ -476,7 +479,7 @@ export function resilientFetch(options = {}) {
|
|
|
476
479
|
// SSRF refusals and caller aborts are not upstream health signals.
|
|
477
480
|
if (err instanceof CircuitOpenError)
|
|
478
481
|
throw err;
|
|
479
|
-
const isCallerAbort =
|
|
482
|
+
const isCallerAbort = callerSignal?.aborted;
|
|
480
483
|
const isSsrf = err instanceof Error && err.name === "SsrfBlockedError";
|
|
481
484
|
if (isCallerAbort || isSsrf)
|
|
482
485
|
breaker.release();
|
|
@@ -39,7 +39,11 @@ import type { Hooks } from "./types.js";
|
|
|
39
39
|
* @since 0.37.0
|
|
40
40
|
*/
|
|
41
41
|
export type HttpSignatureAlgorithm = "hmac-sha256" | "ed25519" | "ecdsa-p256-sha256" | "ecdsa-p384-sha384" | "rsa-pss-sha512" | "rsa-v1_5-sha256";
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Key material accepted by the signer/verifier. Imported keys must match the
|
|
44
|
+
* selected algorithm's family, hash and curve, with the same 32-byte HMAC and
|
|
45
|
+
* 2048-bit RSA minimums as raw keys.
|
|
46
|
+
*/
|
|
43
47
|
export type HttpSignatureKeyMaterial = CryptoKey | Uint8Array | JsonWebKey;
|
|
44
48
|
/**
|
|
45
49
|
* A resolved verification key, optionally pinning the algorithm it may be used
|
|
@@ -134,7 +138,8 @@ export interface MessageSignature {
|
|
|
134
138
|
* @returns The `Signature-Input` / `Signature` header values plus the exact
|
|
135
139
|
* signature base that was signed.
|
|
136
140
|
* @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
|
|
137
|
-
* unserializable parameter values.
|
|
141
|
+
* unserializable parameter values. Imported keys must match the selected
|
|
142
|
+
* algorithm's family, hash and curve and meet its strength floor.
|
|
138
143
|
* @throws {Error} when a covered component cannot be resolved (e.g. a covered
|
|
139
144
|
* header is missing) or WebCrypto is unavailable.
|
|
140
145
|
* @since 0.37.0
|
|
@@ -245,7 +250,8 @@ export interface VerifyMessageOptions {
|
|
|
245
250
|
requiredTag?: string;
|
|
246
251
|
/**
|
|
247
252
|
* Replay check. When provided, a `nonce` is required and the signature is
|
|
248
|
-
* rejected if this returns `true`.
|
|
253
|
+
* rejected if this returns `true`. Called only after cryptographic verification
|
|
254
|
+
* succeeds; implementations that record nonces must check and record atomically.
|
|
249
255
|
*/
|
|
250
256
|
isReplay?: (nonce: string, info: KeyResolutionInfo) => boolean | Promise<boolean>;
|
|
251
257
|
/** Clock used for age checks. Returns milliseconds. Defaults to `Date.now`. */
|
|
@@ -255,6 +261,7 @@ export interface VerifyMessageOptions {
|
|
|
255
261
|
* Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
|
|
256
262
|
* structured result and never throws on a bad/forged signature — only on a
|
|
257
263
|
* programming error (e.g. WebCrypto unavailable).
|
|
264
|
+
* Imported keys that violate the algorithm or strength policy return invalid_key.
|
|
258
265
|
*
|
|
259
266
|
* @param opts - Received message plus verification policy (algorithm
|
|
260
267
|
* allowlist, key resolver, freshness / replay checks); see
|
|
@@ -300,6 +307,8 @@ export interface HttpSignatureAuthOptions extends Omit<VerifyMessageOptions, "me
|
|
|
300
307
|
* requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
|
|
301
308
|
* a missing (unless `optional`) or invalid signature it throws
|
|
302
309
|
* {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
|
|
310
|
+
* Verification runs before body I/O and before stored-response middleware,
|
|
311
|
+
* so cache hits and idempotency replays cannot skip signature authentication.
|
|
303
312
|
*
|
|
304
313
|
* @param opts - Verification policy plus middleware knobs; see
|
|
305
314
|
* {@link HttpSignatureAuthOptions}.
|
package/dist/http-signatures.js
CHANGED
|
@@ -167,7 +167,7 @@ async function importKey(alg, material, usage) {
|
|
|
167
167
|
const spec = algSpec(alg);
|
|
168
168
|
const c = getCrypto();
|
|
169
169
|
if (isCryptoKey(material)) {
|
|
170
|
-
|
|
170
|
+
assertKeyPolicy(alg, material);
|
|
171
171
|
return material;
|
|
172
172
|
}
|
|
173
173
|
if (material instanceof Uint8Array) {
|
|
@@ -177,15 +177,31 @@ async function importKey(alg, material, usage) {
|
|
|
177
177
|
if (material.byteLength < MIN_HMAC_KEY_BYTES) {
|
|
178
178
|
throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2); got ${material.byteLength}.`);
|
|
179
179
|
}
|
|
180
|
-
return c.subtle.importKey("raw", material, spec.importParams, false, [
|
|
180
|
+
return c.subtle.importKey("raw", material, spec.importParams, false, [
|
|
181
|
+
usage,
|
|
182
|
+
]);
|
|
181
183
|
}
|
|
182
184
|
if (isJsonWebKey(material)) {
|
|
183
185
|
const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [usage]);
|
|
184
|
-
|
|
186
|
+
assertKeyPolicy(alg, key);
|
|
185
187
|
return key;
|
|
186
188
|
}
|
|
187
189
|
throw new TypeError("http-signatures: unsupported key material.");
|
|
188
190
|
}
|
|
191
|
+
function assertKeyPolicy(alg, key) {
|
|
192
|
+
const expected = algSpec(alg).importParams;
|
|
193
|
+
const actual = key.algorithm;
|
|
194
|
+
if (actual.name !== expected.name ||
|
|
195
|
+
(expected.hash !== undefined && actual.hash?.name !== expected.hash) ||
|
|
196
|
+
(expected.namedCurve !== undefined && actual.namedCurve !== expected.namedCurve)) {
|
|
197
|
+
throw new TypeError(`http-signatures: key algorithm does not match ${alg}; family, hash and curve must match.`);
|
|
198
|
+
}
|
|
199
|
+
if (expected.name === "HMAC" &&
|
|
200
|
+
(!Number.isFinite(actual.length) || actual.length < MIN_HMAC_KEY_BYTES * 8)) {
|
|
201
|
+
throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2).`);
|
|
202
|
+
}
|
|
203
|
+
assertRsaModulusFloor(alg, key);
|
|
204
|
+
}
|
|
189
205
|
function serializeSfString(s) {
|
|
190
206
|
for (let i = 0; i < s.length; i++) {
|
|
191
207
|
const code = s.charCodeAt(i);
|
|
@@ -518,7 +534,8 @@ function parseComponentSpec(spec) {
|
|
|
518
534
|
* @returns The `Signature-Input` / `Signature` header values plus the exact
|
|
519
535
|
* signature base that was signed.
|
|
520
536
|
* @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
|
|
521
|
-
* unserializable parameter values.
|
|
537
|
+
* unserializable parameter values. Imported keys must match the selected
|
|
538
|
+
* algorithm's family, hash and curve and meet its strength floor.
|
|
522
539
|
* @throws {Error} when a covered component cannot be resolved (e.g. a covered
|
|
523
540
|
* header is missing) or WebCrypto is unavailable.
|
|
524
541
|
* @since 0.37.0
|
|
@@ -585,6 +602,7 @@ function fail(reason) {
|
|
|
585
602
|
* Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
|
|
586
603
|
* structured result and never throws on a bad/forged signature — only on a
|
|
587
604
|
* programming error (e.g. WebCrypto unavailable).
|
|
605
|
+
* Imported keys that violate the algorithm or strength policy return invalid_key.
|
|
588
606
|
*
|
|
589
607
|
* @param opts - Received message plus verification policy (algorithm
|
|
590
608
|
* allowlist, key resolver, freshness / replay checks); see
|
|
@@ -671,12 +689,8 @@ export async function verifyMessage(opts) {
|
|
|
671
689
|
return fail("signature_expired");
|
|
672
690
|
}
|
|
673
691
|
// Replay check.
|
|
674
|
-
if (opts.isReplay)
|
|
675
|
-
|
|
676
|
-
return fail("missing_nonce");
|
|
677
|
-
if (await opts.isReplay(params.nonce, info))
|
|
678
|
-
return fail("replay_detected");
|
|
679
|
-
}
|
|
692
|
+
if (opts.isReplay && params.nonce === undefined)
|
|
693
|
+
return fail("missing_nonce");
|
|
680
694
|
// Resolve the key + effective algorithm (defeating algorithm-confusion).
|
|
681
695
|
const resolved = await opts.resolveKey(info);
|
|
682
696
|
if (resolved === undefined)
|
|
@@ -730,6 +744,9 @@ export async function verifyMessage(opts) {
|
|
|
730
744
|
}
|
|
731
745
|
if (!ok)
|
|
732
746
|
return fail("invalid_signature");
|
|
747
|
+
if (opts.isReplay && (await opts.isReplay(params.nonce, info))) {
|
|
748
|
+
return fail("replay_detected");
|
|
749
|
+
}
|
|
733
750
|
return {
|
|
734
751
|
valid: true,
|
|
735
752
|
label,
|
|
@@ -765,6 +782,8 @@ export function verifyRequest(request, opts) {
|
|
|
765
782
|
* requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
|
|
766
783
|
* a missing (unless `optional`) or invalid signature it throws
|
|
767
784
|
* {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
|
|
785
|
+
* Verification runs before body I/O and before stored-response middleware,
|
|
786
|
+
* so cache hits and idempotency replays cannot skip signature authentication.
|
|
768
787
|
*
|
|
769
788
|
* @param opts - Verification policy plus middleware knobs; see
|
|
770
789
|
* {@link HttpSignatureAuthOptions}.
|
|
@@ -775,7 +794,7 @@ export function httpSignatureAuth(opts) {
|
|
|
775
794
|
const stateKey = opts.stateKey ?? "httpSignature";
|
|
776
795
|
const message = opts.message ?? "Valid HTTP message signature required";
|
|
777
796
|
const authHooks = {
|
|
778
|
-
async
|
|
797
|
+
async preBody(ctx) {
|
|
779
798
|
const headers = ctx.request.headers;
|
|
780
799
|
if (opts.optional && !headers.has("signature"))
|
|
781
800
|
return undefined;
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -214,6 +214,8 @@ export interface IdempotencyOptions {
|
|
|
214
214
|
* alone is not a bound — it only drops records that have *expired*, so a stream
|
|
215
215
|
* of unique keys inside the TTL grew the map linearly no matter how often it ran,
|
|
216
216
|
* with each entry pinning a stored response body.
|
|
217
|
+
* Late completions of evicted reservations use the same capacity policy;
|
|
218
|
+
* completing a retained entry updates it without evicting another record.
|
|
217
219
|
*
|
|
218
220
|
* Evicting a live record can only cost exactly-once semantics for a retry that
|
|
219
221
|
* arrives after the eviction — it re-executes rather than replaying. That is the
|
|
@@ -235,7 +237,14 @@ export declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
235
237
|
* the in-memory store derives expiry from `record.expiresAt`.
|
|
236
238
|
*/
|
|
237
239
|
reserve(key: string, record: IdempotencyRecord, _ttlMs?: number): IdempotencyRecord | null;
|
|
238
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* Persist a completed response, enforcing capacity even if its reservation
|
|
242
|
+
* was evicted while the handler ran.
|
|
243
|
+
* @param key - Reservation key to complete.
|
|
244
|
+
* @param record - Completed record with response data and expiry.
|
|
245
|
+
* @param _ttlMs - Unused; expiry is derived from record.expiresAt.
|
|
246
|
+
* @returns Nothing. A new entry may evict the oldest retained record.
|
|
247
|
+
*/
|
|
239
248
|
complete(key: string, record: IdempotencyRecord, _ttlMs?: number): void;
|
|
240
249
|
/** @inheritDoc */
|
|
241
250
|
release(key: string): void;
|
|
@@ -278,6 +287,12 @@ export declare const IDEMPOTENCY_HOOK_MARKER: unique symbol;
|
|
|
278
287
|
* Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
|
|
279
288
|
* by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
|
|
280
289
|
* cached and the reservation is released so the client can retry.
|
|
290
|
+
* The byte cap is enforced while reading the response clone; exceeding it
|
|
291
|
+
* stops capture without waiting for EOF or consuming the client's branch.
|
|
292
|
+
* Replays require every scope aggregated from the route's requireScopes hooks;
|
|
293
|
+
* callers without those scopes continue to the normal authorization chain.
|
|
294
|
+
* Empty Authorization headers do not resolve a principal and cannot bypass
|
|
295
|
+
* the default cookie-bearing-request scope guard.
|
|
281
296
|
*
|
|
282
297
|
* @example
|
|
283
298
|
* ```ts
|
package/dist/idempotency.js
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { BadRequestError, ConflictError, HttpError } from "./errors.js";
|
|
35
35
|
import { markSchemaValidatedResponse } from "./internal-response.js";
|
|
36
|
+
import { readResponseBodyUpTo } from "./internal-body.js";
|
|
37
|
+
import { hasReplayScopes } from "./internal-replay.js";
|
|
36
38
|
const enc = new TextEncoder();
|
|
37
39
|
/** Internal `ctx.state` key carrying the reservation between hooks. */
|
|
38
40
|
const PENDING_STATE_KEY = "__idempotencyPending";
|
|
@@ -73,6 +75,8 @@ const DEFAULT_MAX_IDEMPOTENCY_ENTRIES = 10_000;
|
|
|
73
75
|
* alone is not a bound — it only drops records that have *expired*, so a stream
|
|
74
76
|
* of unique keys inside the TTL grew the map linearly no matter how often it ran,
|
|
75
77
|
* with each entry pinning a stored response body.
|
|
78
|
+
* Late completions of evicted reservations use the same capacity policy;
|
|
79
|
+
* completing a retained entry updates it without evicting another record.
|
|
76
80
|
*
|
|
77
81
|
* Evicting a live record can only cost exactly-once semantics for a retry that
|
|
78
82
|
* arrives after the eviction — it re-executes rather than replaying. That is the
|
|
@@ -117,8 +121,19 @@ export class MemoryIdempotencyStore {
|
|
|
117
121
|
this.map.set(key, record);
|
|
118
122
|
return null;
|
|
119
123
|
}
|
|
120
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Persist a completed response, enforcing capacity even if its reservation
|
|
126
|
+
* was evicted while the handler ran.
|
|
127
|
+
* @param key - Reservation key to complete.
|
|
128
|
+
* @param record - Completed record with response data and expiry.
|
|
129
|
+
* @param _ttlMs - Unused; expiry is derived from record.expiresAt.
|
|
130
|
+
* @returns Nothing. A new entry may evict the oldest retained record.
|
|
131
|
+
*/
|
|
121
132
|
complete(key, record, _ttlMs) {
|
|
133
|
+
if (!this.map.has(key)) {
|
|
134
|
+
this.reserve(key, record, _ttlMs);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
122
137
|
this.map.set(key, record);
|
|
123
138
|
}
|
|
124
139
|
/** @inheritDoc */
|
|
@@ -266,8 +281,8 @@ const NEVER_REPLAYED_HEADERS = new Set([
|
|
|
266
281
|
"x-request-id",
|
|
267
282
|
]);
|
|
268
283
|
async function captureResponse(res, maxBytes) {
|
|
269
|
-
const buf =
|
|
270
|
-
if (buf
|
|
284
|
+
const buf = await readResponseBodyUpTo(res.clone(), maxBytes);
|
|
285
|
+
if (buf === null)
|
|
271
286
|
return null;
|
|
272
287
|
const headers = [];
|
|
273
288
|
res.headers.forEach((value, name) => {
|
|
@@ -326,6 +341,12 @@ export const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
|
|
|
326
341
|
* Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
|
|
327
342
|
* by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
|
|
328
343
|
* cached and the reservation is released so the client can retry.
|
|
344
|
+
* The byte cap is enforced while reading the response clone; exceeding it
|
|
345
|
+
* stops capture without waiting for EOF or consuming the client's branch.
|
|
346
|
+
* Replays require every scope aggregated from the route's requireScopes hooks;
|
|
347
|
+
* callers without those scopes continue to the normal authorization chain.
|
|
348
|
+
* Empty Authorization headers do not resolve a principal and cannot bypass
|
|
349
|
+
* the default cookie-bearing-request scope guard.
|
|
329
350
|
*
|
|
330
351
|
* @example
|
|
331
352
|
* ```ts
|
|
@@ -376,6 +397,8 @@ export function idempotency(opts = {}) {
|
|
|
376
397
|
const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
|
|
377
398
|
const hooks = {
|
|
378
399
|
async beforeHandle(ctx) {
|
|
400
|
+
if (!hasReplayScopes(ctx))
|
|
401
|
+
return undefined;
|
|
379
402
|
const method = ctx.request.method.toUpperCase();
|
|
380
403
|
if (!methods.has(method))
|
|
381
404
|
return undefined;
|
|
@@ -423,11 +446,11 @@ export function idempotency(opts = {}) {
|
|
|
423
446
|
// is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
|
|
424
447
|
// coarse namespace cannot escalate into handing over a live session.
|
|
425
448
|
if (!opts.scope &&
|
|
426
|
-
scopeRaw
|
|
449
|
+
!scopeRaw &&
|
|
427
450
|
!allowUnscopedCallers &&
|
|
428
451
|
ctx.request.headers.has("cookie")) {
|
|
429
452
|
throw new Error("idempotency(): cannot determine the calling principal for a cookie-bearing request. " +
|
|
430
|
-
"The default scope reads the Authorization header, which this request does not carry, " +
|
|
453
|
+
"The default scope reads the Authorization header, which this request does not carry with a non-empty value, " +
|
|
431
454
|
"so every cookie-authenticated caller would share one idempotency namespace and could " +
|
|
432
455
|
"replay another caller's stored response (CWE-524). Pass " +
|
|
433
456
|
"`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a response clone while enforcing an inclusive byte limit.
|
|
3
|
+
*
|
|
4
|
+
* @param response - A clone whose body may be consumed or cancelled.
|
|
5
|
+
* @param maxBytes - Maximum retained response bytes, validated by the caller.
|
|
6
|
+
* @returns Captured bytes, or null as soon as the stream exceeds the limit.
|
|
7
|
+
* Cancelling a tee branch is not awaited because the other branch may still
|
|
8
|
+
* be waiting for its consumer. The original response remains readable.
|
|
9
|
+
* @throws Propagates stream read errors without converting them to cached data.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function readResponseBodyUpTo(response: Response, maxBytes: number): Promise<Uint8Array | null>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a response clone while enforcing an inclusive byte limit.
|
|
3
|
+
*
|
|
4
|
+
* @param response - A clone whose body may be consumed or cancelled.
|
|
5
|
+
* @param maxBytes - Maximum retained response bytes, validated by the caller.
|
|
6
|
+
* @returns Captured bytes, or null as soon as the stream exceeds the limit.
|
|
7
|
+
* Cancelling a tee branch is not awaited because the other branch may still
|
|
8
|
+
* be waiting for its consumer. The original response remains readable.
|
|
9
|
+
* @throws Propagates stream read errors without converting them to cached data.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export async function readResponseBodyUpTo(response, maxBytes) {
|
|
13
|
+
if (!response.body)
|
|
14
|
+
return new Uint8Array(0);
|
|
15
|
+
const reader = response.body.getReader();
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let total = 0;
|
|
18
|
+
try {
|
|
19
|
+
while (true) {
|
|
20
|
+
const { done, value } = await reader.read();
|
|
21
|
+
if (done)
|
|
22
|
+
break;
|
|
23
|
+
if (value.byteLength === 0)
|
|
24
|
+
continue;
|
|
25
|
+
total += value.byteLength;
|
|
26
|
+
if (total > maxBytes) {
|
|
27
|
+
void reader.cancel().catch(() => undefined);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
chunks.push(value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
reader.releaseLock();
|
|
35
|
+
}
|
|
36
|
+
if (chunks.length === 1)
|
|
37
|
+
return chunks[0];
|
|
38
|
+
const bytes = new Uint8Array(total);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const chunk of chunks) {
|
|
41
|
+
bytes.set(chunk, offset);
|
|
42
|
+
offset += chunk.byteLength;
|
|
43
|
+
}
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check the scope requirements aggregated by App before a stored-response hook.
|
|
3
|
+
*
|
|
4
|
+
* @param context - Current request state, including requirements installed by
|
|
5
|
+
* App.mergeBeforeHandle and the user resolved by upstream authentication.
|
|
6
|
+
* @returns True when no scope guard applies or the current user owns every
|
|
7
|
+
* required scope. False tells replay middleware to defer to the normal hook
|
|
8
|
+
* chain without reading, reserving, or capturing a stored response. The
|
|
9
|
+
* downstream requireScopes hook remains responsible for rejecting access.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function hasReplayScopes(context: {
|
|
13
|
+
readonly state: Readonly<Record<string, unknown>>;
|
|
14
|
+
}): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check the scope requirements aggregated by App before a stored-response hook.
|
|
3
|
+
*
|
|
4
|
+
* @param context - Current request state, including requirements installed by
|
|
5
|
+
* App.mergeBeforeHandle and the user resolved by upstream authentication.
|
|
6
|
+
* @returns True when no scope guard applies or the current user owns every
|
|
7
|
+
* required scope. False tells replay middleware to defer to the normal hook
|
|
8
|
+
* chain without reading, reserving, or capturing a stored response. The
|
|
9
|
+
* downstream requireScopes hook remains responsible for rejecting access.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export function hasReplayScopes(context) {
|
|
13
|
+
const required = context.state.__daloyRequiredScopes;
|
|
14
|
+
if (!Array.isArray(required) || required.length === 0)
|
|
15
|
+
return true;
|
|
16
|
+
const user = context.state.user;
|
|
17
|
+
if (!user || typeof user !== "object")
|
|
18
|
+
return false;
|
|
19
|
+
const owned = user.scopes;
|
|
20
|
+
return (Array.isArray(owned) && required.every((scope) => owned.includes(scope)));
|
|
21
|
+
}
|
package/dist/jwk.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export interface JwkSet {
|
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* Source for the verifier's public keys. Either an in-memory JWKS, a
|
|
12
|
-
* `https://` URL (fetched with TTL caching
|
|
12
|
+
* `https://` URL (fetched directly with TTL caching; redirects are refused),
|
|
13
|
+
* or a custom async resolver.
|
|
13
14
|
*/
|
|
14
15
|
export type JwkSource = JwkSet | string | (() => JwkSet | Promise<JwkSet>);
|
|
15
16
|
/**
|
|
@@ -61,7 +62,8 @@ export interface JwkOptions {
|
|
|
61
62
|
maxStaleSeconds?: number;
|
|
62
63
|
/**
|
|
63
64
|
* Optional `fetch` implementation override (mainly for tests). Defaults
|
|
64
|
-
|
|
65
|
+
* to global `fetch`. Overrides must honor `redirect: "error"` to preserve
|
|
66
|
+
* the configured HTTPS signing-key authority.
|
|
65
67
|
*/
|
|
66
68
|
fetch?: typeof fetch;
|
|
67
69
|
/**
|
|
@@ -79,6 +81,8 @@ export interface JwkOptions {
|
|
|
79
81
|
* algorithms at construction time, requires every token to carry a `kid`
|
|
80
82
|
* header, matches that `kid` against the JWKS, and cross-checks the JWT
|
|
81
83
|
* header `alg` against the JWK's own `alg` (when present).
|
|
84
|
+
* URL sources are fetched with redirects disabled; redirecting refreshes
|
|
85
|
+
* follow the existing last-good-key grace policy, never installing new keys.
|
|
82
86
|
*
|
|
83
87
|
* @example
|
|
84
88
|
* ```ts
|
package/dist/jwk.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* `alg`, the two MUST agree (RFC 7517 §4.4 cross-check).
|
|
17
17
|
* - `exp` / `nbf` / `iat` validated on every verify.
|
|
18
18
|
* - JWKS URLs MUST be `https://` (refused at construction otherwise).
|
|
19
|
+
* Redirects are refused so the HTTPS/key-authority boundary is not bypassed.
|
|
19
20
|
* - Optional `verify(payload, ctx)` revalidation hook for
|
|
20
21
|
* revocation lists, token-version counters, etc.
|
|
21
22
|
*
|
|
@@ -93,6 +94,7 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
|
|
|
93
94
|
try {
|
|
94
95
|
const res = await fetchImpl(source, {
|
|
95
96
|
headers: { accept: "application/json" },
|
|
97
|
+
redirect: "error",
|
|
96
98
|
});
|
|
97
99
|
if (!res.ok) {
|
|
98
100
|
throw new Error(`jwk(): JWKS fetch failed with status ${res.status}.`);
|
|
@@ -144,6 +146,8 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
|
|
|
144
146
|
* algorithms at construction time, requires every token to carry a `kid`
|
|
145
147
|
* header, matches that `kid` against the JWKS, and cross-checks the JWT
|
|
146
148
|
* header `alg` against the JWK's own `alg` (when present).
|
|
149
|
+
* URL sources are fetched with redirects disabled; redirecting refreshes
|
|
150
|
+
* follow the existing last-good-key grace policy, never installing new keys.
|
|
147
151
|
*
|
|
148
152
|
* @example
|
|
149
153
|
* ```ts
|
package/dist/jwt.d.ts
CHANGED
|
@@ -34,7 +34,11 @@ export interface JwtVerified {
|
|
|
34
34
|
/** Decoded claims payload after signature and time-claim checks passed. */
|
|
35
35
|
readonly payload: Record<string, unknown>;
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Key material accepted by the signer/verifier. Imported keys must match the
|
|
39
|
+
* selected algorithm's family, hash and curve. Every HMAC key format must meet
|
|
40
|
+
* the 32-byte minimum, and every RSA key must have a 2048-bit modulus or larger.
|
|
41
|
+
*/
|
|
38
42
|
export type JwtKeyMaterial = CryptoKey | Uint8Array | JsonWebKey;
|
|
39
43
|
/** Options for {@link createJwtSigner}. */
|
|
40
44
|
export interface JwtSignerOptions {
|
|
@@ -116,11 +120,14 @@ export interface JwtVerifierOptions {
|
|
|
116
120
|
* function refuses payloads without an `exp` claim (unless
|
|
117
121
|
* `acknowledgeNoExp: true` was set at construction outside production) and
|
|
118
122
|
* refuses payloads whose `exp - (iat | now)` exceeds `maxLifetimeSeconds`.
|
|
123
|
+
* Imported keys must match the algorithm's family, hash and curve; HMAC and
|
|
124
|
+
* RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
|
|
119
125
|
*
|
|
120
126
|
* @param opts - Algorithm, key, and lifetime policy; see {@link JwtSignerOptions}.
|
|
121
127
|
* @returns An object whose `sign(payload)` resolves to the compact JWS string.
|
|
122
128
|
* @throws {JwtError} for `alg: "none"`, unknown algorithms, weak keys, a
|
|
123
129
|
* missing/invalid `maxLifetimeSeconds`, or `acknowledgeNoExp` in production.
|
|
130
|
+
* `sign()` also rejects imported keys that violate algorithm or strength policy.
|
|
124
131
|
* @since 0.21.0
|
|
125
132
|
*/
|
|
126
133
|
export declare function createJwtSigner(opts: JwtSignerOptions): {
|
|
@@ -132,12 +139,15 @@ export declare function createJwtSigner(opts: JwtSignerOptions): {
|
|
|
132
139
|
* `alg: "none"` and any token whose header `alg` is not in the allowlist;
|
|
133
140
|
* refuses-at-construction when a symmetric algorithm (`HS*`) is mixed with
|
|
134
141
|
* a JWK / JWKS-shaped key source (the documented confused-deputy attack).
|
|
142
|
+
* Imported keys must match the algorithm's family, hash and curve; HMAC and
|
|
143
|
+
* RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
|
|
135
144
|
*
|
|
136
145
|
* @param opts - Allowlist, key source, and claim checks; see {@link JwtVerifierOptions}.
|
|
137
146
|
* @returns An object whose `verify(token)` resolves to the decoded
|
|
138
147
|
* {@link JwtVerified} or rejects with {@link JwtError}.
|
|
139
148
|
* @throws {JwtError} at construction for an empty/invalid allowlist, `"none"`
|
|
140
149
|
* in the allowlist, weak HS* secrets, or HS* mixed with a JWK source.
|
|
150
|
+
* `verify()` also rejects imported keys that violate algorithm or strength policy.
|
|
141
151
|
* @since 0.21.0
|
|
142
152
|
*/
|
|
143
153
|
export declare function createJwtVerifier(opts: JwtVerifierOptions): {
|
package/dist/jwt.js
CHANGED
|
@@ -158,7 +158,7 @@ async function importKey(alg, material, usage) {
|
|
|
158
158
|
const params = algParams(alg);
|
|
159
159
|
const c = getCrypto();
|
|
160
160
|
if (isCryptoKey(material)) {
|
|
161
|
-
|
|
161
|
+
assertKeyPolicy(alg, material);
|
|
162
162
|
return material;
|
|
163
163
|
}
|
|
164
164
|
if (material instanceof Uint8Array) {
|
|
@@ -185,11 +185,24 @@ async function importKey(alg, material, usage) {
|
|
|
185
185
|
? { name: "RSASSA-PKCS1-v1_5", hash: params.hash }
|
|
186
186
|
: { name: "Ed25519" };
|
|
187
187
|
const imported = await c.subtle.importKey("jwk", material, importAlgorithm, false, [usage]);
|
|
188
|
-
|
|
188
|
+
assertKeyPolicy(alg, imported);
|
|
189
189
|
return imported;
|
|
190
190
|
}
|
|
191
191
|
throw new JwtError("invalid_key", "jwt(): unsupported key material.");
|
|
192
192
|
}
|
|
193
|
+
function assertKeyPolicy(alg, key) {
|
|
194
|
+
const params = algParams(alg);
|
|
195
|
+
const algorithm = key.algorithm;
|
|
196
|
+
if (algorithm.name !== params.name ||
|
|
197
|
+
(params.hash !== undefined && params.name !== "ECDSA" && algorithm.hash?.name !== params.hash) ||
|
|
198
|
+
(params.namedCurve !== undefined && algorithm.namedCurve !== params.namedCurve)) {
|
|
199
|
+
throw new JwtError("key_algorithm_mismatch", `jwt(): key algorithm does not match ${alg}; the key family, hash and curve must match the declared JWT algorithm.`);
|
|
200
|
+
}
|
|
201
|
+
if (params.name === "HMAC" && (!Number.isFinite(algorithm.length) || algorithm.length < MIN_HS_KEY_BYTES * 8)) {
|
|
202
|
+
throw new JwtError("weak_hs_secret", `jwt(): ${alg} secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2).`);
|
|
203
|
+
}
|
|
204
|
+
assertRsaModulusFloor(alg, key);
|
|
205
|
+
}
|
|
193
206
|
/**
|
|
194
207
|
* Refuse RSA keys whose modulus is shorter than {@link MIN_RSA_KEY_BITS}.
|
|
195
208
|
* Only applies to `RS*` / `PS*` algorithms — non-RSA keys are ignored. The
|
|
@@ -224,11 +237,14 @@ function buildSignAlgorithm(alg) {
|
|
|
224
237
|
* function refuses payloads without an `exp` claim (unless
|
|
225
238
|
* `acknowledgeNoExp: true` was set at construction outside production) and
|
|
226
239
|
* refuses payloads whose `exp - (iat | now)` exceeds `maxLifetimeSeconds`.
|
|
240
|
+
* Imported keys must match the algorithm's family, hash and curve; HMAC and
|
|
241
|
+
* RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
|
|
227
242
|
*
|
|
228
243
|
* @param opts - Algorithm, key, and lifetime policy; see {@link JwtSignerOptions}.
|
|
229
244
|
* @returns An object whose `sign(payload)` resolves to the compact JWS string.
|
|
230
245
|
* @throws {JwtError} for `alg: "none"`, unknown algorithms, weak keys, a
|
|
231
246
|
* missing/invalid `maxLifetimeSeconds`, or `acknowledgeNoExp` in production.
|
|
247
|
+
* `sign()` also rejects imported keys that violate algorithm or strength policy.
|
|
232
248
|
* @since 0.21.0
|
|
233
249
|
*/
|
|
234
250
|
export function createJwtSigner(opts) {
|
|
@@ -338,12 +354,15 @@ function normalizeStringSet(value) {
|
|
|
338
354
|
* `alg: "none"` and any token whose header `alg` is not in the allowlist;
|
|
339
355
|
* refuses-at-construction when a symmetric algorithm (`HS*`) is mixed with
|
|
340
356
|
* a JWK / JWKS-shaped key source (the documented confused-deputy attack).
|
|
357
|
+
* Imported keys must match the algorithm's family, hash and curve; HMAC and
|
|
358
|
+
* RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
|
|
341
359
|
*
|
|
342
360
|
* @param opts - Allowlist, key source, and claim checks; see {@link JwtVerifierOptions}.
|
|
343
361
|
* @returns An object whose `verify(token)` resolves to the decoded
|
|
344
362
|
* {@link JwtVerified} or rejects with {@link JwtError}.
|
|
345
363
|
* @throws {JwtError} at construction for an empty/invalid allowlist, `"none"`
|
|
346
364
|
* in the allowlist, weak HS* secrets, or HS* mixed with a JWK source.
|
|
365
|
+
* `verify()` also rejects imported keys that violate algorithm or strength policy.
|
|
347
366
|
* @since 0.21.0
|
|
348
367
|
*/
|
|
349
368
|
export function createJwtVerifier(opts) {
|
package/dist/mtls.d.ts
CHANGED
|
@@ -128,6 +128,8 @@ export interface PeerCertificateLike {
|
|
|
128
128
|
* Normalize a Node `getPeerCertificate(true)` result into a
|
|
129
129
|
* {@link ClientCertificate}. Returns `undefined` for the empty object Node
|
|
130
130
|
* returns when the peer presented no certificate.
|
|
131
|
+
* Quoted SAN values are decoded without treating their embedded commas as
|
|
132
|
+
* identity separators. Malformed SAN lists yield no identities for allowlists.
|
|
131
133
|
*
|
|
132
134
|
* @param raw - The structured peer-certificate object from the TLS socket.
|
|
133
135
|
* @param verified - Whether the socket reported `authorized === true` (the
|
|
@@ -220,6 +222,8 @@ export interface ClientCertAuthOptions {
|
|
|
220
222
|
/**
|
|
221
223
|
* If set, the certificate's SHA-256 fingerprint must match one of these
|
|
222
224
|
* (compared in constant time; colons/spaces and case are ignored).
|
|
225
|
+
* An empty list denies every certificate; omit this option to disable
|
|
226
|
+
* fingerprint restrictions while retaining the other certificate checks.
|
|
223
227
|
*/
|
|
224
228
|
allowFingerprints?: readonly string[];
|
|
225
229
|
/**
|
package/dist/mtls.js
CHANGED
|
@@ -68,6 +68,8 @@ export function getClientCertificate(request) {
|
|
|
68
68
|
* Normalize a Node `getPeerCertificate(true)` result into a
|
|
69
69
|
* {@link ClientCertificate}. Returns `undefined` for the empty object Node
|
|
70
70
|
* returns when the peer presented no certificate.
|
|
71
|
+
* Quoted SAN values are decoded without treating their embedded commas as
|
|
72
|
+
* identity separators. Malformed SAN lists yield no identities for allowlists.
|
|
71
73
|
*
|
|
72
74
|
* @param raw - The structured peer-certificate object from the TLS socket.
|
|
73
75
|
* @param verified - Whether the socket reported `authorized === true` (the
|
|
@@ -148,13 +150,58 @@ function parseCertDate(value) {
|
|
|
148
150
|
function parseNodeSubjectAltName(san) {
|
|
149
151
|
if (typeof san !== "string" || san.length === 0)
|
|
150
152
|
return [];
|
|
151
|
-
// Node renders SANs as `DNS:a, IP Address:1.2.3.4, URI:spiffe://...`.
|
|
152
153
|
const out = [];
|
|
153
|
-
|
|
154
|
+
const quoted = san.includes('"');
|
|
155
|
+
const pieces = quoted ? [] : san.split(",");
|
|
156
|
+
if (quoted) {
|
|
157
|
+
let start = 0;
|
|
158
|
+
let inQuotes = false;
|
|
159
|
+
let escaped = false;
|
|
160
|
+
for (let index = 0; index < san.length; index++) {
|
|
161
|
+
const char = san[index];
|
|
162
|
+
if (escaped) {
|
|
163
|
+
escaped = false;
|
|
164
|
+
}
|
|
165
|
+
else if (inQuotes && char === "\\") {
|
|
166
|
+
escaped = true;
|
|
167
|
+
}
|
|
168
|
+
else if (char === '"') {
|
|
169
|
+
inQuotes = !inQuotes;
|
|
170
|
+
}
|
|
171
|
+
else if (char === "," && !inQuotes) {
|
|
172
|
+
pieces.push(san.slice(start, index));
|
|
173
|
+
start = index + 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (inQuotes || escaped)
|
|
177
|
+
return [];
|
|
178
|
+
pieces.push(san.slice(start));
|
|
179
|
+
}
|
|
180
|
+
for (const piece of pieces) {
|
|
154
181
|
const trimmed = piece.trim();
|
|
155
182
|
if (trimmed.length === 0)
|
|
156
183
|
continue;
|
|
157
|
-
|
|
184
|
+
const colon = trimmed.indexOf(":");
|
|
185
|
+
if (colon < 1)
|
|
186
|
+
return [];
|
|
187
|
+
if (!quoted) {
|
|
188
|
+
out.push(trimmed.replace(/^IP Address:/i, "IP:"));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const type = trimmed.slice(0, colon);
|
|
192
|
+
let value = trimmed.slice(colon + 1);
|
|
193
|
+
if (value.startsWith('"')) {
|
|
194
|
+
try {
|
|
195
|
+
value = JSON.parse(value);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (quoted && value.includes('"')) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
out.push(`${type.toLowerCase() === "ip address" ? "IP" : type}:${value}`);
|
|
158
205
|
}
|
|
159
206
|
return out;
|
|
160
207
|
}
|
|
@@ -330,7 +377,7 @@ export function clientCertAuth(opts = {}) {
|
|
|
330
377
|
const message = opts.message ?? "Client certificate not permitted";
|
|
331
378
|
const stateKey = opts.stateKey ?? "clientCertificate";
|
|
332
379
|
const now = opts.now ?? Date.now;
|
|
333
|
-
const allowFingerprints =
|
|
380
|
+
const allowFingerprints = opts.allowFingerprints?.map((f) => normalizeFingerprint(f) ?? "");
|
|
334
381
|
const allowSubjectCNs = opts.allowSubjectCNs;
|
|
335
382
|
const allowIssuerCNs = opts.allowIssuerCNs;
|
|
336
383
|
const allowSANs = opts.allowSANs;
|
|
@@ -371,7 +418,7 @@ export function clientCertAuth(opts = {}) {
|
|
|
371
418
|
if (allowIssuerCNs && !matchesAllowedCN(cert.issuerCN, allowIssuerCNs)) {
|
|
372
419
|
throw new ForbiddenError(message);
|
|
373
420
|
}
|
|
374
|
-
if (allowFingerprints
|
|
421
|
+
if (allowFingerprints !== undefined && !matchesFingerprint(cert, allowFingerprints)) {
|
|
375
422
|
throw new ForbiddenError(message);
|
|
376
423
|
}
|
|
377
424
|
if (allowSANs && !matchesSAN(cert.subjectAltNames, allowSANs)) {
|
package/dist/response-cache.d.ts
CHANGED
|
@@ -355,6 +355,10 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
|
|
|
355
355
|
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
|
|
356
356
|
* failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
357
357
|
* {@link ResponseCacheOptions.maxBodyBytes} are never cached.
|
|
358
|
+
* The byte cap is enforced while reading the response clone; exceeding it
|
|
359
|
+
* stops capture without waiting for EOF or consuming the client's branch.
|
|
360
|
+
* Replays require every scope aggregated from the route's requireScopes hooks;
|
|
361
|
+
* callers without those scopes continue to the normal authorization chain.
|
|
358
362
|
*
|
|
359
363
|
* A response that declares `Vary` is stored as a **variant**: the request's
|
|
360
364
|
* values for those fields are recorded alongside it, and the entry is replayed
|
package/dist/response-cache.js
CHANGED
|
@@ -65,6 +65,8 @@
|
|
|
65
65
|
* @since 0.37.0
|
|
66
66
|
*/
|
|
67
67
|
import { markSchemaValidatedResponse } from "./internal-response.js";
|
|
68
|
+
import { readResponseBodyUpTo } from "./internal-body.js";
|
|
69
|
+
import { hasReplayScopes } from "./internal-replay.js";
|
|
68
70
|
/** Internal `ctx.state` key carrying the pending cache key between hooks. */
|
|
69
71
|
const PENDING_STATE_KEY = "__responseCachePending";
|
|
70
72
|
/**
|
|
@@ -420,6 +422,10 @@ function isPromiseLike(value) {
|
|
|
420
422
|
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
|
|
421
423
|
* failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
422
424
|
* {@link ResponseCacheOptions.maxBodyBytes} are never cached.
|
|
425
|
+
* The byte cap is enforced while reading the response clone; exceeding it
|
|
426
|
+
* stops capture without waiting for EOF or consuming the client's branch.
|
|
427
|
+
* Replays require every scope aggregated from the route's requireScopes hooks;
|
|
428
|
+
* callers without those scopes continue to the normal authorization chain.
|
|
423
429
|
*
|
|
424
430
|
* A response that declares `Vary` is stored as a **variant**: the request's
|
|
425
431
|
* values for those fields are recorded alongside it, and the entry is replayed
|
|
@@ -522,6 +528,8 @@ export function responseCache(opts = {}) {
|
|
|
522
528
|
}
|
|
523
529
|
const hooks = {
|
|
524
530
|
async beforeHandle(ctx) {
|
|
531
|
+
if (!hasReplayScopes(ctx))
|
|
532
|
+
return undefined;
|
|
525
533
|
const method = ctx.request.method.toUpperCase();
|
|
526
534
|
if (!methods.has(method))
|
|
527
535
|
return undefined;
|
|
@@ -641,8 +649,8 @@ export function responseCache(opts = {}) {
|
|
|
641
649
|
res.headers.set(statusHeaderName, "MISS");
|
|
642
650
|
return undefined;
|
|
643
651
|
}
|
|
644
|
-
const buf =
|
|
645
|
-
if (buf
|
|
652
|
+
const buf = await readResponseBodyUpTo(res.clone(), maxBodyBytes);
|
|
653
|
+
if (buf === null) {
|
|
646
654
|
if (statusHeaderName)
|
|
647
655
|
res.headers.set(statusHeaderName, "MISS");
|
|
648
656
|
return undefined;
|
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:e5e6316e-26ff-52ae-9520-4b82d88c8397",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-
|
|
7
|
+
"timestamp": "2026-09-08T07:08:54.894Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.3.
|
|
12
|
+
"version": "1.3.2"
|
|
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.2",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.3.
|
|
24
|
+
"version": "1.3.2",
|
|
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.2",
|
|
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.2",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.3.
|
|
51
|
+
"version": "1.3.2",
|
|
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.2",
|
|
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.2",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.2-e5e6316e-26ff-52ae-9520-4b82d88c8397",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-
|
|
8
|
+
"created": "2026-09-08T07:08:54.894Z",
|
|
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.2",
|
|
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.2"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
|
@@ -252,6 +252,8 @@ export interface WebhookSenderOptions {
|
|
|
252
252
|
* dead-letters a single {@link WebhookEvent}, resolving to a
|
|
253
253
|
* {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
|
|
254
254
|
* failure).
|
|
255
|
+
* Intermediate response bodies are cancelled before retry backoff without
|
|
256
|
+
* waiting for producer cancellation; the final response remains caller-owned.
|
|
255
257
|
*
|
|
256
258
|
* @example
|
|
257
259
|
* ```ts
|
package/dist/webhook-delivery.js
CHANGED
|
@@ -133,6 +133,8 @@ function randomId() {
|
|
|
133
133
|
* dead-letters a single {@link WebhookEvent}, resolving to a
|
|
134
134
|
* {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
|
|
135
135
|
* failure).
|
|
136
|
+
* Intermediate response bodies are cancelled before retry backoff without
|
|
137
|
+
* waiting for producer cancellation; the final response remains caller-owned.
|
|
136
138
|
*
|
|
137
139
|
* @example
|
|
138
140
|
* ```ts
|
|
@@ -277,6 +279,7 @@ export function createWebhookSender(options) {
|
|
|
277
279
|
});
|
|
278
280
|
if (!retryable)
|
|
279
281
|
break;
|
|
282
|
+
void response.body?.cancel().catch(() => undefined);
|
|
280
283
|
await sleep(delayMs);
|
|
281
284
|
continue;
|
|
282
285
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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": {
|
|
@@ -266,8 +266,8 @@
|
|
|
266
266
|
"red-team:live": "node --import tsx red-team-live/run.ts",
|
|
267
267
|
"red-team:live:mcp": "node --import tsx red-team-live/mcp-attacks.ts",
|
|
268
268
|
"red-team:live:wave2": "node --import tsx red-team-live/skill-wave2-attacks.ts",
|
|
269
|
-
"coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include
|
|
270
|
-
"coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include
|
|
269
|
+
"coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
|
|
270
|
+
"coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include=\"dist-coverage/src/**\" --test-coverage-branches=92 dist-coverage/tests/**/*.test.js",
|
|
271
271
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit && tsc -p red-team-live/tsconfig.json --noEmit",
|
|
272
272
|
"typecheck:tests": "tsc -p tests/tsconfig.json --noEmit",
|
|
273
273
|
"format": "prettier --write .",
|